Skip to content

finally is skipped when catch rethrows — and its regression test cannot fail #361

Closed

Description

@Masterplanner25

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:

finally runs after try completes (with or without an error) and after
catch completes — including when catch contains a return. All five
exit paths
from a try/catch/finally block correctly execute finally.

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

  1. added
    bugSomething isn't working
    severity:highMajor feature broken or significantly wrong
    subsystem:vmBytecode virtual machine
    on Aug 7, 2026
  2. Masterplanner25 commented on Aug 15, 2026

    @Masterplanner25
    OwnerAuthor

    Fixed in #372 (6175600).

    The finally-gate that handle_exception leaves on the handler stack for a catch-with-finally was being skipped during propagation. It now defers the exception the way RETURN defers a return: unwind to the catch's recorded depths, jump into the finally, and resume propagation at FINALLY_END. All five exit paths run finally exactly once; an error from the finally itself 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:

    On the test that could not fail — you were right that it was the substantive part. tests/test_finally_rethrow.py has 14 tests, each run under the CLI and embedded NodusRuntime, 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_caught was worse and wasn't in the report: it asserted the buggy output (no finally line) 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_END was introduced at v1.0, and #370/#371 are properties of the single-slot deferral rather than regressions. EXECUTION_INVARIANTS.md I-VM-06 now holds again and names the tests that hold it.

  3. Masterplanner25 commented on Aug 15, 2026

    @Masterplanner25
    OwnerAuthor

    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 introduced finally support. Verified by running the three reproductions in a worktree at that commit:

    Reproduction At 3d4f1ab (v1.0)
    #361 rethrow skips finally A caught / C outer — finally never runs
    #370 stranded deferred return FINALLY_END deferred return outside function
    #371 coroutines swap deferred returns ret-B / nil

    Its parent 661d9ac pins that as the origin rather than an earlier regression: finally was not a keyword yet there, and the same script fails with Undefined variable: finally. At 3d4f1ab the 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 at dac615b (2026-05-25, first released in v3.0.0), which fixed finally being skipped when the catch contained a return. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingseverity:highMajor feature broken or significantly wrongsubsystem:vmBytecode virtual machine

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions