LibGC: ASan use-after-poison in dead ResizeObserver test
Summary
The unmodified Text/input/ResizeObserver/dead-observer-does-not-skip-active-observer.html test intermittently leaves a stale root to an HTMLDivElementWrapper that a pending incremental sweep has deallocated. A closely following automatic GC reads that poisoned cell in GC::MarkingVisitor, causing an ASan use-after-poison/SIGABRT.
This was surfaced in the macOS arm64 Sanitizer/Clang job for unrelated PR #11859, but the same failure occurs on unrelated revisions and is locally reproducible.
Operating system
macOS
Also reproduced locally on x86-64 Linux with Clang ASan+UBSan.
Steps to reproduce
Check out
46f8d7902a2d62ff553880f92f540ddf9daad5feor a newer revision containing the same LibGC and test code.Configure and build
test-webwith the Sanitizer preset:cmake --preset Sanitizer -B Build/sanitizer cmake --build Build/sanitizer --target test-web -j16From the checkout root, repeatedly run the existing test with normal helper-process sandboxing:
TESTS_ONLY=1 \ ASAN_OPTIONS='strict_string_checks=1:check_initialization_order=1:strict_init_order=1:detect_stack_use_after_return=1:allocator_may_return_null=1:detect_leaks=0:symbolize=0:poison_history_size=256' \ UBSAN_OPTIONS='print_stacktrace=1:print_summary=1:halt_on_error=1' \ Build/sanitizer/bin/test-web \ --python-executable "$(command -v python3)" \ --per-test-timeout 120 \ --test-path Tests/LibWeb \ --results-dir /tmp/dead-observer-repro \ --test-concurrency 12 \ --filter Text/input/ResizeObserver/dead-observer-does-not-skip-active-observer.html \ --repeat 3000 \ --fail-fast
This is timing/allocation-layout dependent. Local failures occurred on repetitions 394, 398, 1009, and 1673 in separate runs. A full ctest --preset Sanitizer also naturally reaches the triggering allocation threshold on affected CI runs.
Expected behavior
The test passes without invalid memory access. internals.markAsGarbage() followed by internals.gc() should not leave a root that becomes dangling before the next automatic collection.
Actual behavior
One WebContent process intermittently aborts in the GC mark phase with ASan use-after-poison. In the PR #11859 run, the final result was:
Pass: 10595, Fail: 0, Skipped: 129, Timeout: 0, Crashed: 1
Crashed: Text/input/ResizeObserver/dead-observer-does-not-skip-active-observer.html
Independent CI evidence:
- PR #11859 macOS arm64 Sanitizer/Clang: https://github.com/LadybirdBrowser/ladybird/actions/runs/35121267230/job/104879592183
- Unrelated PR #11855, using the exact same base, failed in the same test with the same stack: https://github.com/LadybirdBrowser/ladybird/actions/runs/35121269560/job/104879811357
- The base revision's own macOS job passed, demonstrating the intermittency: https://github.com/LadybirdBrowser/ladybird/actions/runs/35121157934/job/104878988250
- Earlier identical occurrences: https://github.com/LadybirdBrowser/ladybird/actions/runs/35037082055/job/104608553941, https://github.com/LadybirdBrowser/ladybird/actions/runs/34964307474/job/104365034763, and https://github.com/LadybirdBrowser/ladybird/actions/runs/34822357817/job/103906594203
PR #11859 changes only the integrity attribute lookup in HTMLLinkElement and adds link-integrity-namespaced-attribute.html. Its new test passed in the full suite and passed the separate 100x -j1 and 100x -j12 flakiness checks.
URL for a reduced test case
HTML/SVG/etc. source for a reduced test case
N/A
Log output and (if possible) backtrace
The locally captured primary report begins:
ERROR: AddressSanitizer: use-after-poison
READ of size 1
Access stack (symbolized from the module offsets):
GC::Cell::is_marked() Libraries/LibGC/Cell.h:54
GC::MarkingVisitor::visit_impl(GC::Cell&) Libraries/LibGC/Heap.cpp:1080
GC::MarkingVisitor::MarkingVisitor(...) Libraries/LibGC/Heap.cpp:1062
GC::Heap::mark_live_cells_across(...) Libraries/LibGC/Heap.cpp:1156
GC::Heap::run_collection(...) Libraries/LibGC/Heap.cpp:681
GC::Heap::collect_garbage(...) Libraries/LibGC/Heap.cpp:636
GC::Heap::will_allocate(...) Libraries/LibGC/Heap.cpp:326
GC::Heap::allocate<JS::Shape>(...)
JS::Shape::create_put_transition(...) Libraries/LibJS/Runtime/Shape.cpp:224
JS::Object::storage_set(...)
JS::ECMAScriptFunctionObject::initialize(...)
Address is a wild pointer; ASan shadow is f7 (poisoned by user).
Memory was manually poisoned by thread T0:
GC::BlockAllocator::deallocate_block(...) Libraries/LibGC/BlockAllocator.cpp:381
GC::CellAllocator::block_did_become_empty(...) Libraries/LibGC/CellAllocator.cpp:88
GC::Heap::sweep_block(...) Libraries/LibGC/Heap.cpp:1319
GC::Heap::sweep_next_block() Libraries/LibGC/Heap.cpp:1342
GC::Heap::finish_pending_incremental_sweep() Libraries/LibGC/Heap.cpp:1422
GC::Heap::run_collection(...) Libraries/LibGC/Heap.cpp:648
GC::Heap::collect_garbage(...) Libraries/LibGC/Heap.cpp:636
GC::Heap::will_allocate(...) Libraries/LibGC/Heap.cpp:326
With LIBGC_LOG_LEVEL=2, the poisoned block was identified immediately before the fault as the HTMLDivElementWrapper (88b) allocator's block. The invalid read was at that block's first cell mark byte.
Screenshots or screen recordings
N/A
Build flags or config settings
CI: Sanitizer/Clang, Blacksmith macOS 26 arm64 runner, Xcode 26.6 / Clang 21.
Local: ENABLE_ADDRESS_SANITIZER=ON, ENABLE_UNDEFINED_SANITIZER=ON, RelWithDebInfo, normal WebContent sandbox, --test-concurrency 12.
Analysis
The causal sequence observed in the ASan poison history is:
- The test calls
internals.markAsGarbage("deadBox"). This deletes the environment binding and callsHeap::uproot_cell()for its wrapper, allowing it to be unmarked even if the VM still contains a root to it. - The synchronous
internals.gc()performs the mark and starts incremental sweeping. The helper's inverse-root list is then cleared. collect_garbage()does not itself resetm_allocated_bytes_since_last_gc; only the threshold path inwill_allocate()does. If this long-lived test process is close to its threshold, creation/initialization of the active observer callback at test line 28 immediately enters another automatic GC.- At the start of that second GC,
finish_pending_incremental_sweep()drains the prior sweep and deallocates/poisons the entire wrapper block. - Root gathering still produces the wrapper pointer.
MarkingVisitordirectly callsis_marked()on roots without first proving their block remains inm_live_heap_blocks, so it reads the poisoned mark bit.
The likely remaining root is a dead value in the active VM execution-context register frame: ExecutionContext::visit_edges() visits the entire initialized register array, while uproot_cell() suppresses roots for one collection but does not clear those slots for later collections. Regardless of the precise root owner, ASan proves that a root survives past block removal and is consumed in the immediately following mark phase.
There is a separate diagnostics issue that hides the useful ASan report on macOS CI. .github/workflows/lagom-template.yml sets ASAN_OPTIONS and UBSAN_OPTIONS log paths under ${GITHUB_WORKSPACE}/Build, but RendererSandboxMacOS.cpp allows WebContent to read/execute selected Build subdirectories and does not allow writing the Build root. The sanitizer consequently prints ERROR: Can't open file: .../Build/ubsan.log.<pid> (reason: 1) instead of recording the primary report. Logging to stderr or the writable renderer cache would expose the original diagnostic. The two BUS reports retained by that job belong to expected protected-memory/decommit death tests, not this crash.
Both known uproot_cell() callers—the LibWeb Internals helper and Tests/LibJS/test-js.cpp—are test-only; no claim is made here that the same path is reachable in a normal browsing session.
Source: LadybirdBrowser/ladybird