JPEG XL, One Year Later

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

Getting a decoder into Chrome was one milestone. Hardening it and enabling it by default completed the next stage.

Status: โœ… Enabled by Default for Chrome 155

After the Initial Integration

The original JPEG XL integration added a memory-safe Rust decoder. Since then the work has covered ordinary browser concerns: 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.

Intent to Ship and Chrome 155

We filed the Intent to Ship, and the Blink API Owners gate received the required three LGTMs.

ChromeStatus targets JPEG XL decoding for Chrome 155 on desktop, Android, and WebView. The default-enable change has landed for that release:

It enables both decoding and image/jxl advertising in the Accept header by default. kJXLImageFormat remains as a kill switch, and the virtual test suite is inverted to exercise the disabled configuration after the default changes. The CL also keeps incremental animation decoding sequential until the pixel decoder is initialized, avoiding an invalid early seek.

The I2S review covers the decoder's security model and test coverage: Rust memory safety, renderer sandboxing, Chromium and upstream fuzzing, controlled allocation failures, conformance tests, and deterministic testing of parallel schedules.

Making Fuzzing Part of the Upstream Loop

Fuzzing needed to cover both Chromium and the upstream crate. Fixing every finding only in the browser would leave other jxl-rs embedders without the same corrections.

ClusterFuzzLite was added to jxl-rs with short AddressSanitizer runs on pull requests and longer scheduled runs against the existing decode targets. It soon produced several arithmetic and allocation findings.

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, while the reproducer and generic fix live in jxl-rs. The next crate roll then brings the upstream correction back into 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. Chromium now wires jxl-rs's parallel runner into the 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 as a kill switch after default enablement. 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.