Fix for-await-of IteratorClose on abrupt completion#620
Conversation
`for await...of` was not calling the iterator's `.return()` method when the loop exited early via break, return, or throw — in both interpreter and bytecode modes. Interpreter: add CloseAsyncIteratorPreservingError (calls `.return()`, awaits the result per AsyncIteratorClose spec) at all four abrupt- completion sites in the async iterator path, and add the existing CloseIteratorPreservingError calls to the sync iterator fallback path. Bytecode compiler: mirror the sync CompileForOfStatement pattern — NeedsIteratorClose detection, CloseErrorReg allocation, OP_PUSH_HANDLER, TPendingFinallyEntry with IsIteratorClose, and OP_ITER_CLOSE + OP_THROW at the handler target. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds async-aware iterator-close across compiler, evaluator, and VM: marks pending-finally entries as async, emits async-mode OP_ITER_CLOSE from compiler, implements awaited iterator.return() paths in VM and evaluator, and adds tests covering break/return/throw and sync fallback cases. ChangesAsync Iterator Close on Abrupt Completion
Sequence Diagram(s)sequenceDiagram
participant Compiler
participant VM
participant Iterator
participant Evaluator
Compiler->>VM: emit OP_ITER_CLOSE async=1 on abrupt exit
VM->>Iterator: call return() (await in async path)
alt return() present and resolves to object
Iterator-->>VM: promise resolved with object
VM-->>Evaluator: continue/exit
else missing/primitive/undefined/null
Iterator-->>VM: resolved to invalid
VM-->>Evaluator: throw TypeError
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Suite TimingTest Runner (interpreted: 9,037 passed; bytecode: 9,037 passed)
MemoryGC rows aggregate the main thread plus all worker thread-local GCs. Test runner worker shutdown frees thread-local heaps in bulk; that shutdown reclamation is not counted as GC collections or collected objects.
Benchmarks (interpreted: 407; bytecode: 407)
MemoryGC rows aggregate the main thread plus all worker thread-local GCs. Benchmark runner performs explicit between-file collections, so collection and collected-object counts can be much higher than the test runner.
Measured on ubuntu-latest x64. |
Benchmark Results407 benchmarks Interpreted: 🟢 206 improved · 🔴 83 regressed · 118 unchanged · avg +4.4% arraybuffer.js — Interp: 🟢 6, 🔴 4, 4 unch. · avg +1.5% · Bytecode: 🟢 9, 🔴 1, 4 unch. · avg +4.5%
arrays.js — Interp: 🟢 18, 1 unch. · avg +5.3% · Bytecode: 🟢 18, 1 unch. · avg +13.6%
async-await.js — Interp: 🟢 5, 1 unch. · avg +7.2% · Bytecode: 🟢 5, 1 unch. · avg +11.2%
async-generators.js — Interp: 🟢 2 · avg +5.7% · Bytecode: 🟢 2 · avg +11.2%
base64.js — Interp: 🟢 3, 🔴 4, 3 unch. · avg +0.8% · Bytecode: 🟢 3, 🔴 7 · avg +0.6%
classes.js — Interp: 🟢 15, 16 unch. · avg +3.2% · Bytecode: 🟢 14, 17 unch. · avg +3.7%
closures.js — Interp: 🟢 5, 🔴 1, 5 unch. · avg +3.0% · Bytecode: 🟢 11 · avg +10.2%
collections.js — Interp: 🟢 12 · avg +9.3% · Bytecode: 🟢 12 · avg +10.0%
csv.js — Interp: 🔴 5, 8 unch. · avg -2.3% · Bytecode: 🟢 13 · avg +8.5%
destructuring.js — Interp: 🟢 15, 🔴 1, 6 unch. · avg +4.3% · Bytecode: 🟢 18, 4 unch. · avg +5.9%
fibonacci.js — Interp: 🟢 8 · avg +5.7% · Bytecode: 🟢 8 · avg +11.8%
float16array.js — Interp: 🟢 13, 🔴 9, 10 unch. · avg +9.4% · Bytecode: 🟢 30, 2 unch. · avg +18.3%
for-of.js — Interp: 🟢 4, 3 unch. · avg +2.0% · Bytecode: 🟢 7 · avg +10.1%
generators.js — Interp: 🟢 4 · avg +4.4% · Bytecode: 🟢 4 · avg +7.8%
iterators.js — Interp: 🟢 7, 🔴 17, 18 unch. · avg -1.3% · Bytecode: 🟢 41, 1 unch. · avg +9.2%
json.js — Interp: 🟢 20 · avg +7.8% · Bytecode: 🟢 18, 2 unch. · avg +8.9%
jsx.jsx — Interp: 🟢 20, 1 unch. · avg +4.7% · Bytecode: 🟢 20, 1 unch. · avg +6.7%
modules.js — Interp: 🟢 2, 🔴 1, 6 unch. · avg +0.4% · Bytecode: 🟢 9 · avg +15.2%
numbers.js — Interp: 🟢 1, 🔴 2, 8 unch. · avg +0.2% · Bytecode: 🟢 11 · avg +7.7%
objects.js — Interp: 🟢 1, 🔴 1, 5 unch. · avg +0.5% · Bytecode: 🟢 6, 1 unch. · avg +8.8%
promises.js — Interp: 🟢 10, 2 unch. · avg +7.1% · Bytecode: 🟢 12 · avg +10.2%
regexp.js — Interp: 🔴 9, 2 unch. · avg -3.0% · Bytecode: 🟢 5, 🔴 6 · avg -1.0%
strings.js — Interp: 🟢 7, 🔴 1, 11 unch. · avg +4.2% · Bytecode: 🟢 13, 🔴 1, 5 unch. · avg +5.5%
tsv.js — Interp: 🔴 9 · avg -6.5% · Bytecode: 🟢 9 · avg +8.9%
typed-arrays.js — Interp: 🟢 11, 🔴 5, 6 unch. · avg -0.5% · Bytecode: 🟢 20, 2 unch. · avg +34.1%
uint8array-encoding.js — Interp: 🟢 2, 🔴 14, 2 unch. · avg -21.0% · Bytecode: 🟢 5, 🔴 10, 3 unch. · avg -14.2%
weak-collections.js — Interp: 🟢 15 · avg +69.1% · Bytecode: 🟢 15 · avg +40.3%
Deterministic profile diffasync-generators
Measured on ubuntu-latest x64. Benchmark ranges compare cached main-branch min/max ops/sec with the PR run; overlapping ranges are treated as unchanged noise. Percentage deltas are secondary context. |
test262 Conformance
Areas closest to 100%
Per-test deltas (+7 / -0)Newly passing (7):
Steady-state failures are non-blocking; regressions vs the cached main baseline (lower total pass count, or any PASS → non-PASS transition) fail the conformance gate. Measured on ubuntu-latest x64, bytecode mode. Areas grouped by the first two test262 path components; minimum 25 attempted tests, areas already at 100% excluded. Δ vs main compares against the most recent cached |
OP_ITER_CLOSE now uses its B operand as an async flag (B=1 means await the .return() result per AsyncIteratorClose spec). The compiler sets B=1 for for-await-of and a new IsAsyncIterator field on TPendingFinallyEntry carries the flag through EmitPendingEntryCleanup. Also adds the missing sync-fallback-on-return test and reverts the unrelated MismatchJump reorder from the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
tests/language/for-of/for-await-of.js (1)
361-388: ⚡ Quick winConsider strengthening the assertion to verify the
.return()promise is awaited.The test currently logs
"return"synchronously before the promise settles. While the test verifies that.return()is called, it doesn't verify that the engine awaits the returned promise—per the ES spec requirement forfor-await-ofon abrupt completion. Moving the logged marker into the promise chain makes this assertion stricter.Proposed enhancement
return() { - log.push("return"); - return Promise.resolve({ done: true }); + log.push("return:start"); + return Promise.resolve().then(() => { + log.push("return:end"); + return { done: true }; + }); }Then update the final assertion:
- expect(log.join(",")).toBe("next:1,next:2,return"); + expect(log.join(",")).toBe("next:1,next:2,return:start,return:end");🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/language/for-of/for-await-of.js` around lines 361 - 388, The test should ensure the for-await-of loop actually awaits the promise returned by the async iterator's return method: change asyncIter's return() to return a promise that logs only after it resolves (e.g. return Promise.resolve().then(() => { log.push("return"); return { done: true }; })), keeping the rest of the test (including the expect for "next:1,next:2,return") unchanged; this uses the asyncIter and its return() method in the "calls async iterator return on break" test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@source/units/Goccia.Evaluator.pas`:
- Around line 2045-2075: The code is calling
CloseAsyncIteratorPreservingError/CloseIteratorPreservingError for non-exception
completions (cfkBreak/cfkReturn), which suppresses iterator .return() failures;
change the break/return paths to call the non-preserving cleanup functions
(AsyncIteratorClose/IteratorClose) instead of
CloseAsyncIteratorPreservingError/CloseIteratorPreservingError so that
close-time errors replace the loop completion rather than being swallowed;
update the call sites that currently reference CloseAsyncIteratorPreservingError
(and the synchronous CloseIteratorPreservingError) in the cfkBreak/cfkReturn
handling to call the non-preserving versions and keep Close*Preserving* only on
the exception/throw paths.
In `@source/units/Goccia.VM.pas`:
- Around line 4246-4255: CloseRawIteratorPreservingError currently swallows all
exceptions from CloseRawAsyncIterator, which hides rejections for non-throw
abrupt completions; change the helper to accept the original completion kind (or
a boolean like wasThrowCompletion) and only suppress exceptions when the
original completion was a Throw, otherwise propagate the exception back to the
caller so the VM can replace the pending completion with the iterator-return
failure. Update all call sites that route abrupt completions through this helper
(e.g., OP_ITER_CLOSE and the cleanup paths from CompileBreakStatement /
CompileReturnStatement → EmitPendingCleanup → EmitPendingEntryCleanup) to pass
the correct completion-kind flag, and ensure CloseRawIteratorPreservingError
uses CloseRawAsyncIterator / CloseRawIterator unchanged but re-raises or returns
the error when the original completion was not a Throw.
---
Nitpick comments:
In `@tests/language/for-of/for-await-of.js`:
- Around line 361-388: The test should ensure the for-await-of loop actually
awaits the promise returned by the async iterator's return method: change
asyncIter's return() to return a promise that logs only after it resolves (e.g.
return Promise.resolve().then(() => { log.push("return"); return { done: true };
})), keeping the rest of the test (including the expect for
"next:1,next:2,return") unchanged; this uses the asyncIter and its return()
method in the "calls async iterator return on break" test.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 612887aa-4091-4970-bcbf-a126e07f4a86
📒 Files selected for processing (4)
source/units/Goccia.Compiler.Statements.passource/units/Goccia.Evaluator.passource/units/Goccia.VM.pastests/language/for-of/for-await-of.js
Per AsyncIteratorClose step 6–7: only suppress .return() errors when the original completion is a throw. Break/return must let close-time errors surface as the new completion. Split evaluator CloseAsyncIterator (propagates) from CloseAsyncIteratorPreservingError (swallows). Use OP_ITER_CLOSE C operand as a preserve-error flag (C=1 on exception handler targets, C=0 on break/return via EmitPendingEntryCleanup). Strengthen the break test to verify the .return() promise is actually awaited, and add a test for .return() rejection surfacing on break. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Covers the second case CodeRabbit suggested: for-await-of with an in-loop return where iterator.return() rejects — the rejection must surface as the new completion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/language/for-of/for-await-of.js`:
- Around line 451-477: The test "preserves original error when return also
throws" currently doesn't verify that the iterator's return() was invoked; add a
boolean flag (e.g., returnCalled = false) and set it to true inside the
asyncIter's return() method, then after the try/catch add an assertion that
returnCalled is true in addition to the existing
expect(caughtMessage).toBe("body-threw"); this ensures the async iterator's
return() actually ran (refer to asyncIter, its return() method, the
caughtMessage variable and the test block).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3abc5070-63ab-4b68-9c89-0b4d6996c7ca
📒 Files selected for processing (1)
tests/language/for-of/for-await-of.js
Add returnCalled counter to the "preserves original error when return also throws" test so it cannot false-positive when iterator close is skipped entirely. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
for await...ofnot calling the async iterator's.return()method when the loop exits early viabreak,return, orthrow— in both interpreter and bytecode modes.CloseAsyncIteratorPreservingErrornested procedure (calls.return(), awaits the result per ES2026 AsyncIteratorClose) at all four abrupt-completion sites in the async iterator path; add the existingCloseIteratorPreservingErrorcalls to the sync iterator fallback path.CompileForOfStatementpattern —NeedsIteratorClosedetection,CloseErrorRegallocation,OP_PUSH_HANDLER,TPendingFinallyEntrywithIsIteratorClose, andOP_ITER_CLOSE+OP_THROWat the handler target.Testing