fix(ir,codegen): release caught exceptions through the owned-local lifecycle (#448)#477
Conversation
…fecycle Fixes illegalstudio#448. Every handled throw leaked its throwable (~48 bytes per catch): the catch binding moved the in-flight exception (refcount 1, owned by _exc_value) into the variable's slot via Op::CatchBind — a raw store the cleanup machinery never saw. The frame epilogue releases only slots written by Op::StoreLocal, so the catch slot escaped rebind release, function-epilogue release, and program-exit release alike. Three coordinated changes: - ir_lower lower_catch_bind: the bound slot now follows the owned-local lifecycle — the previous binding is released before the rebind (slot is zero-initialized, so the first pass is a runtime no-op) and the local is marked initialized so later explicit stores release the old value. A variable-less catch consumes the reference through a hidden owned temporary instead of dropping it. - ir_lower lower_throw: throwing a borrowed value (a load of an owned local, e.g. rethrowing the caught variable with 'throw $e') retains it, so inner and outer bindings each own a reference; throwing an owning temporary ('throw new E()', 'throw f()') still transfers its reference. - codegen_ir frame: local_slot_has_store also recognizes Op::CatchBind, so catch-bound slots join the epilogue cleanup set and are zero-initialized in the prologue (catch-not-taken paths stay safe). Rethrow chains, aliasing, finally (both paths), nested try, and escape via array retention verified balanced. Regression tests in tests/codegen/runtime_gc/regressions.rs. Known pre-existing edges (unchanged, filed separately): 'return $e' from inside a catch does not compile, and reassigning the catch variable to a new object mis-types the previous-value release as Mixed and leaks.
The linux-x86_64 CI failures on this PR's regression tests were not an emission defect (the PR's codegen is symmetric across targets) but a latent gate in the pre-existing x86_64 runtime: __rt_decref_object and the __rt_object_free_deep validator only accepted heap kind 4 (plain object), and __rt_decref_any had no kind-6 arm, while throwables are stamped heap kind 6. The ARM64 variants have no kind gate, so exception releases worked there. On x86_64 every release of a caught exception was a silent no-op (the incref path checks only the heap magic, so refcounts only ever went up) — latent while catch bindings were never released, exposed the moment this PR made those releases real. The three x86_64 release paths now treat kind 6 like kind 4, mirroring ARM64: decref_object and the deep-free validator accept both kinds, and decref_any dispatches kind 6 to the object decref helper. Verified on real linux-x86_64 (Docker): the previously-failing regression tests now pass (per-catch release, unbound catch, rethrow chain, multi-type catch); macOS ARM64 suite unchanged (8/8). Related follow-up filed separately: several x86_64 emitters stamp a transposed heap magic (0x4548504c vs canonical 0x454C5048), which makes refcount ops no-op for objects created through those paths (issue illegalstudio#482).
|
CI fix pushed ( Root cause: throwables are stamped heap kind 6, but the x86_64 Fix: the three x86_64 release paths now treat kind 6 like kind 4, mirroring ARM64's behavior. Verified on real linux-x86_64 via the Docker harness: the previously-failing regression tests (per-catch release, unbound catch, rethrow chain, multi-type catch) all pass; the macOS suite is unchanged. While tracing this I also found that several x86_64 emitters stamp a transposed heap magic ( |
Summary
Fixes #448. Every handled
throwleaked its throwable (~48 bytes percatch), so long-running throw/catch loops grew the heap without bound.Root cause
The exception's single reference (allocated at refcount 1, published to
_exc_valuebythrow) is moved into the catch variable's slot byOp::CatchBind— a raw store the cleanup machinery never sees. The frame epilogue only releases slots written byOp::StoreLocal, so a catch-bound slot escaped rebind release, function-epilogue release, and program-exit release alike; a variable-lesscatch (\Throwable)dropped the in-flight reference outright. The system was "balanced" only because the catch side leaked: making the slot owned without touchingthrowwould double-free on rethrow (throw $eleaves two slots sharing one reference).Fix (three coordinated changes)
lower_catch_bind(ir_lower) — the bound slot follows the ordinary owned-local lifecycle: the previous binding is released before the rebind (the slot is zero-initialized, so the first pass is a runtime no-op) and the local is marked initialized so later explicit stores release the old value. A variable-less catch consumes the reference through a hidden owned temporary instead of dropping it.lower_throw(ir_lower) — throwing a borrowed value (a load of an owned local, e.g. rethrowing the caught variable) retains it, so inner and outer bindings each own a reference; throwing an owning temporary (throw new E(),throw f()) still transfers.local_slot_has_store(codegen frame) — also recognizesOp::CatchBind, so catch-bound slots join the epilogue cleanup set and the prologue zero-init set (catch-not-taken paths stay safe); the direct-return exclusion continues to apply.Verification
Adversarially reviewed across 23 shapes — no double free, no bad-refcount abort, no divergence: throw/catch loops (50 and 1000 iterations), unbound catch, rethrow chains up to 3 deep reusing the same variable name, aliasing (
$x = $e,throw $x), sequential try/catch reusing$e, multi-clause and multi-type catches, nested try,finallyon both the caught and the uncaught-propagating paths,throwof globals/properties/array elements (acquire lanes checked in the IR), the uncaught fatal path, and escape via array retention (byte-identical to a no-try control). Generator-internal catches improve: previously the caught exceptions leaked; now they are freed.8 regression tests in
tests/codegen/runtime_gc/regressions.rs. Full suites green: exceptions/generators/fibers/spl (472), runtime_gc/control/oop/types (1028), error_tests (993); build warning-free.Known edges (pre-existing, verified identical on
main, filed separately)return $efrom inside a catch does not compile (unsupported: runtime_call … returning Object) — catch-specific; returning an ordinary object local works.$e = new X()) mistypes the previous-value release asMixedand leaks — this fix is neutral on that shape (identical block counts tomain).new E(str_repeat(...))) is not persisted and can be corrupted by later concat/itoa activity — unrelated to the catch lifecycle.