Repository navigation
Replace libjpeg-turbo with a JPEG codec in safe Rust - #47074
Open
dylan-conway wants to merge 142 commits into
Open
dylan-conway wants to merge 142 commits into
dylan-conway wants to merge 142 commits into
Conversation
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>
Collaborator
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>
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.
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.
…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
…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.
… 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.
`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.
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.
…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.
…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.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Removes the dependency on libjpeg-turbo.
Bun.Imagedecodes and encodes JPEG withsrc/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 insrc/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.src/runtime/image/codec_jpeg.rskeeps 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.src/jpegcarries libjpeg-turbo'sLICENSE.mdandREADME.ijgunaltered.NOTICE.mdlists 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:
idct.rsdoes what those instructions do, lane by lane. The other sizes ran as C with a 64-bitlong.FF FF 00.huff.rshas both routines, and reads ahead exactly as far as they did, because that decides which one sees the bytes.tj3GetScalingFactorsdid.Changes in behaviour
TJPARAM_SCANLIMIT, which Bun never set. 500 is whatTJFLAG_LIMITSCANSstood for. In a corpus of 286,000 files from 47 encoders and tools the most is 18validate_scriptaccepts 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's182.jpgandsubtract-with-overflow.jpgof 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 scansERR_OUT_OF_MEMORYsrc/jpegparses the header once. Bun 1.4.4 runs out of time in the new test, three times out of threelong, 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 differJSIMD_FORCENONE,JSIMD_FORCESSE2andJSIMD_NOHUFFENCin the environment do nothing any moreCarried over on purpose, and open to a decision:
maxPixelsand the two rules on scans.maxPixelsis.jddiffct.c), not the standard's:djpegdecodes 56 of 84 conforming files of that kind differently from their source.cjpegnever 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.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 -arithmeticitself 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 inDecodeOptions. Peak memory is the same as libjpeg-turbo's: 3.08 GB for 135 bytes at the defaultmaxPixels.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.bench/image/bench-resize.mjson 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 = 3keeps debug builds usable: atopt-level0 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
mainat the commit that the branch has merged in).How did you verify your code works?
Against libjpeg-turbo itself.
misctools/jpegdiff(temporary) linksbun_jpegnext to libjpeg-turbo 3.2.0 built with Bun's patches and flags, SIMD on, and replays whatcodec_jpeg.rsdid 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 thatbun_jpegrefuses 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_jpegis 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 theFF FF 00case:cjpegof every IJG release from 4 to 10 and of libjpeg-turbo 1.0 to 3.2,jpegtranof 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 gluedecode_mcuagainst an instrumented libjpeg-turbo: 109 million streamsBun.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 themnew 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 leaksAgainst 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'schange.logfrom 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:
maxPixelsraised), 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 uninitialisedbit_pos[0]there,jchuff.c, also on itsmain.)NOTICE.mdleft out Guido Vollbeding, the notice ofjsimdext.incand seven headers.LICENSE.mdlacked the sentence the IJG license asks for.highway_jpeg.cpphas a list of Highway targets of its own, which is sound only whilehwy::ChosenTarget::GetIndex()is inlined. Without optimization the linker would merge it with other files' copies. It now refuses to compile that way.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 forto_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 andcodec_jpeg.rs, 886 changes of kinds that none of the earlier ones made (one side of a||or&&dropped, a loop or ananynarrowed to its first or last item or to all but it, areturn Errremoved, 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 throughresize(), 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.mdthere lists those and says where the files come from.image-kernels.test.ts: each Highway kernel, through a hook inbun:internal-for-testing, against libjpeg's formulas in JavaScript, in memory that ASAN watches (a test proves that it does). And each wrapper insrc/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.mainat cee9dd9 merged in:test/js/bun/image/andsimd-baseline.test.ts504 pass, 8 skip, with the tests of the PNG codec that came withmain. A release build of the same commit: the same tests 503 pass, 9 skip, andtest/internal/,test/aot/builtins/,bun-types.test.tsandtestaot run test/aot/engine-testspass. Intest/aot/engine/one of WebKit's own tests fails, as it does onmainbuilt 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_runtimepass.bun run rust:check-allpassed 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_jpeglinks the C++ kernels, so it has nocargo 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 withjpeg_gen_optimal_tableitself 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/jpegdifftakes 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. ThroughBun.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, andmisctools/jpegdiffitself 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.tslooked for libjpeg-turbo's messages in a trimmed executable. None ofbun_jpeg's texts is linked, so it looks for two of its tables.exif.rssearches for the next marker withindex_of_char_raw: the other function asserts, in a debug build, that the bytes hold still.To do
misctools/jpegdiff(it is worth keeping until the block kernels are done)bugs/in libjpeg-turbo/seed-corpora, which has no licenseFound on the way, the same in Bun 1.4.4 and not touched here:
Bun.Imagereads 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 slicedBun.file; nothing can stop a decode once it runs.Generated by Claude Code