JPEG XL, One Year Later

โšก Chromium ๐Ÿ”ง Rust / C++ / Images ๐Ÿ‘ค Helmut Januschka

Getting a decoder into Chrome is one milestone. Making it behave correctly while the network delivers an image a few kilobytes at a time is the next one.

Status: ๐Ÿšง Ongoing

After the Comeback

The original JPEG XL return to Chrome was about integrating a memory-safe Rust decoder. Since then the work has looked less like a comeback story and more like ordinary browser engineering: partial input, paint requests arriving before headers, threading, MIME sniffing, and fuzzers.

That is a good sign. A codec starts feeling native when its bugs are the same unglamorous bugs every mature decoder has to solve.

Making Fuzzing Part of the Upstream Loop

The biggest missing part of this story was fuzzing. Chromium already runs image decoders against hostile inputs, but fixing every finding only in the browser would leave the underlying Rust crate vulnerable for every other embedder.

The first step was adding ClusterFuzzLite to jxl-rs: short AddressSanitizer fuzz runs on pull requests and longer scheduled runs against the existing decode targets. It paid for itself almost immediately.

The first wave found arithmetic and allocation assumptions hidden behind valid Rust types:

Rust prevents these from becoming ordinary out-of-bounds writes or use-after-free bugs. It does not automatically make a panic or an infallible multi-gigabyte allocation acceptable in a renderer. A browser decoder still has to turn arbitrary bytes into either pixels or a controlled error.

From Crashes to Invariants

Later findings were less about one arithmetic operation and more about decoder invariants:

The last issue came from a 9-byte truncated codestream whose TOC claimed a 743 MB section. Section buffers now grow from bytes actually available, not declarations made by untrusted input.

That last issue came from Chromium's blink_jxl_decoder_fuzzer, but the reproducer and fix live in jxl-rs. This is the feedback loop I wanted: Chromium supplies production-scale fuzzing; the generic fix goes upstream; the next crate roll brings it back to Chromium.

Fuzzing Thread Schedules Too

Once multi-threaded decoding entered the picture, bytes were no longer the only input worth fuzzing. Shuttle explores thread schedules deterministically, turning rare races into replayable tests.

That work found reentrant locks, incorrect read/write lock ownership, schedule-dependent border rows, and progressive output that depended on group completion order:

This is the less visible half of enabling a parallel runner. The happy path getting faster is useful; proving that output does not change with a different interleaving is what makes it shippable.

The 4096-Byte Stall

Progressive images should render as data arrives. A page should not need the full file before showing the first useful pixels.

The jxl-rs codestream parser eagerly reads input into a 4096-byte internal buffer while parsing headers. Chromium's decode loop saw that all external input had been consumed and stopped calling process(). But jxl-rs still had buffered bytes to drain. The result: no partial image until the network delivered more than the first 4096 bytes.

The fix is subtle but simple: external input being empty does not mean the decoder has no work. Keep calling process() while the internal parser can make progress.

A related bug happened at the other end of initialization: Chromium tried to flush partial pixels before the decoder had selected a pixel format. jxl-rs correctly aborted at an internal unwrap(). The browser now waits until basic image information exists before flushing.

Paint Can Arrive Before the Header

Browser image decoders do not control every call order. Paint code can ask whether an image repeats before enough bytes have arrived to parse its basic header.

The JXL path used to CHECK that basic_info_ existed. A valid streaming sequence could therefore crash the renderer before the header was complete. Returning kAnimationNone until the information exists is both safe and consistent with an image whose animation state is not known yet.

More Cores, Same Decoder

Large images should not decode on one core when the Rust decoder already supports parallel work. The next performance step is wiring jxl-rs's parallel runner into Chromium's decode path rather than implementing a second threading model around it.

The decoder itself also moved forward through the normal Rust roll from 0.4.3 to 0.5.1.

The Less Visible Chromium Work

MIME sniffing now handles complete image data shorter than the longest known signature, stale web-test expectations are gone, and the feature flag remains available while the implementation matures. The Chromium AVIF and JXL fuzzers also dropped an invalid timeout_per_input option so ClusterFuzz runs the configuration it actually understands.

None of that makes a good launch screenshot. It is exactly what makes a decoder trustworthy.