Skip to content

release a result or optional local's payload when it is provably the sole owner#537

Merged
kacy merged 6 commits into
mainfrom
result-local-arc-fix
Jul 23, 2026
Merged

release a result or optional local's payload when it is provably the sole owner#537
kacy merged 6 commits into
mainfrom
result-local-arc-fix

Conversation

@kacy

@kacy kacy commented Jul 23, 2026

Copy link
Copy Markdown
Owner

summary

a T! or T? bound to a name lowers to a three-slot heap value (flag, payload, error). releasing one freed those three slots and nothing else, so the payload it owned was never dropped. a loop binding a fallible call a million times grew to ~295 mb; the same loop written x := call()! stayed flat, because ! hands the payload's count to the caller instead of leaving it in the tuple.

such a local now releases its payload — but only when every use of it is provably safe. today that means the flag reads (.is_ok, .is_err, == none, != none), where the value is never handed to anyone and the local is unambiguously its sole owner. anything else keeps the old shell-only release.

1m iterations before after
optional local, probed with == none 295 mb 2.5 mb
result local, probed with .is_err 295 mb 2.7 mb
result local read through .ok 295 mb 295 mb (unchanged)

why .ok is deliberately excluded

this is the interesting part, and the reason the whitelist is narrow.

treating .ok as cascade-safe looks right on paper — the read does emit a retain, and the releases at the exit edge match the counts. but valgrind on tests/cases/test_http2_tls_roundtrip (the curated memcheck gate) then fails 3/3 with a use-after-free: a 48-byte block freed on the main thread and read afterwards from a spawned server task. baseline passes 3/3.

the cascade is not what's wrong. what changes is that the payload is now genuinely freed where it previously leaked, and something in the http/2 path outlives it. the leak had been hiding an ownership bug. that bug is real and still there; this change simply declines to convert it into memory corruption. an under-fix that leaks beats a use-after-free, so .ok waits until the http/2 ownership issue is fixed on its own.

a result/optional parameter is likewise never cascaded — it is a borrow owned by the caller.

docs

docs/performance.md's http-server figure has read "~0.8 kb/request ... a real per-request leak that is still unfixed" since july. a re-measurement puts it at ~2.8 kb/request (218 mb over 78k requests), and the cause is the leak above — serve_connection reads each request into a T! local and reads it back with .ok, leaking the whole HttpRequestBytes every request. the entry now says so, and gains the reclamation numbers. docs/ownership.md's known-gaps list gains the same case.

throughput numbers are left untouched: this machine had been running benchmarks all session and is in no state to produce an honest req/s. growth-per-request is a ratio and survives that.

what was tested

  • make memcheck — all cases clean, including test_http2_tls_roundtrip, the one that fails under a wider whitelist. this is the load-bearing check.
  • make bootstrap-verify — ir fixed point verified (the seed is regenerated here).
  • make run-regressions — 235 passed, make run-examples and make green-tests green.
  • valgrind across the extraction patterns (!, ?, catch, unwrap_or, or_else, .ok/.err, if-let, while-let, match, for-iter, index-propagate, discarded call, passing and returning a result/optional) — no invalid free, no invalid read/write.
  • the repro numbers above, measured on the built binaries directly rather than through pith run.

notes

this does not make the http server's rss flat — that needs the .ok case, which is blocked on the http/2 ownership bug above. that bug is the next thing worth fixing, independently of the leak: it is live today.

https://claude.ai/code/session_01Uzg5VKBucCeHxan34VAsjv

kacy added 6 commits July 23, 2026 02:27
…safe

a `T!`/`T?` bound to a local lowers to a 3-slot tuple whose slot 8 (and slot
16 on the error side) owns its payload. the exit cleanup and the rebind path
only released the shell via pith_struct_release, so the payload leaked — about
300 bytes per iteration for a loop doing `r := make(); if r.is_err: ...; b := r.ok`.

a cascading destructor on the tuple itself is not safe: the many sites that
extract the payload and then release the shell (`!`, catch, unwrap_or, if-let,
match, for-iter, the json/toml decoders) would double-free. instead the cascade
only fires for tracked tuple locals whose EVERY use is a `.ok`/`.err` read (which
retains, leaving the tuple's own count intact) or a flag check (`.is_ok`, `== none`,
which only read slot 0). none of those release the shell, so cleanup stays the
sole releaser. any other use — `!`, `?`, catch, unwrap_or, a bind, a call
argument, a return, a match or a for — falls back to the shell-only release,
which may still leak but can never double-free.

the release is emitted as a call to a generated per-signature helper rather than
inline branches: an inline brif would split the caller's basic block between a
register's definition and its use (the freshly built value in a rebind, the
return value at an exit edge). the helper also short-circuits on a null shell,
which is what an unassigned local or a first loop iteration hands in.

Claude-Session: https://claude.ai/code/session_01Uzg5VKBucCeHxan34VAsjv
…ocal

the prologue retains a tracked param's shell so the exit release stays balanced
with the caller, but the payload behind slot 8 belongs to the caller's tuple.
cascading one would drop a count the caller still holds. params record no
producing type id today, so this was only reachable by shadowing a `T!` param
with a same-named local bind — cheap to rule out outright.
valgrind on tests/cases/test_http2_tls_roundtrip (the curated memcheck gate)
turned up an invalid read once `.ok` reads were treated as cascade-safe:
baseline passes 3/3, with `.ok` whitelisted it fails 3/3 with a use-after-free
of a 48-byte block, freed on the main thread and then read from a spawned
server task.

the counts balance locally — `r := attempt.ok` does emit a retain, and the two
releases at the exit edge match the two counts — so the cascade itself is not
obviously wrong. what changes is that the ok payload is now actually FREED
where it previously leaked, and something in the http2 path outlives it. that
is a latent ownership bug the leak was hiding, not something this change can
settle safely.

so `.ok`/`.err` come back out of the whitelist. what is left is the flag reads
(`.is_ok`, `.is_err`, `== none`, `!= none`), which only touch slot 0 and never
hand the payload to anyone — a tuple local whose value is never extracted is
unambiguously the sole owner. that still fixes the optional repro (295MB -> flat)
and any result local that is only ever probed, and leaves the `.ok` case leaking
exactly as before. an under-fix that leaks beats a use-after-free.
a re-measurement of the threaded server puts per-request growth at
~2.8 kb (218 mb over 78k requests), not the ~0.8 kb this document has
carried since july. the old figure understated it, and the growth now
has a cause worth naming rather than being filed as "a real per-request
leak that is still unfixed": serve_connection reads each request into a
`T!` local and reads it back with `.ok`, and such a local is released as
a bare three-slot shell, leaking the HttpRequestBytes it owns.

performance.md gains an entry for what the reclamation does fix, with
the million-iteration numbers (295 mb -> 2.5 mb for a probed optional,
295 mb -> 2.7 mb for a probed result, unchanged for a `.ok` read) and
the reason `.ok` is left alone. ownership.md's list of known gaps gains
the same case, since that list is where someone would look for it.

throughput numbers are left as they were: this machine had been running
benchmarks all session and is in no state to produce an honest req/s.
growth-per-request is a ratio and survives that.
the fmt gate wants the two new tracking globals' trailing comments a
single space off the declaration, not column-aligned.
@kacy
kacy merged commit 310823e into main Jul 23, 2026
2 checks passed
@kacy
kacy deleted the result-local-arc-fix branch July 23, 2026 11:39
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.

1 participant