Repository navigation
finally is skipped when catch rethrows — and its regression test cannot fail #361
Description
Summary
finally does not run when the catch block rethrows. Cleanup placed in
finally — closing handles, releasing locks, restoring state — is silently
skipped on the exact path where it matters most.
Found running Gate 10 creator validation against the published 4.1.1 wheel
installed from PyPI in a clean venv. Reproduces identically on dev source and
under embedded NodusRuntime.
Reproduction
fn f() {
try { throw "boom" }
catch e { print("A caught"); throw e }
finally { print("B finally") }
}
try { f() } catch e2 { print("C outer") }
Actual:
A caught
C outer
Expected: B finally between them.
The other exit paths are fine — verified individually:
| Path | finally runs? |
|---|---|
| catch without rethrow | ✅ |
return from try |
✅ |
| no exception | ✅ |
rethrow from catch |
❌ |
This contradicts the documentation
docs/guide/error-handling.md:
finallyruns aftertrycompletes (with or without an error) and after
catchcompletes — including whencatchcontains areturn. All five
exit paths from atry/catch/finallyblock correctly executefinally.
Why it shipped: the regression test cannot fail
tests/test_finally_after_catch_return.py::test_finally_runs_when_inner_error_propagates
exists to cover exactly this path. Its fixture:
} catch e {
print("inner_finally_ran") <-- catch prints this
throw e
} finally {
print("inner_finally_ran") <-- finally prints the SAME string
}
and then asserts "inner_finally_ran" in stdout.
The catch block prints the same string the finally block does, so the
assertion is satisfied by the catch alone. Running that script verbatim emits
inner_finally_ran exactly once — from the catch — and the test still
passes. It is structurally incapable of detecting the bug it was written for.
Suggested fix for the test, independent of the runtime fix: give each block a
distinct marker and assert on both, or assert on the ordered sequence
rather than membership.
Impact
Correctness, and the failure is silent. Any .nd code using the standard
acquire/try/finally-release idiom leaks whatever finally was responsible
for, but only on the rethrow path — so it survives ordinary testing and shows up
under error conditions in production.
Affected versions
v4.1.1 (current published), dev source, and embedded. The vacuous test predates
this release, so the bug is likely much older — worth bisecting rather than
assuming it is new.
Activity
docs(design,evals): run the skipped Gate 10 for 4.1.1 — finds #361 (#362
docs(governance): record the sweep's nine findings; fix a repeated fa…
docs(runtime): document 2 undocumented opcodes and 9 missing sandbox …
fix(vm): run finally when catch rethrows; scope deferred state (#361, #…
Masterplanner25 commentedon Aug 15, 2026
Fixed in #372 (
6175600).The finally-gate that
handle_exceptionleaves on the handler stack for a catch-with-finally was being skipped during propagation. It now defers the exception the wayRETURNdefers a return: unwind to the catch's recorded depths, jump into thefinally, and resume propagation atFINALLY_END. All five exit paths runfinallyexactly once; an error from thefinallyitself supersedes a pending re-throw or return.Fixing it exposed two siblings in the same single-slot mechanism, filed and fixed in the same change:
returndeferred to afinallythat then raised stayed pending and was applied by an unrelatedFINALLY_ENDlater, surfacing at module level asFINALLY_END deferred return outside function.finallyconsumed each other's pending action.On the test that could not fail — you were right that it was the substantive part.
tests/test_finally_rethrow.pyhas 14 tests, each run under the CLI and embeddedNodusRuntime, asserting the ordered sequence of output rather than membership. 12 of the 14 fail against the unfixed VM, verified by stashing the fix and re-running rather than assumed. The two that pass are the guards that should pass either way.Both offending tests were corrected:
test_finally_runs_when_inner_error_propagates— the vacuous one from this issue — now asserts["inner_catch_ran", "inner_finally_ran", "outer_catch_ran"]in order.test_finally.py::test_exception_from_outer_try_still_caughtwas worse and wasn't in the report: it asserted the buggy output (nofinallyline) as expected behavior, so it actively defended the bug. Corrected.On bisecting: I did not, because the fix is not sensitive to when it regressed — the finally-gate design has skipped this path since
FINALLY_ENDwas introduced at v1.0, and #370/#371 are properties of the single-slot deferral rather than regressions.EXECUTION_INVARIANTS.mdI-VM-06 now holds again and names the tests that hold it.Masterplanner25 commentedon Aug 15, 2026
History confirmed — I said the origin wouldn't change the fix, but it was worth checking rather than asserting.
All three bugs date to
3d4f1ab(2026-03-15,v1.0 — finally blocks, opcode freeze, version 1.0.0) — the commit that introducedfinallysupport. Verified by running the three reproductions in a worktree at that commit:3d4f1ab(v1.0)A caught/C outer— finally never runsFINALLY_END deferred return outside functionret-B/nilIts parent
661d9acpins that as the origin rather than an earlier regression:finallywas not a keyword yet there, and the same script fails withUndefined variable: finally. At3d4f1abthe normal path works (body/fin) while the rethrow path is already broken — so the feature shipped with the gap, it was never lost.One correction to my own wording in #372: the finally-gate (
_FINALLY_GATE) is not from v1.0. It arrived atdac615b(2026-05-25, first released in v3.0.0), which fixedfinallybeing skipped when thecatchcontained areturn. Running the same three reproductions at that commit gives identical output to v1.0 — the gate changed how the return path works without touching the rethrow path, so the mechanism my fix modifies inherited the gap rather than causing it.Not a regression at any point, and every 4.x release is affected.
release: Gate 10 creator validation for v4.2.0; two findings filed
release: v4.2.0 (#414)
docs(evals): post-publish eval for v4.2.0 -- clean, no findings
docs(evals): post-publish eval for v4.2.0 -- clean, no findings (#417)
#415: try/finally needs no catch (#580)
feat: per-opcode semantic spec for the control-flow and frame opcodes (…