While working on implementing CSS in my own browser, Omoikane (source), Acid3 began crashing due to memory errors.
The problem only occurs when the baseline JIT is enabled; it runs fine with the interpreter.
Furthermore, Omoikane’s Acid3 runner has two execution modes, Faithful and DirectDrive. Faithful completes all 100 points, but then it crashes during DirectDrive.
The failure manifests differently depending on the environment.
| Environment | Observed failure |
|---|---|
| x86_64 Linux | SIGSEGV |
| ARM64 Linux | SIGABRT, ~60 GB allocation, SIGSEGV on rerun |
| ARM64 macOS | SIGSEGV, SIGBUS on rerun |
Even though it’s the same test, the signals differ.
The issue discovered with the CSS changes turned out not to be CSS itself, but rather the lifetime of the weak‑reference cache held by Boa’s shared shape.
This concerns Issue #755 (issue #755) and Pull Request #757 (PR #757).
Faithful and DirectDrive
First, let’s explain the two execution modes available in Omoikane’s Acid3 runner.
Both load the same Acid3 page and run the same 100 subtests. The difference lies in how the Acid3 test loop is advanced.
In Faithful, inline event handlers on the page are attached and the actual load event is fired.
The Acid3 <body onload="update()"> passes through Omoikane’s event handling and is executed; thereafter, while the virtual clock advances, the operations scheduled for setTimeout(update, 10) are executed in order.
load event
-> <body onload="update()">
-> setTimeout(update, 10)
-> Advance the virtual clock
-> Next update()
This follows a path similar to what happens when a real browser opens the page. If any part of the event‑handler attachment, timers, callbacks, or the event loop is broken, execution will stall partway through.
In DirectDrive, this setTimeout chain is not used.
The runner directly invokes update() and advances the subtests one by one. It processes only the 0 ms tasks required for loading iframes and objects, but it bypasses the 10 ms timer that Acid3 itself schedules.
runner
-> Call update() directly
-> Process required 0 ms tasks
-> Call the next update() directly
This serves as a baseline for seeing how far the JavaScript engine and DOM can handle each individual subtest, even when the timer or load‑event implementations are incomplete.
In this test, the more browser‑like Faithful mode completed all 100 subtests. After that, a memory error occurred with DirectDrive, which skips the timer chain.
Even with the same test content, the way the update() call is made and how often small evaluations are repeated changes, which in turn alters the timing of allocations and garbage collection. The fact that only DirectDrive failed suggests that, rather than being caused by the processing of setTimeout, an existing garbage‑collection bug surfaced under a different execution order.
Adding CSS changes broke Acid3
The root cause was the implementation of text-decoration-thickness in Issue #698 (issue #698).
In the branch that included this change, the regular tests and WPT passed, but Acid3 with the baseline JIT failed. The same base revision on the main branch succeeds.
At first glance, it appears that the newly added CSS property handling broke something.
However, with memory corruption, the location where the problem is introduced can be far from where it actually crashes. A slight shift in the timing of allocations or garbage collection may have merely exposed a pre‑existing bug.
First, we reverted only the two newly added items from the bootstrap CSS validation set and tried again.
Even so, DirectDrive still resulted in a SIGSEGV.
At any rate, it doesn’t seem to be a simple case where the newly added CSS property name itself is the direct cause.
The first symptom was an inconsistency in the PropertyMap
Running the same test on a local ARM64 Linux machine also failed, exiting with code 101.
At that point it halted during the consistency check in PropertyMap::apply_insert. After adding a new accessor property, the expected slot could not be looked up.
The call path is as follows.
SetPropertyGetterByName
-> validate_and_apply_property_descriptor
-> PropertyMap::apply_insert
We have identified that the property map becomes corrupted when a getter is added to a JavaScript object.
However, the assertion may have merely detected the corrupted state after it had already occurred. In CI the failure appears as SIGSEGV or SIGBUS rather than an assertion, so we need to investigate a bit earlier.
Inspecting the core dump
Next, we saved the core file generated by the SIGSEGV and opened the corresponding executable with gdb.
In that context, the crash occurred a little earlier.
SetPropertyGetterByName
-> validate_and_apply_property_descriptor
-> PropertyMap::plan_insert
-> SharedShape::insert_property_transition
-> GcEdge::root
-> GcHeader::register_root
It stopped while trying to promote an existing transition target retrieved from the shared‑shape transition cache to the root.
Shared‑shape transition cache
JavaScript objects have a “shape” that describes their structure—property names, attributes, and the layout of slots.
For example, if the same property is added to objects that share the same shape, the resulting shape after addition will also be identical.
Parent shape
+ property x
-> Child shape
To avoid rebuilding this transition each time, a parent shape caches a forward transition that records “when this property is added, the child shape becomes this.”
However, if the cache strongly retains the child shape that is the target of the transition, even shapes that are no longer used cannot be reclaimed by garbage collection.
Therefore, the transition target is kept as a weak reference.
Parent shape
-> Transition cache
-- weak → child shape
As long as the child shape is still used elsewhere, it can be reused from the cache. When it becomes unused, it can be garbage‑collected, and if the same transition is needed again it will be recreated.
The area managing the weak references disappeared first
Examining the saved core dump, the old parent shape’s transition map still contained a cache entry.
However, the ephemeron allocation referenced by that entry had already been freed and repurposed for something else.
Parent shape (after GC)
-> cache entry → remains
-> ephemeron allocation → freed and reused
-- weak → child shape
The cache itself remains, but the region that manages the weak references has already been released.
If you try to reuse the transition in that state, you will read the shape pointer from a freed management area. The value obtained can no longer be trusted.
The differing symptoms across environments—SIGSEGV, SIGBUS, and roughly 60 GB of allocation—were caused by how that invalid value was used.
Keep the transition target weak, but retain only the ephemeron
Before the fix, the transition map stored the following type:
FxHashMap<T, WeakGcEdge<SharedShapeInner>>
After the fix, it stores WeakGc.
FxHashMap<T, WeakGc<SharedShapeInner>>
WeakGc holds the ephemeron allocation that implements the weak reference, keeping it rooted for the lifetime of the cache entry. However, it does not strongly retain the child shape that is the transition target.
In other words, we separate the two concerns:
- As long as a cache entry exists, the region that manages the weak reference must remain alive.
- The target shape may be garbage‑collected once it is no longer used elsewhere.
Simply turning the transition target into a strong reference would stop the crash, but that would prevent unnecessary shapes from being reclaimed.
This time, while retaining weak semantics, we aligned only the management of the managed region’s lifetime with the cache entry.
Regression testing with garbage collection
In the regression tests, we add a property from the same parent shape and then repeatedly interleave major and minor garbage collections to reuse the transition cache.
While the child shape is still retained, it must be possible to retrieve the same shape from the cache.
Retain the child shape
-> major/minor GC
-> Retrieve the same transition
-> Reuse the same child shape
After that, when the child shape is dropped and a major GC is performed, the weak reference can no longer be upgraded. When the same property is added again, a new child shape must be safely created.
Drop the child shape
-> major GC
-> Weak references cannot be upgraded
-> Safely regenerate the same transition
We confirmed that, beyond merely not crashing, the original purpose of the weak reference is also preserved.
Post‑fix verification
Locally, the following tests passed:
- 4 regression tests for shared shapes
- 968 Boa engine unit tests
- 79 baseline JIT regression tests
- On the ARM64 Linux baseline JIT Acid3 test, both Faithful and DirectDrive achieved a perfect 100/100
In the PR’s CI, all browser tests for both the interpreter and the baseline JIT succeeded on x86_64 Linux, ARM64 Linux, and ARM64 macOS.
Acid3, JIT stress, Test262, regular tests, WPT, and Gate 5 for each environment also passed.
Summary
In this CSS property implementation branch, the baseline JIT’s Acid3 test exhibited different memory corruption issues depending on the environment.
Initially we suspected a link to the CSS changes, but the problem reproduced even after reverting some of those changes. Tracing the core issue revealed that the transition cache for shared shapes was referencing an ephemeron allocation that had already been freed.
The fix does not involve strongly retaining the destination shape; instead, it retains only the allocation that manages the weak reference for the lifetime of the cache entry. The destination shape can still be reclaimed as usual when it is no longer needed.
The memory corruption could not be diagnosed by looking only at the crash site. The CSS changes were not the direct cause; rather, they acted as a trigger that exposed an existing problem by altering allocation or garbage‑collection conditions.
The signals also differed across environments, but examining the paths that lead to invalid references in the core allowed us to categorize them under the same root cause.