Skip to content

fix(codegen): a taken throw out of a packed loop no longer loses loop-carried locals - #9215

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9185-throw-writeback
Aug 30, 2026
Merged

fix(codegen): a taken throw out of a packed loop no longer loses loop-carried locals#9215
proggeramlug merged 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9185-throw-writeback

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

This is a correctness fix for a silent wrong answer currently on main, from my own #9185. Reverting the admission is the safe move; the real fix is #9210.

The defect

#9185 admitted Stmt::Throw to the packed-f64 versioned loop, reasoning that the throw block ends in unreachable, so "control never returns to the loop and nothing reads what the clone cached". The second half is false — an unwind lands in a catch, which can read anything the loop wrote.

break, continue and return leave the clone through normal CFG edges that flush the loop-carried locals back to their frame slots. An unwind edge does not:

const arr: number[] = [];
for (let i = 0; i < 64; i++) arr.push(i);
const PRE = new Error("boom");

let s = 0;
try {
  for (let i = 0; i < arr.length; i++) { if (arr[i] === 40) throw PRE; s += arr[i]; }
} catch (e) { console.log(s); }   // perry: 0     node: 780
shape (loop-carried local read AFTER the exit) node main @ 84185b5656 ABRUPT=0 this PR
taken break 780 780 780 780
taken continue 1024 1024 1024 1024
taken throw, accumulate after 780 0 780 780
taken throw, accumulate before 820 0 820 820
taken throw, read via closure 780 780 780 780

