Skip to content

A return deferred to finally survives an exception from that finally and is applied by an unrelated finally later #370

Closed

Description

@Masterplanner25

Summary

A return deferred to a finally block is stored in a single VM-wide slot
(VM._deferred_return) and is only cleared when a FINALLY_END consumes it. If
the finally block raises instead of completing, the deferred return is never
consumed — it stays pending and is applied by whatever unrelated FINALLY_END
runs next, in a completely different part of the program.

Found while fixing #361; the two share the same state machine.

Reproduction

fn f() { try { return 1 } catch e { print("f catch") } finally { throw "x" } }
try { f() } catch e2 { print("caught: \(e2.message)") }
try { print("body") } catch e3 { print("e3") } finally { print("fin") }
print("after")

Actual (v4.1.1 and dev source, CLI and embedded):

Runtime error at ...:3:64: FINALLY_END deferred return outside function
Stack trace:
  at <main> (...:3:64)
caught: x
body
fin

exit code 1. Expected:

caught: x
body
fin
after

The second try/catch/finally is unrelated to f() — it has no return at
all. It inherits f's stranded deferred return of 1, tries to pop a frame
that does not exist at module level, and dies with an internal VM message.

Expected behavior

An exception raised inside a finally supersedes a return that block was
about to complete. The pending return should be discarded when the exception
escapes the region that owes it, and unrelated finally blocks must never see it.

Fix direction

handle_exception should drop deferred state owned by a finally region it has
unwound out of. The region can be identified by the handler-stack depth recorded
when the return was deferred: if the handler receiving the exception sits outside
that depth, the region will never reach its FINALLY_END, so the pending action
is dead. A try nested inside the finally that catches its own error is at
the same depth or deeper and must not clear it.

Affected versions

v4.1.1 (current published) and dev source. Reproduces under CLI and embedded
NodusRuntime. Predates 4.1.1.

Activity

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

    @Masterplanner25
    OwnerAuthor

    Fixed in #372 (6175600), together with #361 — both are the same single-slot deferral mechanism.

    handle_exception now discards deferred state owned by a finally region it has unwound out of. The region is identified by the handler-stack depth recorded when the action was deferred: if the handler receiving the exception sits outside that depth, the region will never reach its FINALLY_END and the pending action is dead.

    The guard is deliberately not eager — an error raised and caught inside the finally is at the same depth or deeper, so the deferred return still lands. Both directions are tested:

    • test_return_stranded_by_a_raising_finally_does_not_leak — the reproduction from this issue; fails against the unfixed VM with FINALLY_END deferred return outside function
    • test_a_caught_error_inside_the_finally_keeps_the_deferred_return — the over-eager-guard case; passes before and after, which is what makes it a useful guard

    Both run under the CLI and embedded NodusRuntime.

  3. added 2 commits that reference this issue on Aug 30, 2026
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