Skip to content

Replace libjpeg-turbo with a JPEG codec in safe Rust - #47074

Open
dylan-conway wants to merge 142 commits into
mainfrom
claude/libjpeg-turbo-rust-17fiip
Open

dylan-conway wants to merge 142 commits into
mainfrom
claude/libjpeg-turbo-rust-17fiip

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Removes the dependency on libjpeg-turbo. Bun.Image decodes and encodes JPEG with src/jpeg (bun_jpeg, new, #[forbid(unsafe_code)]), a translation to Rust of the parts of libjpeg-turbo 3.2.0 that Bun reached through the TurboJPEG API. Five kernels that work on a row of samples (color conversion both ways, downsampling, upsampling) are C++ with Highway, as the repository's other SIMD is: src/jsc/bindings/highway_jpeg.cpp, behind safe wrappers in src/highway/jpeg.rs, compiled for SSSE3, AVX2 and NEON. An AVX-512 variant made a whole encode or decode no faster (35.4 against 34.4 ms, 26.4 against 26.8 ms on Cascade Lake) and is gone.

  • Decoder: 8-bit baseline, extended sequential and progressive, Huffman or arithmetic coded, lossless Huffman up to 8 bits (RGB-like and CMYK only: a gray lossless file was refused before and still is, because libjpeg-turbo converts a lossless frame to RGBA only from RGB), any sampling factors libjpeg takes, restart markers and resynchronization, block smoothing of incomplete progressive files, fancy upsampling, output at 1/8 to 8/8 of the size, RGBA or CMYK, ICC profile from APP2.
  • Encoder: RGBA to 4:2:0 YCbCr, baseline with the standard Huffman tables or progressive with optimal ones, ICC profile.
  • src/runtime/image/codec_jpeg.rs keeps its policy (the pixel limit, which M/8 scale a resize target picks, CMYK conversion, when the ICC profile is kept) and loses the FFI, 330 lines of it.
  • Deleted: the dependency, its two patches and the stub overlay, the AVX2 entries for its kernels in the baseline-verification allowlists, and nasm in the CI steps that only needed it for libjpeg-turbo (it stays for BoringSSL on Windows x64).
  • src/jpeg carries libjpeg-turbo's LICENSE.md and README.ijg unaltered. NOTICE.md lists the 63 files it derives from with the attribution of each header word for word, and says up to which upstream commit the history was checked for fixes.

The goal is no observable change, quirks included: the same streams decode, to the same pixels, the same streams are rejected, and the same pixels encode to the same bytes. Some of what that took:

  • The 8x8, 4x4 and 2x2 inverse DCTs ran as SSE2/AVX2 assembly on x86-64, which wraps and saturates in 16-bit lanes where the C code does not. For coefficients no encoder produces, the result depends on it. idct.rs does what those instructions do, lane by lane. The other sizes ran as C with a 64-bit long.
  • libjpeg-turbo decodes an MCU with a fast routine that gives up at anything that looks like a marker, and starts the MCU over with a slow one. The coefficients the fast one stored stay in the block, and the two disagree about FF FF 00. huff.rs has both routines, and reads ahead exactly as far as they did, because that decides which one sees the bytes.
  • A resize target picks the largest of the M/8 factors that round up to the same size, as the loop over tj3GetScalingFactors did.

Changes in behaviour

Change Why
A JPEG with more than 500 scans is rejected On purpose. Every scan costs a pass over the whole image and a scan without data is 10 bytes long. libjpeg-turbo's answer is TJPARAM_SCANLIMIT, which Bun never set. 500 is what TJFLAG_LIMITSCANS stood for. In a corpus of 286,000 files from 47 encoders and tools the most is 18
A JPEG with a scan of what an earlier scan has coded is rejected: a second scan of a component in a frame that is not progressive, or in a progressive frame a scan of a coefficient that does not add a bit to it On purpose. libjpeg-turbo decodes any scan that is well formed in itself, so the limit of 500 still left 500 full-price passes: see "What a hostile file can cost". The standard allows no such scan, and every script that libjpeg's own validate_script accepts passes (6,000 random ones tried). Of 554,250 files from 193 origins none that an encoder wrote is refused (56 origins). Apart from libjpeg-turbo's own fuzzing seeds and files damaged on purpose, 7 distinct files that used to decode with warnings are refused now, all of them a fuzzer's or a crash test's (among them Blink's 182.jpg and subtract-with-overflow.jpg of jpeg-decoder). Models of the rule written independently of the code, one of them 15 lines of C in a copy of libjpeg-turbo, agree with it on 31 million scripts of scans
Running out of memory in the JPEG codec rejects with ERR_OUT_OF_MEMORY libjpeg reported it as a failed decode or encode, and the output buffer aborted the process. Every allocation in the crate can fail. 12 files with huge dimensions, 5 operations, 8 limits on the address space: Bun 1.4.4 aborts in 242 of the 480 cases, this branch in none, and the same 220 succeed. Outside the codec (resize buffer, PNG, WebP) a failed allocation still aborts, as before
A decode ends even if JavaScript makes the height in the frame header smaller while the file is decoded The old glue had the header parsed twice, and TurboJPEG went on asking for rows up to the first height, which libjpeg stops delivering at the second: a thread of the pool stayed busy for good and the promise never settled. The docs forbid writing to the input meanwhile, but it must not hang. src/jpeg parses the header once. Bun 1.4.4 runs out of time in the new test, three times out of three
On arm64, a damaged or hostile file can decode to other pixels than before. Files that an encoder made decode as before The Neon kernels wrap and saturate at other places than the x86-64 ones. There is one implementation now, and it follows x86-64. Against libjpeg-turbo built for aarch64, under qemu: identical for every file made by an encoder and as long as coefficient times quantizer stays within ±1023. Of 31,560 results from 3,945 damaged files, 945 differ, never in size or in whether the file decodes
On Windows, a hostile file can decode to other pixels than before, at 3/8, 5/8, 6/8 and 7/8 of the size and in block smoothing That code ran as C with long, which has 32 bits on Windows. There is one implementation now, and it follows the 64-bit one. Of the same 31,560 results, 92 differ
JSIMD_FORCENONE, JSIMD_FORCESSE2 and JSIMD_NOHUFFENC in the environment do nothing any more They were libjpeg-turbo's debugging aids. The behaviour is that of the old default
Speed: see below The DCTs and quantization were hand-written SIMD that picks AVX2 at run time. Here they are loops the compiler vectorizes for the build's target, which is Nehalem

Carried over on purpose, and open to a decision:

  • No limit on memory or time other than maxPixels and the two rules on scans.
  • An image of more than 2^31 - 1 pixels fails to decode however high maxPixels is.
  • Files whose sampling factors TurboJPEG could not name decode at full size even for a thumbnail.
  • An ICC profile of more than 255 segments is written with wrapped sequence numbers.
  • In a lossless scan of one component with a vertical sampling factor above 1, a restart inside a row of MCUs starts the prediction over in the wrong row. That is libjpeg-turbo's behaviour (jddiffct.c), not the standard's: djpeg decodes 56 of 84 conforming files of that kind differently from their source. cjpeg never writes a subsampled lossless file.

What a hostile file can cost

An earlier version of this description called 15.4 s the most expensive file at the default maxPixels. That was wrong by three orders of magnitude, for libjpeg-turbo as for this code, which take about the same time on all of these. An arithmetic decoder goes on deciding after its data has run out, as the standard wants, and 150 bytes can train its statistics so that every later block costs 2,100 decisions.

At 16383x16383, four components, CPU time on a 2.5 GHz Xeon before now
arithmetic, 500 scans, 60 to 90 KB 6 to 24 hours (extrapolated from 2 and 3 scans) refused at the second scan
arithmetic, 500 scans without data, 8 KB 13 to 19 minutes refused
lossless, 500 scans, 8 KB 7 to 8 minutes refused
Huffman, 500 scans without data, 8 KB about 1 minute refused
arithmetic, one scan, 300 to 400 bytes 1 to 3 minutes, depending on the training the same
up to 500 scans of which none repeats another the same Huffman 17 s (5 KB, no data) to 1 minute (250 KB); arithmetic 75 to 85 s (5 KB, no data), 133 to 162 s and 3.2 GB with trained statistics (5.7 KB, 486 scans; libjpeg-turbo with the same rule 146 to 152 s). Measured on other, loaded machines

A coefficient has a first scan and up to 13 that add a bit, so a script of 500 scans can still keep to the rule, but each bit is now decoded once: more than half of that time is one dense pass. All of it is spent before any output, so it costs the same for a thumbnail. What is left is the arithmetic decoder going on after the end of its data, and I have no fix for it that I would defend. A limit on the zero bytes made up after the end of the data changes the pixels of files that cjpeg -arithmetic itself writes: it drops trailing zero bytes, 131,055 of them for a 4096x4096 ramp. Candidates: a budget of decisions per block once the data has ended (a prototype passes all 390 legitimate arithmetic files at hand and turns 174 s into 9 s), a lower pixel limit for arithmetic files, a lower limit on scans (100 would refuse one more known file, a stress test of Chromium's) or a limit on blocks visited over all scans, a deadline in DecodeOptions. Peak memory is the same as libjpeg-turbo's: 3.08 GB for 135 bytes at the default maxPixels.

Performance

The codecs alone, 3000x2000 synthetic photograph, one thread, taking turns. Column 1: AMD EPYC (Zen 4), both compiled as Bun's release build does (-O3, Nehalem, ThinLTO across both languages), at 63485ca. Column 2: Intel Xeon (Cascade Lake), the harness's build (no LTO, libjpeg-turbo's C at -O2, which flatters this code a little), machine not idle, at 0f31846.

time against libjpeg-turbo Zen 4 Cascade Lake
encode q80 / q95 1.38x / 1.27x 1.5x to 1.6x / 1.4x to 1.5x
encode q80 progressive 0.88x 0.95x
decode q80 at 8/8, 4/8, 1/8 1.07x, 0.99x, 0.98x 1.13x, 0.98x, 1.02x
decode q95 at 8/8, 4/8, 1/8 0.93x, 0.87x, 0.89x 1.03x, 0.92x, 0.96x
decode q80 progressive at 8/8, 4/8, 1/8 0.88x, 0.85x, 0.82x 0.89x, 0.88x, 0.84x
decode 4:2:2 / 4:4:4 1.14x / 1.24x
decode lossless 1.10x to 1.15x at b01c1cf (1.45x until dbd2eda). Where one loop lands in memory moves it by 8%

bench/image/bench-resize.mjs on the Zen 4 machine, Bun 1.4.3 against a release build: 1.02x to 1.16x, one case (4K JPEG to 1080p JPEG) 1.19x to 1.28x.

On files that nobody made to be slow, a sample of 4,900 from 193 origins, those on which libjpeg-turbo takes a million cycles or more (loaded machine, at b01c1cf): at 8/8 the median is 1.03x in cycles and 1.01x in CPU time, the 99th percentile 1.41x, the most 1.57x; at 3/8 0.93x and 0.92x, 1.34x, 1.43x. Peak memory: median 1.00x for small and for large files, the most 1.32x (65,500 pixels wide and 9 high). Encoding a picture 65,500 wide takes 3 MB more. Files with no data at all take up to 2.2x at 2/8. Very narrow and tall subsampled images take up to 2x, a few milliseconds.

The five Highway kernels take as long as libjpeg-turbo's assembly or less. What is left of the gap is the kernels that work on a block and are still loops for the vectorizer: alone, the 8x8 inverse DCT takes 163 cycles against 86 and the 4x4 112 against 53, and by one profile a quarter to nearly a half of a sequential encode goes into the two transpositions of the forward DCT. Writing those with Highway too is the next step, and not in this PR. Speed is not measured on arm64 or Windows. [profile.dev.package.bun_jpeg] opt-level = 3 keeps debug builds usable: at opt-level 0 the crate takes 20 to 60 times as long.

Where the stack lies can matter to the sequential encoder, though not in the build of Bun that I could measure. The forward DCT hands a block from stage to stage through four buffers of 128 bytes on the stack, and nothing in the source aligns them. In a build of the crate alone (Zen 4) the compiler aligns two of them by 8 only. At the 16 of the 256 places of the stack within a page at which one of those two straddles a page, a flat 4000x3000 picture takes 1.12x to 1.17x as long, because a 16-byte access is split between pages; an independent measurement with another toolchain found 1.25x to 1.4x at 8 places and 1.1x to 1.25x at 16, which are exactly the places at which one of the three buffers that build aligns by 8 straddles a page. The worst of them is used six times for a block. With all four aligned by 16 that band is gone, but the wrapper type I tried it with cost more than it saved (30.7 ms against 26.0 ms), so it is no fix as it stands. In a release build of Bun on the same machine the compiler aligns all four by 16. Encoding runs on worker threads, so there the place is the same in every run of one build, and moving it through 239 of the 256 places changes the time by at most 2% (flat) and 4% (like a photograph). I could not look at the binaries that CI builds. Kernels that keep the block in registers would end this.

Every release binary gets smaller, by 131 to 328 KB: Linux x64 -308 KB, Linux aarch64 -256 KB, macOS x64 -306 KB, macOS arm64 -194 KB, Windows x64 -284 KB, Windows arm64 -182 KB (#136360 against the build of main at the commit that the branch has merged in).

How did you verify your code works?

Against libjpeg-turbo itself. misctools/jpegdiff (temporary) links bun_jpeg next to libjpeg-turbo 3.2.0 built with Bun's patches and flags, SIMD on, and replays what codec_jpeg.rs did at the commit before this branch. Decoding compares pixels, dimensions, ICC profile, and whether and at which stage it fails, at all 8 scales. A stream that bun_jpeg refuses for a redundant scan and libjpeg-turbo decodes is counted and not compared: about 2% of the generated streams (see "Not covered"). Encoding compares bytes. bun_jpeg is compiled with overflow checks and debug assertions. Three independent fuzzing efforts build on it, with generators, harnesses and corpora of their own. Over the life of the branch, with no difference since the first 22 million mutations found the FF FF 00 case:

  • more than 14 billion comparisons of generated streams, 1.6 billion mutations, 1.9 billion encoded images, about 350 million executions of libFuzzer and AFL++, 17 billion single inverse DCTs against the SSE2 and AVX2 kernels, quantization and both color conversions exhaustively
  • 554,250 files from 193 origins: cjpeg of every IJG release from 4 to 10 and of libjpeg-turbo 1.0 to 3.2, jpegtran of 17 releases, encoders that share no code with IJG (the ISO reference software, PVRG, ffmpeg, Guetzli, Go, Rust, Java, jpeg-js, stb, jpge), 79 other projects' test suites, JPEG streams out of PDFs, DICOM files and thumbnails: 30 million comparisons through copies of the old and the new glue
  • all 69,904 sampling layouts; every prefix, every value of every byte and every marker at every position of 115 small streams of all six processes (220 million comparisons)
  • the decoder's state after every decode_mcu against an instrumented libjpeg-turbo: 109 million streams
  • through Bun.Image, Bun 1.4.4 against builds of this branch: more than 35 million cases, among them 140,400 files that libjpeg-turbo's API wrote at every width from 1 to 300 in 36 configurations and 117,000 encodes at the same sizes, 23 ways to pass the input and 18 to take the output, among them new Response(image), which runs on the JS thread; inputs flush against unmapped pages; pools of 2 to 1,024 threads; images of 2.15 gigapixels; inputs of 6 GiB; 30 scenarios of 300,000 operations for leaks
  • more than 140 million runs with allocations made to fail: an out-of-memory error every time
  • ASAN, UBSAN, MSAN, TSAN, valgrind; i686, armv7, s390x, aarch64 with the NEON kernels, wasm32: the same results
  • the input rewritten by another thread during the decode, as JavaScript can: about 300 million decodes without a panic, by three tools, each of which finds the one bug of that kind that this branch had when its fix is taken out
  • mutation testing of the crate, the kernels and the wrappers: every change that survives was read and changes nothing that can be observed, or is named below as a gap that is now closed

Against the history. For each part of the code, every upstream commit that touched the files it derives from was read and classified, on all branches and IJG's releases: between 61 and 467 commits for each of 8 parts, up to the 57 after 3.2.0. No fix is missing. So were all 399 entries of ChangeLog.md, IJG's change.log from 6b to 10, 51 CVEs against libjpeg-turbo, IJG, mozjpeg and the copies in Chromium, Android, DCMTK, GDCM and the distributions (11 carried over, 17 cannot arise, 23 in code that was not ported; for CVE-2016-6702, CVE-2020-0207, CVE-2017-8826 and CVE-2018-5252 I could not find out what the flaw was), and 136 CVE, GHSA and RUSTSEC ids of other JPEG decoders in 48 classes of mistake, each with crafted files (about 100,000 files at 8 scales for the two). A second table by one of the fuzzing efforts has 702 rows in 44 groups and 50 million comparisons. Its crafted files were tried on 37 old releases of libjpeg-turbo and IJG under ASAN, UBSAN and MSAN: of the 54 bugs whose code has a counterpart here, 39 show themselves there and not in 3.2.0, and the other 15 are of kinds that a sanitizer cannot show (cost and limits, assembly, 32 bits) or, for one, were not tried. libjpeg-turbo's own reproducers (277) and AFL test cases (2,996) give the same results as 3.2.0.

A review of the whole PR, part by part, each line against its counterpart in C or assembly. No difference from libjpeg-turbo, panic, hang or memory error in the code as it was. What it did find, all fixed here:

  • Above a gigapixel (maxPixels raised), a symbol that occurs more than 10^9 times in a progressive scan got no Huffman code, and the encoder returned a corrupt file where libjpeg-turbo fails. (libjpeg-turbo itself indexes with an uninitialised bit_pos[0] there, jchuff.c, also on its main.)
  • Once the encoder's output could not grow, it went on to the end of the image, asking the allocator again at every write: 13 s instead of 0.1 s.
  • The cost of hostile files above, and the wrong claim about it.
  • Lossless decoding took 1.45x as long.
  • NOTICE.md left out Guido Vollbeding, the notice of jsimdext.inc and seven headers. LICENSE.md lacked the sentence the IJG license asks for.
  • highway_jpeg.cpp has a list of Highway targets of its own, which is sound only while hwy::ChosenTarget::GetIndex() is inlined. Without optimization the linker would merge it with other files' copies. It now refuses to compile that way.
  • 100 lines of checks that cannot fail, state that can be computed and copies are gone, and in a second round what the four entropy decoders, the two controllers and the two progressive decoders each had a copy of. Comments are one line. One of those checks turned out to be needed when JavaScript rewrites the input between two reads of the same bytes. The new test for exactly that found it, and it is back.
  • 95 small changes to the code that alter pixels or outcomes passed the tests in the repository, which are all that is left once the harness goes. 152 small files and about 45 tests close those gaps. A later sweep over the entropy decoders, the coefficient controller and part of the IDCT found 12 more such changes, and 632 of the 3,648 ways to flip one bit of the arithmetic decoder's table. 14 more files close those: Bun built with each of the 12, and with three of the flips, fails the golden test on a new file and on no old one. The sweep then went over the rest of the crate and found 25 more in the decoder and 5 in the encoder, which 18 more files and 102 more pictures close, shown in the same way. A second sweep of the whole crate, with 308 changes of kinds that the first did not make, found 2 more, and 2 more files close them. A sweep over the kernels and their wrappers, 464 changes, found nothing in the kernels. In src/highway/jpeg.rs, a debug build with ASAN passed every image test with any of 10 changes that take away a check that keeps a kernel inside its rows, and with any of 10 that make a wrapper leave to the scalar code what a kernel should do: the kernel tests call the C++ functions themselves, and the codec gives the wrappers room enough and, but for to_ycc, rows of one length. A second hook now calls the wrappers with rows of any lengths, and all 20 fail there, on a debug build with ASAN and on a release build. So do 35 more that were tried later, among them every length and every bound off by one. A last sweep over the crate and codec_jpeg.rs, 886 changes of kinds that none of the earlier ones made (one side of a || or && dropped, a loop or an any narrowed to its first or last item or to all but it, a return Err removed, an alternative of a pattern dropped), found 14 more in the decoder. 11 more files close them: Bun built with each of the 14 fails the golden test on its new file and on no other. Of 77 further changes that a larger set of inputs had caught only late, the tests in the repository catch 60 (all 39 in the encoder by the 466 pictures, and 21 in the decoder, 16 of those by nothing but the damaged copies of the golden files), 15 change only which error a file fails with, and 2 more files close the last 2, shown in the same way.

Tests in the repository.

  • test/js/bun/image/jpeg-golden: 868 files, each at all 8 scales through resize(), 3 damaged copies of each at 2 scales, pixels and ICC profile, and 466 generated pictures encoded in both modes, at every quality. The expected results were recorded with Bun 1.4.4 (1.4.3 records the same), which used libjpeg-turbo, but for the 20 that the rule on redundant scans changes. README.md there lists those and says where the files come from.
  • image-kernels.test.ts: each Highway kernel, through a hook in bun:internal-for-testing, against libjpeg's formulas in JavaScript, in memory that ASAN watches (a test proves that it does). And each wrapper in src/highway/jpeg.rs, through a hook in safe Rust (src/runtime/image/jpeg_rows_testing.rs), with rows of different lengths and too little room, against the kernel on the shortest length.
  • simd-baseline.test.ts, which CI also runs under an emulated Nehalem: pinned bytes and pixels, a 4:2:2 and a YCCK file.
  • image-adversarial.test.ts, image.test.ts: both rules on scans, DC predictions at the ends of 32 bits, 65500 and 65501, 2^31 - 1 pixels, ICC profiles around the size of a segment, photographs, a JPEG rewritten while it is decoded, as a whole and in its height alone.
  • Debug build with ASAN at 07da79c, which has main at cee9dd9 merged in: test/js/bun/image/ and simd-baseline.test.ts 504 pass, 8 skip, with the tests of the PNG codec that came with main. A release build of the same commit: the same tests 503 pass, 9 skip, and test/internal/, test/aot/builtins/, bun-types.test.ts and testaot run test/aot/engine-tests pass. In test/aot/engine/ one of WebKit's own tests fails, as it does on main built without this PR. The harness on b01c1cf, since when nothing in the codec but a comment has changed: the 1,037,127 files that the review crafted at 8 scales, 6 million mutations, 200,000 encodes, no difference. test/internal/source-lints/, bun-types.test.ts, cargo clippy -p bun_jpeg -p bun_highway -p bun_runtime pass. bun run rust:check-all passed at 5093d08 and was not repeated.

Not covered: real arm64 and Windows machines other than by CI. No test in the repository makes an allocation fail or reaches the three refusals of the encoder's table generation (bun_jpeg links the C++ kernels, so it has no cargo test); the harness does both. Eight small changes to what that table generation does with codes of 31 to 33 bits show only in pictures of 10 to 26 megapixels, too large for a test; the function was compared with jpeg_gen_optimal_table itself on 889 million tables of frequencies, without a difference. The independent fuzzing uses rustc 1.98.1 with LLVM 21, not Bun's pinned nightly; my own runs use the pinned toolchain.

misctools/jpegdiff takes every refusal for a redundant scan as intended, without checking that the stream has such a scan, and it does not check that a stream which decodes has none. One of the three fuzzing efforts never went by that: its reference is a copy of libjpeg-turbo with the rule patched in, and a refusal on one side only is a mismatch. The other two did until 30 October, so their runs up to then do not show that the rule fires when it should and only then. They now hold every stream against a model that reads the stream's bytes, both ways, and have found no mismatch. In the harness: 15 million generated streams with 334,787 refusals, 3 million mutations with 47,033, and the regression sets on 7 builds. Through Bun.Image: 86 million new cases against Bun 1.4.4 with 14.5 million refusals, 143 million cases of the exhaustive suites (every change of one byte), and 130 million inputs of earlier runs made again, of which the model calls 5.6 million redundant and none decodes. Both find wrong versions of the rule that were put in on purpose, 3 and 9 of them. Runs whose inputs cannot be made again have not been repeated, and misctools/jpegdiff itself does not have the model.

The tests in the repository had gaps of their own here. Of 22 wrong versions of the rule, Bun built with each, 4 passed every image test: one that lets a second first scan of a coefficient through if it goes down to a lower bit, one that looks at the first coefficient of a band only, and two that look at the first or the last component of a scan only. 6f83dfd adds the cases that tell them apart. All 22 fail now, each of the four on new cases only, and Bun 1.4.4 fails every new case.

Also in this PR

  • test/bundler/compile-trim-by-use.test.ts looked for libjpeg-turbo's messages in a trimmed executable. None of bun_jpeg's texts is linked, so it looks for two of its tables.
  • exif.rs searches for the next marker with index_of_char_raw: the other function asserts, in a debug build, that the bytes hold still.

To do

  • Remove misctools/jpegdiff (it is worth keeping until the block kernels are done)
  • Performance: the kernels that work on a block
  • 32 of the golden files come from bugs/ in libjpeg-turbo/seed-corpora, which has no license

Found on the way, the same in Bun 1.4.4 and not touched here: Bun.Image reads freed memory if JavaScript transfers the input's buffer or grows the WebAssembly memory it is in during a decode, with every format; new Response(image) ignores the slice of a sliced Bun.file; nothing can stop a decode once it runs.


Generated by Claude Code

claude and others added 7 commits October 27, 2026 11:32
Deletes the dependency, its patches and the allowlist entries of its AVX2
kernels. The build now fails to link: codec_jpeg.rs still calls 14 tj3*
functions. The next commits replace them.

Co-authored-by: dylanc <dylanc@anthropic.com>
Reads what Bun could read through TurboJPEG: baseline, extended sequential and
progressive streams, Huffman or arithmetic coded, lossless Huffman streams of
up to 8 bits, at 1/8 to 8/8 of the size, to RGBA or CMYK. Not wired up yet, so
the build still fails to link.

Co-authored-by: dylanc <dylanc@anthropic.com>
RGBA to 4:2:0 YCbCr, baseline with the standard Huffman tables or
progressive with optimal ones, with an optional ICC profile. The output
is byte for byte what tj3Compress8 produced.

Co-authored-by: dylanc <dylanc@anthropic.com>
Bun links again. codec_jpeg.rs keeps its policy (the pixel limit, which
M/8 scale a resize target picks, CMYK conversion, when the ICC profile is
kept) and loses the FFI.

Co-authored-by: dylanc <dylanc@anthropic.com>
To be removed before this is merged.

Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
@robobun

robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 3:05 AM PT - Nov 1st, 2026

❌ @claude, your commit 07da79c has 38 failures in Build #136360 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47074

That installs a local version of the PR into your bun-47074 executable, so you can run:

bun-47074 --bun

autofix-ci Bot and others added 22 commits October 27, 2026 12:44
Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
The inputs of the tests to come.

Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
…n libjpeg-turbo

Co-authored-by: dylanc <dylanc@anthropic.com>
metadata() no longer copies it. APP2 segments are borrowed from the input
and those after the first scan, which nothing read, are skipped.

Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
…rust-17fiip

Co-authored-by: dylanc <dylanc@anthropic.com>
Marker codes, the DCT's and the colour conversion's fixed-point numbers and
fallible allocation were each written more than once. Names instead of bare
bools and pairs where a lint asked for them.

Co-authored-by: dylanc <dylanc@anthropic.com>
A transform of one dimension is now scalar code for one column, run on every
column by a loop that is alone in its function. Before, most of the 8x8
inverse DCT came out as scalar code. Blocks with only a first row or only
low frequencies skip the sums of zeros.

3000x2000, -Ctarget-cpu=nehalem: decoding q80 48 to 32 ms, at 1/2 27 to 19 ms,
encoding q80 72 to 54 ms.

Co-authored-by: dylanc <dylanc@anthropic.com>
A block keeps its coefficients in row-major order, with a bit for each in
zigzag order that says whether it is zero. The Huffman coders walk the bits
instead of the 63 coefficients, which a progressive file goes through twenty
times. The colour conversion takes pixels apart by shifts, which vectorizes.

3000x2000, -Ctarget-cpu=nehalem: q80 54 to 32 ms, progressive 217 to 111 ms.

Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
3000x2000 q80 at 7/8: 73 to 46 ms, at 6/8: 55 to 39 ms, at 5/8: 41 to 34 ms.

Co-authored-by: dylanc <dylanc@anthropic.com>
…r-ed with their values

Co-authored-by: dylanc <dylanc@anthropic.com>
…y a local

Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
Co-authored-by: dylanc <dylanc@anthropic.com>
…e tests let through

What 4d06b29 did for the entropy decoders, for the rest of src/jpeg. Each of these changes passed the 835 files with
their damaged copies and the 364 pictures. Bun built with it now fails jpeg-golden.test.ts on a new file or picture, and
on no old one:

- decode/phuff.rs: in a refinement scan, a run of 16 zeros that goes past the band writes a zero after it
- decode/mod.rs: 65500 pixels a side are too many, or the height has no limit; 3 or 5 Huffman tables; 5 quantization
  tables; 11 blocks in an MCU; three components of 1x3 are not scaled; lossless precision 17 passes the header; the
  height of a subsampled lossless component
- decode/marker.rs: APP15 is unknown; a second SOF; a scan of no component; a DAC whose Kx is not kept, or that loses
  bit 2 of L; tables 2 and 3 are taken for AC tables; an APP2 that is no profile is not skipped; a segment is taken
  for a byte shorter; ICC pieces "1 of 1" and "2 of 2" make a profile
- markers.rs: another value for SOF7
- encode/mod.rs: a quality scales the steps with + 51 and not + 50, which shows at 14, 18, 22, 31, 33 and 41 only
- encode/phuff.rs: a refinement scan begins with a bit waiting
- encode/huff.rs: the search for a shorter code to split takes two steps at a time, or none

The pictures are one of 1x1 at every quality, one that gets a run of empty blocks to exactly 937 waiting bits, and one
whose optimal table has a code of 18 bits. What is expected of the old files and pictures has not changed.

Not closed: what optimal_table does with codes of 31 to 33 bits shows only in pictures of 10 to 26 megapixels.

In the helpers: an error without a code is thrown again and not recorded as an outcome; readPng says so if a PNG is not
8-bit RGBA; metadata() is asked without auto-orientation, as the decodes are; record.ts does not take what is expected
of a new file or picture from the code under test; a comment promised every size one quality too many. Recording
everything again after that gives the same fixtures.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

bun test does not kill the child of a concurrent test that times out. On a build whose decoder never
returns, "height of a JPEG" therefore left a process behind that kept several cores busy after the run.
The children of the race tests now exit with the test process, as in s3-networksink-leak.test.ts.

The search for the frame header in that test now throws past the end of the file instead of going on
for ever.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…rough

Bun built with either change passed every image test. It now fails the golden test, on the new file and
on no other.

- decode/bits.rs, catch_up: `unsigned_abs()` replaced by `cast_unsigned()`, so that the reader does not
  fill after a negative value: huffman/padded_ff_catch_up_negative.jpg
- decode/mod.rs, downsampled_height: `div_ceil` replaced by `div_euclid`:
  lossless/ll_mixed_sampling_height_3.jpg, 3 rows with vertical factors of 2, 2, 4 and 1

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…rent lengths

src/highway/jpeg.rs holds the codec's only unsafe code. What keeps a kernel inside its rows is that each
wrapper goes by its shortest row and looks at the room in its output, and hardly any test got there: the
kernel tests call the C++ functions themselves, and the codec gives the wrappers room enough and, but for
`to_ycc`, rows of one length. A debug build with ASAN passed every image test with any of 10 changes that
take such a check away (`.min` to `.max`, `||` to `&&`, `out.len() >= n` or the whole condition removed),
and with any of 10 that make a wrapper leave to the scalar code what a kernel should do.

A second hook in bun:internal-for-testing, in safe Rust, calls each wrapper with rows of any lengths, and
image-kernels.test.ts compares what it answers and writes with the kernel on the shortest length. All 20
fail there, on a debug build with ASAN and on a release build, as do the 3 in `to_ycc` that only ASAN saw.
`Bun.file().bytes()` gives a `Uint8Array<ArrayBuffer>` and `Bun.Image#bytes()` does not, so the run of
image.test.ts as compiled code stopped with TS2322 and ran none of it. It now runs: 130 pass, 5 skip.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

… node:http tests

`objects-closed-through-others.ts` failed on macOS x64 in CI on this branch. It and two
programs like it, `objects-closed-in-other-ways.ts` and
`frames-of-stores-closed-objects-refuse.ts`, fail on macOS on other branches too. The
server drops the connection after the exchange that the test breaks on purpose. On a
busy machine the client sends the next request on that connection before it notices, so
`fetch` rejects, and one of the two runs that are compared prints a `TypeError` more
than the other.

A request that fails before the handler ran is now sent again, up to three times. A
request that the handler did see is judged as before.

The three files are as in #47316, byte for byte, so that they merge without a conflict.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

`frozen-sealed-and-closed-objects.ts` and `frozen-sealed-and-closed-heirs.ts` ask the same
kind of server in the same way, so they can fail as the three of the commit before did.
Neither has been seen to.

The first is as in #47316, byte for byte. The second is not changed there. Its counter is
called `resent`, because the file has a function `attempt`.
`test/aot/build-scaling/build-scaling-2.test.ts` failed all four attempts on macOS arm64
in CI on this branch: "twice as many functions cost 2.59 times the peakBytes; allowed:
2.5", and 2.61 at one attempt. The same compiled code measured 2.42 on another machine
four hours earlier. The growth came with #47281 (mimalloc 3.6.0), which the merge of
`main` brought in.

The growth is now in the file of budgets, which makes the bound 2.84. And `AOT_BUDGETS`
keeps a growth that the machine that writes the file does not judge.

The two files are as in #47329, byte for byte, so that they merge without a conflict.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

dylan-conway pushed a commit that referenced this pull request Oct 30, 2026
…r in eleven types

`test/aot/engine/ir-6.test.ts` fails on `main` since 73a2043 (#47087), on
Alpine, on Windows and in the ASAN build. There the engine prints
`builtin(Object)|builtin(FunctionPrototype)|builtin(ObjectPrototype)|..` where
this fixture expects `builtin(Object)|builtin(ObjectPrototype)|..`. The eleven
lines with that type get a `{{*}}` after `builtin(Object)|`.

This is the change that #47074 makes to the file, byte for byte. With it, that
PR's build passes on all those lanes. It has nothing to do with the rest of
this branch, which touches neither the AOT engine nor its tests.
… got through

Bun was built with each of 22 wrong versions of the rule (`start_progressive_scan` in decode/phuff.rs and
`start_input_pass` in decode/output.rs) and run against image-adversarial, jpeg-golden and image.test.ts.
Four passed all three: without `ah == 0 ||`, so that a second first scan of a coefficient gets through if it
goes down to a lower bit; looking at the first coefficient of the band only; and looking at the first, or at
the last, component of a scan only, which nothing could tell because the progressive cases have one component.

Three more scripts of scans, the test with three components for progressive frames too (Huffman and
arithmetic), and two more cases in it, with the component that comes again first and last in its scan. All 22
fail now, each of the four on new cases only. Bun 1.4.4, which decodes such scans, fails every new case.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…t nothing noticed

A sweep with kinds of changes that the earlier ones did not make went over src/jpeg and codec_jpeg.rs: one side
of a `||` or `&&` dropped, a condition made false, a loop or an `any` over the first or the last item only or
over all but it, a `return Err` removed, an alternative of a pattern dropped. 886 changes. 14 of them change
what a file decodes to and still passed the golden files and every stream that image-adversarial.test.ts and
image.test.ts build:

- marker.rs: a scan that names a component twice is found only if it is the first or not the last of those before
  it (2); a piece of an ICC profile may have a number above the count
- output.rs: sampling factors that do not divide the largest are refused in one direction only (2); of a row group
  of two rows, h2v2 and h1v2 upsampling do the first or the last only (4); the rows around an iMCU row are kept
  only if a component other than the last needs them
- phuff.rs: a correction bit moves a coefficient that has the bit already
- coef.rs: whether smoothing is of use is decided without coefficient 1, or by it alone (2)
- mod.rs: chroma of 1x2 or 2x1 counts as sampling that TurboJPEG has a name for whatever the first component has

Eleven files of 31 to 229 bytes tell them apart. What is expected of them comes from Bun 1.4.4, and this branch
gives the same. Bun built with each of the 14 fails the golden test on its new file and on no other file.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…rust-17fiip

main replaced libspng with bun_png (#47070) in the places where this branch replaces libjpeg-turbo with bun_jpeg:
the lists of dependencies, Cargo.toml, the tables of licenses, the image docs and types, codecs.rs and three test
files. Both libraries are gone from the lists, and both crates and both files of Highway kernels stay.

Everything under test/aot is main's. #47316 wrote custom-accessor-reached-computed-key.ir anew without the unions
that depend on ids, so the wildcards are not needed, and it has the retry in the node:http programs. The recorded
growth of one shape of build-scaling is dropped, because main recorded that shape's budget again.
…e one too high

Of 77 small changes to src/jpeg that a larger set of inputs had caught only late, the tests in the repository
catch 60: all 39 in the encoder by the 466 pictures, and 21 in the decoder, 16 of those by nothing but the damaged
copies of the golden files. 15 change only which error a file fails with, which Bun does not show. Two are left:

- M_RST7 as 0xd8: an FF D8 where a restart marker is due is dropped, and the file decodes where it has to fail
- M_APP15 as 0xf0: an FF F0 segment is skipped like an APPn segment, and the header is read where it has to fail

A file of 125 and one of 129 bytes tell them apart. What is expected of them comes from Bun 1.4.4, and this branch
gives the same. Bun built with either change fails the golden test on its new file and on no other file.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants