Managed locals leak at ORDINARY scope exit on wasm32, and a Variant local traps
Why this is not the unwind ticket
bug-a-managed-locals-leak-on-an-unwind-on-wasm32-and-xtensa is priced at p25
on a reachability argument that is correct for what it describes: "It needs an
exception to unwind through a frame that owns a managed local."
This needs no exception. It fires on every ordinary return from every procedure holding one of the affected kinds. The two defects share a target and nothing else, and this one is strictly larger.
Measured
x86-64 as the oracle, wasmtime 48.0.1 as the host, -dPXX_ALLOC_CENSUS.
The allocation counts are identical on both sides of every row — only the
frees differ, so nothing here is a difference in how much work the two backends
do.
| managed local, released at ordinary scope exit | x86-64 | wasm32 | |
|---|---|---|---|
scalar AnsiString |
ok | ok | |
| dynamic array | ok | ok | |
| COM interface | freed=1 |
freed=0 |
leaks every one |
| record with managed fields | live=2 of allocs=4274 |
live=269 |
leaks |
static array of AnsiString |
live=3 of allocs=8671 |
live=543 |
leaks |
Variant |
ok | trap, exit 134 | crashes |
| promotable int | ok | ok |
The Variant row is not a leak and should not be filed as one:
wasm trap: wasm `unreachable` instruction executed
0: 0x1e3f8 - <unknown>!<wasm function 241>
Reproduce any row with a procedure whose only body is an assignment to a local
of that kind, called 300 times from the program body. Build both sides with
-dPXX_ALLOC_CENSUS; build the wasm side with
--target=wasm32 -Fulib/rtl/platform/wasi and run it under wasmtime run.
Use a RUNTIME-built string, not a literal. A literal now costs x86-64 zero allocations and wasm32 1871 (see the note at the bottom), so a literal-based probe compares two different workloads and the leak is buried in the difference. That confounded the first version of this measurement.
The cause, and it is named in the source
WasmEmitManagedLocals (ir_codegen_wasm32.inc:5448) has two halves with two
predicates, and its own comment says so deliberately:
THE TWO HALVES ANSWER DIFFERENT QUESTIONS AND NOW HAVE DIFFERENT PREDICATES, which is the point rather than an inconsistency ... conflating "what must start nil" with "what this backend knows how to release" is precisely what hid the gap below.
The ZERO half asks the shared table ManagedLocalZeroBytes. The RELEASE half
keeps a hand-written list:
if (Syms[i].Kind = skLocal) and not Syms[i].IsRef
and (((Syms[i].TypeKind = tyAnsiString) and not Syms[i].IsArray)
or (Syms[i].IsArray and (Syms[i].ArrLen = -1))) then
Two kinds. The comment above it even enumerates what the zero half admits that this does not — "a local RECORD with managed fields, a static array of string, a Variant, a COM interface local and a promotable int" — which is this ticket's table, written down before anyone ran it. The gap was disclosed and never measured; the measurement is the only new thing here.
Sizing it honestly: this is NOT a predicate widening
grep for the helpers this backend can call: PXXStrDecRef and
PXXDynArrayRelease, and nothing else. There is no PXXIntfRelease, no
PXXVarClear, no record-finalize call wired into wasm32 at all. So widening the
predicate would emit calls to helpers that are not there.
The real job is the same one frankS ran for xtensa — e1d7977a2 took that
backend's arm from one managed kind to six and 3a1c1dc73 added the seventh —
plus, for the static-array row, an element loop, and, for Variant, a separate
diagnosis because that one is a trap rather than a missing release.
Do not widen the predicate first. It is the last line of the change, for the same reason the unwind ticket says its own predicate arm is last.
Why it is filed rather than fixed
Wasm backend work, which the owner demoted on 2026-08-30 ("they simply must not
outrank ordinary Track A work"), with
decide-the-wasm-umbrella-at-70-reinstates-everything-the-owner-demoted-to-25
open on exactly that question. Two agents have already declined the adjacent
ticket on that reasoning and it applies here unchanged.
What has changed is the input to the ranking: the adjacent ticket's p25 rests on an unwind being rare, and the argument was sound. These are ordinary-path defects and one of them is a crash, so whoever ranks this should do it on the table above rather than by inheriting p25 from the neighbouring slug. Priority was set to 55 on that basis as a proposal, not a finding.
Corrected to 25 the same day. The decision I filed this under as "open" had
already been ruled: e9d1b7850, "linux only for now — demote the wasm and BSD
umbrellas", which caps wasm work at 25 regardless of a defect's severity. The
ruling is about the PLATFORM, not about how bad an individual bug is: wasm32 is
not a current target, so a crash on it blocks nothing, and 25 is the honest
number for now. b2d3eb61a records the shape this ticket should sit in —
demoted is not forgotten: correct, open, parked, and read the moment wasm
comes back. The table above is unaffected; a measurement does not change when
the ranking does.
An accuracy note on a resolved ticket
perf-a-every-string-literal-assignment-heap-copies-on-i386-arm32-riscv32-and-xtensa
named four backends. wasm32 is a fifth and was never in that sweep:
s := 'yy' 2000 times costs it allocs=1871, the same figure the other four
gave, against x86-64's zero. EmitStaticLitHandle still has exactly two
implementations. That ticket's title is wrong by omission rather than by claim —
its sweep covered six targets and wasm32 was not one of them, which nothing in
it said. Recorded here rather than by reopening it; the remaining arm is small
(WasmDataAddr(Strs[si].Offset + 8), which this backend already emits
elsewhere) and belongs to whoever takes the wasm work above.
2026-09-01 — fixed, and the Variant row was a different animal
74e33af46. Four of the five rows in the table above were one defect: the
release half of WasmEmitManagedLocals asked its own two-line question while
the zero half asked the shared table. Six arms now, riscv32's order and
riscv32's predicates.
The fifth row was never a leak. v := 42 traps in a bare program body with no
procedure in it, so scope-exit release cannot be involved, and the compiler
had already said why in its broken-body report: main$0 — statement IR op 43,
IR_VAR_STORE. The whole Variant family is absent from this backend. Filed as
bug-a-wasm32-has-no-variant-ir-arms-so-any-variant-assignment-traps.
The thing worth keeping. The trap was diagnosed by reading a report the compiler prints at COMPILE time, having first been diagnosed by running the program and reading a wasm backtrace. The backtrace named a function index; the report named the IR op. The report cost nothing and was already on screen — I had tailed two lines past it.
And the PXXVarClear arm I added is a live example of face 242's cousin: written, committed, green, and never executed on this target, because nothing can construct its input. It is flagged in both tickets rather than counted.
Log
- 2026-09-01 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 276fed635.