The 0.5.1220 release (pre-#9185) also gives 780, which confirms #9185 as the regression rather than something older.

Why #9185's tests passed

Both of its taken-throw tests are blind to this by construction: throwPre reads the thrown value and an untouched parameter, throwValue throws s itself. Both observe the clone's live SSA value, which was correct — never the frame slot left behind. The closure row above is correct for the mirror-image reason: a captured accumulator is boxed rather than register-promoted, so there is no promoted copy to lose. That is what kept the defect narrow enough to slip through.

The added test reads a loop-carried local after each abrupt exit. Its break and continue rows are not padding — they are what establish that the defect is specific to the unwind edge rather than to abrupt exits in general.

The rule, which #9154's labeled-break bug also obeyed: an abrupt-exit optimisation is only tested by an exit that is actually taken AND a subsequent read of something the loop wrote. Satisfying one of those two conditions is what every test here did.

Cost, stated plainly

This gives back #9185's win. A counted loop containing a throw returns to 16.00 ns/op against node's 1.17 (13.7× slower) — the pre-#9185 gap, and now the worst shape in that benchmark; break (1.00) and the throw-free loop (1.00) both beat node (1.32, 2.26). I would rather be 13.7× slower than silently wrong, and #9210 is the way back: emit the writeback at the throw site, which is the piece #9185 was missing. The admission predicate was never the problem.

Validation

  • packed_loop_abrupt_statements 9/9, loop_property_array_hoist 12/12, issue_8690_loop_versioned_arraylike 3/3
  • perry-hir 591/591, perry-codegen 21/21
  • 9 differential programs against node, including four shapes that shadow Error (user class, const function, function-valued binding, block-scoped class) — all node-identical
  • RUSTFLAGS="-D warnings" cargo check --workspace --all-targets: 8 errors, all in perry-ui-macos, all present on a pristine origin/main with this diff stashed
  • Size: __text unchanged (10,968,212 B, 0.00%), __const +64 B, file +8 B

Refs #9151, #9185, #9210

Summary by CodeRabbit

  • Bug Fixes

    • Fixed incorrect results when throw statements occur inside optimized packed-array loops.
    • Ensured loop-carried values remain correct when execution transfers to a catch block.
    • Prevented unsafe fast-path execution for loops containing throw statements.
    • Improved reliability when thrown exceptions are handled during loop execution.
  • Tests

    • Added coverage for preserved loop values across thrown exceptions, including access through closures.

@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 18f9aeeb-b859-4063-94c6-0885ecce040c

📥 Commits

Reviewing files that changed from the base of the PR and between cdc5211 and 9376282.

📒 Files selected for processing (7)
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-ui-macos/src/file_dialog.rs
  • crates/perry-ui-macos/src/lib_ffi/system.rs
  • crates/perry-ui-macos/src/lib_ffi/window_misc.rs
  • crates/perry-ui-macos/src/widgets/alert.rs
  • crates/perry-ui-macos/src/widgets/combobox.rs
  • crates/perry-ui-macos/src/widgets/webview.rs

📝 Walkthrough

Walkthrough

Packed-f64 loop fast clones now reject throw statements. Optional tracing reports packed-loop rejection reasons and call-freeness failures. Tests cover generic-path execution and loop-carried locals across unwind edges. macOS call sites remove redundant unsafe scopes without behavior changes.

Changes

Packed-loop throw handling

Layer / File(s) Summary
Compiler safety and diagnostics
crates/perry-codegen/src/stmt/loops.rs
Stmt::Throw remains excluded from packed-f64 fast clones. Packed-loop admission and fast-clone call-freeness failures can emit named diagnostics when PERRY_PACKED_LOOP_TRACE=1.
Throw regression coverage
crates/perry/tests/packed_loop_abrupt_statements.rs
Tests verify generic-path throw handling and preservation of loop-carried locals across taken throws, including closure reads.

macOS unsafe-scope cleanup

Layer / File(s) Summary
macOS call-site cleanup
crates/perry-ui-macos/src/file_dialog.rs, crates/perry-ui-macos/src/lib_ffi/{system.rs,window_misc.rs}, crates/perry-ui-macos/src/widgets/{alert.rs,combobox.rs,webview.rs}
Redundant inline unsafe scopes were removed from string conversion and text-handler call sites. Behavior remains unchanged.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to cdc52

Loops containing throws now use the established generic path, preventing stale loop-carried values after unwinding while preserving correct behavior for other abrupt exits. No actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main correctness fix: preventing taken throws in packed loops from losing loop-carried locals.
Description check ✅ Passed The description is detailed and covers the defect, root cause, behavioral impact, related issues, tests, validation results, and performance tradeoff. It does not follow the template headings or inclu…
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and covers the defect, root cause, behavioral impact, related issues, tests, validation results, and performance tradeoff. It does not follow the template headings or include the checklist, but the required substantive information is mostly present.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 2 commits August 31, 2026 00:14
…-carried locals

PerryTS#9185 admitted `Stmt::Throw` to the packed-f64 versioned loop on the
reasoning that the throw block ends in `unreachable`, so "control never
returns to the loop and nothing reads what the clone cached". The second
half of that is false: an unwind lands in a `catch`, which can read
anything the loop wrote.

`break`, `continue` and `return` leave the clone through normal CFG edges
that flush the loop-carried locals back to their frame slots. An unwind
edge does not, so a local the loop updated read back stale:

    let s = 0;
    try { for (let i = 0; i < arr.length; i++) {
            if (arr[i] === 40) throw PRE; s += arr[i]; } }
    catch (e) { console.log(s); }   // perry 0, node 780

Wrong in both orderings (780 and 820 expected, 0 observed), correct under
PERRY_PACKED_LOOP_ABRUPT=0, and correct on the 0.5.1220 release, which
confirms PerryTS#9185 as the regression.

This restores `Stmt::Throw` to unadmitted. Re-admitting needs the
writeback emitted at the throw site rather than only at the exit block;
that is PerryTS#9210, and the admission predicate is not the missing piece.

PerryTS#9185's own tests missed this because both taken-throw cases read the
thrown value or an untouched variable — the clone's live SSA value, which
was correct — never the frame slot left behind. A closure-captured
accumulator was also correct, being boxed rather than register-promoted,
which is what kept the defect this narrow. The added test reads a
loop-carried local AFTER each abrupt exit; its break and continue rows are
what establish that the defect is specific to the unwind edge.

Cost: a loop containing a throw returns to 16.00 ns/op against node's
1.17 (13.7x), the pre-PerryTS#9185 gap. Correctness first; PerryTS#9210 is the way back.

Size: __text unchanged (10,968,212 B, 0.00%), __const +64 B, file +8 B.

Refs PerryTS#9151, PerryTS#9185, PerryTS#9210
Six inner unsafe blocks nested inside an outer one, each wrapping a single
str_from_header call. rustc reports 12 unnecessary-unsafe warnings across the
lib and test targets, which fails the workspace warnings gate under -D warnings
on any PR that invalidates this crate's check cache. Pre-existing since PerryTS#8453.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Superseded by #9230, which contains this revert as its first commit and then fixes the underlying defect — the writeback is emitted at the throw site, so the loop stays on the fast path AND is correct (7.99 → 0.95 ns/op, against node's 1.10).

Merging either one resolves the wrong answer on main. This PR is the smaller, more obviously-correct change if the preference is to stop the bleeding first and review the real fix separately.

@proggeramlug
proggeramlug force-pushed the fix/9185-throw-writeback branch from cdc5211 to 9376282 Compare August 30, 2026 22:52
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Merged. I merged #9185, so this is my miss as much as yours — and the reason it got through is worth writing down, because my review specifically went looking for this hazard and cleared it on a wrong distinction.

I reasoned: an unwind could return control to the loop, but Stmt::Try { .. } => false rejects a try inside the loop body, so the handler is always outside the loop being cloned — therefore control leaves and nothing reads the cache. The gap is that "leaves the loop" and "flushes the loop-carried locals" are different things. A catch outside the loop is still inside the function, and s is still in scope there. Your one-line statement of it is the correct one: normal CFG edges flush, the unwind edge doesn't.

Verified the premise rather than taking it, because a peer running a nine-shape packed-loop battery could not reproduce it on main and I was not going to revert a working optimization on an unconfirmed claim. A/B on one binary, loops.rs swapped, runtime and stdlib static wrappers rebuilt on both arms so the coherence stamp matched:

