From a4a110fde3ad95fbd7a3b177845bb3a8157a9a72 Mon Sep 17 00:00:00 2001 From: Marcin Gasperowicz Date: Sat, 17 Oct 2026 22:51:12 +0100 Subject: [PATCH] compiler: drop with objects at a loop's continue landing A continue that leaves a with body through a finally keeps the with objects enclosing the try pushed, so the finally body still sees them. Nothing dropped them afterwards: the continue completion resumed at the loop's continue target with those objects still pushed. The next iteration's lookups inside a with then resolved through them. Each loop now truncates the frame's with objects to the loop's own depth at its continue landing, as it already did at its break landing. Normal fall-through reaches the landing with nothing extra pushed, so the truncate is a no-op there. It is only emitted when the loop body contains a with. Co-Authored-By: Claude Opus 5.5 --- pkg/compiler/compile_for_of_new.go | 1 + pkg/compiler/compile_statement.go | 11 +++++++---- tests/scripts/with_continue_through_finally.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 tests/scripts/with_continue_through_finally.ts diff --git a/pkg/compiler/compile_for_of_new.go b/pkg/compiler/compile_for_of_new.go index 34e60ea3..7ec9ca87 100644 --- a/pkg/compiler/compile_for_of_new.go +++ b/pkg/compiler/compile_for_of_new.go @@ -463,6 +463,7 @@ func (c *Compiler) compileForOfStatementLabeled(node *parser.ForOfStatement, lab for _, continuePos := range loopContext.ContinuePlaceholderPosList { c.patchJump(continuePos) } + c.emitWithTruncate(loopContext.WithDepth, loopContext.WithCount, node.Token.Line) // 15a. Per-iteration bindings: close upvalues for let/const loop variables // ECMAScript spec: each iteration gets fresh bindings. This ensures closures diff --git a/pkg/compiler/compile_statement.go b/pkg/compiler/compile_statement.go index 8d2fce60..44b04c6a 100644 --- a/pkg/compiler/compile_statement.go +++ b/pkg/compiler/compile_statement.go @@ -1171,6 +1171,7 @@ func (c *Compiler) compileWhileStatementLabeled(node *parser.WhileStatement, lab // (There's no header binding to close for `while` - only for/for-in/for-of // have one - so unlike those loops this is the only closing done here.) continueLandingPos := len(c.chunk.Code) + c.emitWithTruncate(loopContext.WithDepth, loopContext.WithCount, line) c.closeLoopBodyPerIterationBindings(loopContext, line) // --- Jump Back To Start --- @@ -1473,6 +1474,8 @@ func (c *Compiler) compileForStatementLabeled(node *parser.ForStatement, label s for _, continuePos := range loopContext.ContinuePlaceholderPosList { // Use context on stack c.patchJump(continuePos) // Patch placeholder to jump to current position } + // A continue routed through finally can still have with objects pushed. + c.emitWithTruncate(loopContext.WithDepth, loopContext.WithCount, node.Token.Line) // *** Per-iteration bindings: close upvalues for let/const loop variables *** // ECMAScript spec: each iteration gets fresh bindings. This is achieved by @@ -1716,10 +1719,8 @@ func (c *Compiler) compileContinueStatement(node *parser.ContinueStatement, hint } // Leave the `with` bodies between here and the loop. Routed through a - // finally, keep those enclosing the try: the finally body still sees them. - // FIXME: those then stay pushed into the next iteration (until the loop - // exits), since a continue completion resumed after finally can't drop - // them. + // finally, keep those enclosing the try: the finally body still sees them, + // and the loop's continue landing drops them afterwards. if keep := targetContext.WithDepth; c.currentFuncWithDepth > keep { if len(c.finallyContextStack) > 0 && !c.inFinallyBlock { if fd := c.finallyContextStack[len(c.finallyContextStack)-1].WithDepth; fd > keep { @@ -1870,6 +1871,7 @@ func (c *Compiler) compileDoWhileStatementLabeled(node *parser.DoWhileStatement, for _, continuePos := range loopContext.ContinuePlaceholderPosList { c.patchJump(continuePos) } + c.emitWithTruncate(loopContext.WithDepth, loopContext.WithCount, node.Token.Line) // #132: close per-iteration bindings for let/const/class declared directly // in the loop body, same as for/for-in/for-of/while. @@ -2595,6 +2597,7 @@ func (c *Compiler) compileForInStatementLabeled(node *parser.ForInStatement, lab for _, continuePos := range loopContext.ContinuePlaceholderPosList { c.patchJump(continuePos) } + c.emitWithTruncate(loopContext.WithDepth, loopContext.WithCount, node.Token.Line) // 8a. Per-iteration bindings: close upvalues for let/const loop variables // ECMAScript spec: each iteration gets fresh bindings. This ensures closures diff --git a/tests/scripts/with_continue_through_finally.ts b/tests/scripts/with_continue_through_finally.ts new file mode 100644 index 00000000..f23a3030 --- /dev/null +++ b/tests/scripts/with_continue_through_finally.ts @@ -0,0 +1,14 @@ +// A continue that leaves a with body through a finally drops the with object +// once the finally has run: the next iteration must not see it +// no-typecheck +// expect: out,leak,out,leak,out,out,out +var o = { x: "leak" }; +var x = "out"; +var seen = []; +for (var i = 0; i < 2; i++) { + with ({}) seen.push(x); + with (o) { try { continue; } finally { seen.push(x); } } +} +for (var k of [1, 2]) { with ({}) seen.push(x); with (o) { try { continue; } finally {} } } +with ({}) seen.push(x); +seen.join(",");