Skip to content

tests: make weak-table weakness tests robust under conservative GC - #1815

Closed
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:pr/flaky-weak-hash-test
Closed

tests: make weak-table weakness tests robust under conservative GC#1815
dg1sbg wants to merge 1 commit into
clasp-developers:mainfrom
dg1sbg:pr/flaky-weak-hash-test

Conversation

@dg1sbg

@dg1sbg dg1sbg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #1814.

WEAK-KEY-AND-VALUE-WEAKNESS fails intermittently on clasp/ubuntu-latest/bytecode. The identical commit failed the regression step on one run and passed it on another, while macos/bytecode and ubuntu/native passed both times — so it is non-deterministic, not a real regression. Details and run links in #1814.

Why the tests are fragile

All four weakness tests share this shape:

(let ((table (make-hash-table :weakness :key-and-value)))
  (setf (gethash (list nil) table) :value
        ...)
  (gctools:garbage-collect)
  (hash-table-count table))

Two things make that unreliable under Boehm's conservative stack scanning:

  1. The throwaway (list nil) values are created in the same frame that then calls garbage-collect, so their addresses are still sitting in live stack slots and registers when the collector scans the stack. The collector cannot tell those words from real references and keeps the entries alive.
  2. A single collection is assumed to be enough.

Either one leaves a dead entry alive and the count comes out one too high. Because it depends on register allocation and stack residue, it can be perturbed by entirely unrelated codegen changes elsewhere in the tree — which is how it surfaced.

The existing comment already acknowledged these are "pretty strict"; this makes them strict about the property being tested rather than about incidental stack layout.

Change

  • Populate the table in a builder function that returns before the collection, so the frame holding the temporaries is popped and below the stack pointer when the scan happens.
  • Collect up to TRIES times rather than exactly once.
  • One WEAK-COUNT helper shared by all four tests instead of repeating the retry logic four times.

Not vacuous

A retry loop risks turning the test into a tautology, so I checked that it still fails on genuine retention. Holding one key in a global:

dead temporaries      => 1   (expected 1, passes)
one key held globally => 2   (expected 1, still fails after all attempts)

An entry that is really retained never reaches the expected count, so the tests keep their teeth.

Verification

macOS arm64, boehmprecise: full suite 1979 successes, zero unexpected failures; the weakness tests pass 3/3 across repeated TEST_SUITES=hash-tables0 runs. (weak-key-or-value-weakness is #-use-boehm and does not run on these variants, but is updated for consistency.)

WEAK-KEY-AND-VALUE-WEAKNESS fails intermittently on
clasp/ubuntu-latest/bytecode. The identical commit failed the regression
step on one run and passed it on another, while macos/bytecode and
ubuntu/native passed both times, so it is non-deterministic rather than a
real regression.

The four weakness tests populate a table with throwaway (list nil)
values, call garbage-collect once, and assert an exact hash-table-count.
Two things make that fragile under Boehm's conservative stack scanning:

  - the temporaries are created in the same frame that then calls
    garbage-collect, so their addresses are still in live stack slots and
    registers when the collector scans;
  - a single collection is assumed sufficient.

Either one leaves a dead entry alive and the count comes out one too
high, which can be perturbed by entirely unrelated codegen changes.

Build the table in a function that returns before collecting, so the
frame holding the temporaries is popped and below the stack pointer when
the scan happens, and collect up to TRIES times instead of once. A
genuinely retained entry still fails the test -- verified by holding a
key in a global, where the count stays at 2 for all attempts -- so this
does not make the tests vacuous.

Factored into one WEAK-COUNT helper shared by all four tests rather than
repeating the retry in each.

Fixes clasp-developers#1814.
@dg1sbg

dg1sbg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Tried to validate this on Linux, where the flake actually occurs. The result is inconclusive, and I would rather say so than imply the fix is proven.

Setup: x86-64 Ubuntu 24.04, LLVM 18, --build-mode=bytecode --no-default-native — i.e. the same configuration as the clasp/ubuntu-latest/bytecode/no/no job that failed twice on WEAK-KEY-AND-VALUE-WEAKNESS. Two arms, same machine, same binary, differing only in which version of hash-tables0.lisp the harness compiles at run time:

arm runs WEAK-* failures
with this fix 6 0
with this fix reverted (control) 6 0

The control passed too. So this box never reproduced the flake, and the six green runs on the fixed side establish nothing about the fix. Reproduction likely needs CI's environment — 2-4 core runners under memory pressure have a very different stack and heap profile than the 16-core / 157 GB box I used, and conservative-GC retention is exactly the sort of thing that depends on that.

What is established, independent of reproduction:

  • The hazard is real. The dead (list nil) temporaries are created in the same frame that then calls garbage-collect, so their addresses are still in live stack slots when the collector scans. Building them in a function that returns first puts that frame below the stack pointer.
  • The change does not make the tests vacuous. Holding one key in a global keeps the count at 2 through every retry, so genuine retention still fails.
  • No regression: full suites pass on macOS arm64 / LLVM 22 / native (1979 successes, 0 unexpected failures) and on x86-64 Linux / LLVM 18 / bytecode (1979, 0 unexpected failures, 13 consecutive runs).

So this rests on the reasoning plus the non-vacuity check, not on an observed before/after. If the flake reappears after this merges, that is the reason.

@Bike

Bike commented Aug 1, 2026

Copy link
Copy Markdown
Member

For the moment I'm just going to mark the tests as known failures under Boehm. Boehm is not reliable enough for us to be able to mandate that objects are collectable.

@Bike Bike closed this Aug 1, 2026
dg1sbg added a commit to dg1sbg/clasp that referenced this pull request Aug 4, 2026
The four weakness tests in hash-tables0.lisp allocate throwaway (list nil)
temporaries in the same frame that then calls garbage-collect, and assert an
exact hash-table-count afterwards.  Boehm scans the stack conservatively, so a
stale word left in that frame or in a register can still reference one of the
temporaries and keep its weak entry alive; the count comes out one too high and
the test fails.

This is not a collector defect.  A conservative collector promises never to
collect live data, not to always collect dead data, and the comment above these
tests already acknowledges they are stricter than that.  The tests create the
stale reference themselves by building and collecting in the same frame.

Move construction into a builder that RETURNS first, so the frame holding the
temporaries is popped before the collection.  The assertion is unchanged: one
collection, exact count.

Measured on x86-64 Linux (LLVM 18, boehmprecise, bytecode) by calling the test
body directly under 500 frames of recursion, 1000 trials per arm.  The rate
depends on code layout and varies between builds: across three builds the
current form failed at 16.5%, 5.4% and 0.1%.  The builder form failed 0 times
in 2000 trials spanning the 5.4% and 0.1% builds, where about 55 failures would
otherwise be expected.

Non-vacuity checked: an entry whose key and value are both held in globals
still yields a count of 2 and still fails, so a genuine leak is not masked.

Previously submitted as clasp-developers#1815, which also retried the collection.  The retry
proves unnecessary and is omitted, so the test asserts exactly what it does
today.  Refs clasp-developers#1814.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky test: WEAK-KEY-AND-VALUE-WEAKNESS fails intermittently on ubuntu-latest/bytecode

2 participants