shape main (Throw admitted) this PR node
accumulate after the throw 0 780 780
accumulate before the throw 0 820 820
two locals, s + ":" + n 0:0 780:40 780:40
nested loops, s + hits 17594 17646 17646
read via closure 780 780 780
taken break / continue / return correct correct correct

Two things that came out of chasing the non-reproduction, both worth having in the regression test:

  • The nested case is a partial flush, not a total one — 17594 against node's 17646, short by 52. Some iterations' contributions reach the frame slot and the last ones don't. That row matters more than the zeros: 0 looks obviously broken, 17594 looks like a plausible answer. A fix validated only against the 0-valued shapes could have left this one standing.
  • Two things independently hide it: a local captured by a closure gets boxed (the boxed path never had the bug), and a function-local array receiver takes a different tier and is correct even on the broken compiler. The peer's battery used function-local receivers throughout, which is why nine shapes came back clean against a live bug. Pairing a module-level and a function-local receiver, and a direct and a closure read, and asserting they agree, is what makes such a battery able to see this class at all.

Validation: packed_loop_abrupt_statements 9/9 including your new a_loop_carried_local_survives_a_taken_throw; perry-runtime and perry-codegen green; all 60 lint gates green.

Agreed that reverting the admission is right and #9210 is the real fix. Worth carrying this table into #9210 as its acceptance criteria — particularly the nested partial-flush row, since that's the one a "does the value survive" test would pass while still being wrong.

@proggeramlug
proggeramlug merged commit 8651ce0 into PerryTS:main Aug 30, 2026
14 of 15 checks passed
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Correction to the record: this squash carried one commit that isn't yours.

A git checkout of your branch aborted on a dirty loops.rs in my worktree, so my HEAD was still on the validation branch when I pushed — which meant 937628231a fix(ui): drop redundant unsafe blocks in perry-ui-macos went in alongside your fix. My mistake, not a deliberate bundling.

What landed on main as 8651ce0 is therefore your throw-admission revert plus six redundant unsafe blocks removed from perry-ui-macos (file_dialog.rs, lib_ffi/{system,window_misc}.rs, widgets/{alert,combobox,webview}.rs). That second change is unrelated to packed loops: rustc reports 12 unnecessary unsafe block warnings across the lib and test targets there, which fails the workspace warnings gate under -D warnings on any PR that invalidates that crate's check cache. Pre-existing since #8453, and it was making the gate red for everyone.

Both changes were validated together on the branch I actually built — packed_loop_abrupt_statements 9/9, perry-runtime and perry-codegen green, all 60 lint gates green, and the node-differential table in my previous comment — so main is correct, and nothing needs reverting. But your PR number now points at a diff wider than what you wrote, and that shouldn't be discovered later by someone reading git log.

The perry-ui-macos change is mine to answer for if it turns out to be wrong.

proggeramlug added a commit that referenced this pull request Aug 31, 2026
…4 PRs through this hole in one day) (#9256)

* docs(contributing): do not cancel the CI run of the PR being merged

A cancelled job is neither a pass nor a failure, and two protections go
quiet together: pr-gate never reports (so the required context is absent
rather than red, which is what invites the bypass), and the changelog
fragment check — a step inside lint, conditioned on pull_request — is
skipped silently, so the omission stays invisible until release notes are
cut.

Both were observed on the same day. #9169 merged with lint failing and five
jobs cancelled, breaking method dispatch and property lookup on main for
four and a half hours (#9247). #9215, #9230 and #9235 each merged with lint
CANCELLED; all three touched crates/, none carried a fragment, and the work
is absent from its release notes.

States explicitly that the gate is correct and should not be changed: gate
in test.yml runs if: always() and treats cancelled as failure, exactly so a
cancelled dependency cannot read as green. Every incident has been a bypass
of a working gate.

Docs only.

* docs(contributing): teach 'pr-gate present and passing', not 'nothing red'

A gate that never ran is absent from the status list, so it reads as clean
under any failure filter — the same way CANCELLED does. 'pr-gate: pass' is a
positive assertion that the fan-in ran and every dependency was success or
skipped; '0 failing' is satisfied equally by a PR whose gate never executed.

Extends the note to release automation, where the same hole exists one level
up: a skipped or absent required context satisfies 'not failing', so the
dispatch condition has to require conclusion == success.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 31, 2026
….5.1519 (#9255)

#9215, #9230 and #9235 merged without changelog fragments, so the v0.5.1519
notes carry no mention of the change. Only one entry is actually missing:
#9215 and #9230 fix a regression from #9185 that was introduced and repaired
entirely within the unreleased window, so no released version ever exhibited
it and describing it as a fix would tell readers their current version is
affected when none ever was.

The fragment states the shipping release explicitly, so folding it into the
next set of notes reads as a correction rather than as a new change.

Docs only; no code, and no effect on the frozen v0.5.1519 candidate.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

1 participant