Skip to content

fix(stack): keep finally-block locals in distinct slots (no reuse)#3235

Merged
borisbat merged 1 commit into
masterfrom
bbatkin/fix-finally-stack-reuse
Jun 21, 2026
Merged

fix(stack): keep finally-block locals in distinct slots (no reuse)#3235
borisbat merged 1 commit into
masterfrom
bbatkin/fix-finally-stack-reuse

Conversation

@borisbat

Copy link
Copy Markdown
Collaborator

Symptom

A defer'd cleanup crashes when a function takes an early return before the deferred resource is declared. Minimal shape:

def main : int {
    if (cfg.help) { print_help(...); return 0 }   // early exit
    var p = open_something()                        // not reached on --help
    defer() { close_something(p) }                  // runs anyway -> p is garbage -> crash
}

defer lowers to the block's finally, which runs on every block exit — including the early return above, which precedes p's declaration.

Root cause

The interpreter already zeroes a finally-block's locals at block entry (the memzero in SimulateVisitor::visit(ExprBlock*), gated on finalList, with the comment "bad scenario we fight is ( in scope X ; return ; in scope Y )"). But the stack-reuse optimization defeats it: popSp() restores stackTop on sub-scope exit, so a later finally-referenced local can reuse a slot that an earlier sub-scope on the early-return path writes into — clobbering the zero. The finally then reads garbage (and dereferences it for a pointer local → access violation).

AOT never hits this: its finallyBeforeBody path pre-declares every block variable as distinct, memset-zeroed storage.

Fix

ast_allocate_stack.cpp: bump the existing doNotOptimize counter while inside a block that has a finalList (balanced across preVisit/visit of ExprBlock). That block's locals then keep distinct slots, so the block-entry memzero stays valid when the finally fires before a local's initializer ran. It's the interpreter analog of AOT's distinct-storage approach. No effect on blocks without a finally.

Tests

  • tests/bare_block/finally_stack_reuse.das — a finally referencing a local declared after an early return; asserts the local reads as 0 (not clobbered garbage). Fails before the fix, passes after.
  • Full suite green: 10405 passed, 0 failed, 0 errors.

🤖 Generated with Claude Code

A block with a finally section runs that section on every exit, including an early return that lexically precedes a later variable's declaration. The interpreter zeroes such locals at block entry, but the stack-reuse optimization (popSp restoring stackTop) could hand a finally-referenced local's slot to an earlier sub-scope, clobbering the zero -- so the finally saw garbage and crashed when it dereferenced a pointer local (e.g. a defer'd close on a resource declared after an early --help/error return).

Fix: bump doNotOptimize while inside a block that has a finalList, so its locals keep distinct slots and the block-entry memzero stays valid. Mirrors the AOT path, which already pre-declares every block var as distinct zeroed storage (finallyBeforeBody).

Regression test in tests/bare_block; full suite green (10405 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 21, 2026 04:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an interpreter-only crash/UB where finally (including defer-lowered finally blocks) could read uninitialized/garbage locals on early exits due to stack slot reuse within a block that has a finalList. The change aligns interpreter stack allocation behavior more closely with the AOT “distinct storage” approach for finally-reachable locals.

Changes:

  • Disable scoped stack slot reuse within any ExprBlock that has a non-empty finalList by incrementing/decrementing the existing doNotOptimize counter for that block’s traversal.
  • Add a regression test that exits early before a later local declaration and asserts the finally observes a zero-initialized value rather than clobbered garbage.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/ast/ast_allocate_stack.cpp Prevents stack slot reuse within blocks that have finally sections, preserving block-entry memzero correctness for early exits.
tests/bare_block/finally_stack_reuse.das Regression test ensuring finally-referenced locals declared after an early return remain zeroed (no reuse/clobber).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@borisbat borisbat merged commit 3b77d46 into master Jun 21, 2026
34 checks passed
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.

2 participants