Summary
RESOLVED the same day it was filed, by building the probe it describes.
test/test_sweep_thunk_preserves_stack_alignment.pas now fails when either
constant is wrong: 4 against 12 on x86-64, 8 against 12 on i386. See the
resolution at the bottom, including the fact that the FIRST version of that
probe was itself blind.
The managed-local sweep thunk (EmitSweepThunkStackAdjust, ir_codegen.inc)
compensates for the return address a call pushes, so the sweep's own calls see
the stack alignment the INLINE sweep saw: sub rsp,8 on x86-64, sub esp,12 on
i386. Emitting the wrong constant changes nothing any test can observe.
Measured 2026-09-07, by building it wrong on purpose: with the x86-64 value (8)
emitted on i386 — misaligning every call the sweep makes by 8 —
test_managed_sweep_thunk ran natively on i386 and printed
SWEEPTHUNK OK ok=80000 caught=5000 with census allocs=40103 frees=40101 live=2, identical to the correct build in every digit, exceptions included.
So the constant is correct by the SysV contract that symtab.inc:12870 and
ir_codegen386.inc:3723 both state, and it is not correct by measurement.
It is a value with no guard behind it.
Why the corpus cannot reach it
Three independent reasons, all of which would have to change:
- The sweep's callees are pxx-internal release stubs.
- Neither backend emits a memory-operand
movaps/movapd/movdqa. The x86-64 hits are all register-to-register (movaps xmm0, xmm8), which has no alignment requirement. - The external-call path re-aligns for itself (
and esp,-16, ir_codegen386.inc:3815) rather than trusting the alignment it was handed.
What would see it, and why this is not hypothetical
A released interface whose _Release runs user code that calls an external
function using aligned SSE. symtab.inc:16362 records precisely that fault mode
already: "callees (GTK/GLib) use aligned SSE (movaps) and fault otherwise."
That path exists today; the leak corpus simply does not walk it.
Proposed probe, cheapest form first, and it needs no inline asm: give a
refcounted interface implementation a Destroy that takes the address of a
local and records PtrUInt(@local) mod 16 into a global. A stack local's
address carries the frame's alignment. Hold that interface in a procedure with
three-plus managed locals and two-plus returns so the thunk fires, then compare
the recorded residue between a thunked and a non-thunked build. They must agree;
with a wrong constant they differ by exactly the miscompensation. That is a
differential probe, so it needs no absolute expected value and cannot collide
with a do-nothing default — the failure mode CLAUDE.md warns about for any row
whose expected value is a small power of two.
Why the adjustment stays rather than being dropped as inert
Because "the thunk body sees the stack the inline sweep saw" is a cheaper invariant to hold than auditing every transitive callee, forever, against a requirement that already has a recorded fault mode in this tree. It costs 6 bytes once per thunk against a measured -31.9%.
Scope
Both arms landed unverified in this respect. x86-64 at 50e25f5f0, i386 in the
commit that files this. The five remaining targets will each add a constant of
their own with the same blind spot unless this probe exists first — which is
the argument for doing it before them, not after.
Resolution
Built the probe this ticket specifies, and it works: an interface local's
release is the one path in the sweep that runs USER code
(SXR_INTF -> PXXIntfRelease -> _Release -> Destroy), so the destructor's frame
inherits the alignment the sweep ran with, and PtrUInt(@local) and 15 reads it
with no inline asm.
THE FIRST VERSION OF THE PROBE WAS ITSELF BLIND, AND ONLY THE POSITIVE CONTROL
CAUGHT IT. It compared a three-return procedure against a one-return one and
read the recorded residue after each loop. Both loops ran 1 to 30; 30 mod 3
is 0, which selects the three-return procedure's FIRST return — and a body's
first return is always emitted inline. So both readings came from inline sweeps,
they agreed trivially, and the test printed ALIGN OK against a deliberately
broken compiler on both targets. Sampling the final iteration meant the arm
under test was never the arm measured.
That is this ticket's own defect one level up: a guard whose expected value is produced by the path it is not testing. It was found the same way as the original — by running it against a build known to be wrong — which is the argument for making the deliberately-broken build a routine step and not a flourish.
The fix removes the dependence on which path the last iteration took: record min and max of the residue over EVERY destructor call, and assert they are equal. One three-return body already contains both arms, so the cross-procedure control is not needed at all. No absolute expected value appears anywhere, so there is nothing for a do-nothing default to collide with, and the row needs no per-target constant — it is correct on any target that gets a thunk.
Controls, both directions, both targets:
| build | x86-64 | i386 |
|---|---|---|
| correct constants | ALIGN OK |
ALIGN OK |
| compensation wrong | ALIGN MISMATCH lo=4 hi=12 |
ALIGN MISMATCH lo=8 hi=12 |
Wired into test-core, which is tier-enrolled. The five remaining targets now
each land against a guard that already exists, which was the reason for doing
this before them rather than after.
Amendment, same day — the guard was aimed, and nothing was ASSERTING that
The resolution above is correct and was incomplete in a way its own positive controls could not show.
test_sweep_thunk_preserves_stack_alignment asserts gSeen = 30 — the
destructor really ran — and then that gLo = gHi. Both are right. Neither
asserts that a thunk was placed. If SWEEP_THUNK_MIN_SLOTS ever moves, or
the slot accounting shifts under Thunked, every return emits its sweep inline,
all thirty residues agree trivially, and the row prints ALIGN OK for a reason
that has nothing to do with the constant it exists to guard. That is this
ticket's own defect one level further out, and the controls cannot see it: a
control run today proves the guard discriminates today.
Verified by disassembly that a thunk IS currently placed in Thunked — and the
first pass of that check said the opposite. I matched sub rsp, 0x0*8 against a
disassembler that emits a bare decimal sub rsp, 8. An instrument lying by
being correct about a literal string, inside an hour of writing that class into
this ticket. The rule did not protect me; the second source did.
Wired the precondition as tools/assert_sweep_thunk_placed.sh, asserting on
the -S output that Thunked contains a thunk and that a known-thunkless
procedure in the same file does not. It carries no per-target constant, because
the tell is a relation:
thunk epilogue add rsp, N ; ret (N = 8 on x86-64, 12 on i386)
procedure return leave ; ret
so "a ret reached over a stack adjustment rather than over leave" names the
thunk on any target that gets one. Controlled in five directions, all verified
against real assembly: Thunked present and TProbe.Tag absent pass;
Thunked absent, TProbe.Tag present and a missing procedure all fail. The
last one was a hole I nearly shipped — the absent arm counts zero for a
procedure that no longer exists, so a renamed control would have passed
vacuously, certifying a subject that is gone.
Two residuals this leaves, stated rather than closed
The i386 constant still has no automated guard. The controls in the
resolution above were hand-run once. Only the x86-64 row is in test-core, so
the value 12 is guarded by a measurement that will never run again. i386 binaries
execute natively on this box and there is ample precedent for --target=i386
rows, so the row is cheap and is being added.
The thunk-placement precondition is x86-64 only, and not by choice. -S
refuses on any other target — correctly; textual emit really is x86-64 only —
so the i386 row can assert the RESULT (ALIGN OK) but not the PRECONDITION.
The i386 guard is therefore one notch weaker than the x86-64 one, and will stay
that way until -S grows an i386 path. Recorded here rather than left for
whoever ports the next backend to discover.
The i386 row's control, RE-DERIVED rather than cited
The controls in the resolution above were hand-run at an earlier tree, and a
control that ran once is a claim about the past. Re-derived by building it wrong
again at f74ddb135d27, with the i386 compensation emitting the x86-64 value:
i386 ALIGN MISMATCH lo=8 hi=12 expect_same rc=1 <- the row fails
x86-64 ALIGN OK expect_same rc=0 <- unaffected
The break is i386-only and only the i386 row moved, which is the part worth
having: it shows the two rows are independent rather than the new one mirroring
the old. Restored with git checkout HEAD --, and the rebuild returned the
identical fixedpoint sha f74ddb135d27, so the revert-rebuild-restore-rebuild
cycle left no seed drift.