#11870·ladybird

LibGC: ASan use-after-poison in dead ResizeObserver test

Author: kyle-elliott-tobCreated Sep 16, 2026Updated Sep 16, 2026
Labelsbugci flakinesscrash

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

  1. Check out 46f8d7902a2d62ff553880f92f540ddf9daad5fe or a newer revision containing the same LibGC and test code.

  2. Configure and build test-web with the Sanitizer preset:

    cmake --preset Sanitizer -B Build/sanitizer
    cmake --build Build/sanitizer --target test-web -j16
    
  3. From 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 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

https://github.com/LadybirdBrowser/ladybird/blob/46f8d7902a2d62ff553880f92f540ddf9daad5fe/Tests/LibWeb/Text/input/ResizeObserver/dead-observer-does-not-skip-active-observer.html

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:

  1. The test calls internals.markAsGarbage("deadBox"). This deletes the environment binding and calls Heap::uproot_cell() for its wrapper, allowing it to be unmarked even if the VM still contains a root to it.
  2. The synchronous internals.gc() performs the mark and starts incremental sweeping. The helper's inverse-root list is then cleared.
  3. collect_garbage() does not itself reset m_allocated_bytes_since_last_gc; only the threshold path in will_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.
  4. At the start of that second GC, finish_pending_incremental_sweep() drains the prior sweep and deallocates/poisons the entire wrapper block.
  5. Root gathering still produces the wrapper pointer. MarkingVisitor directly calls is_marked() on roots without first proving their block remains in m_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