Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions .github/workflows/eh-transport.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# CI arm for the exception transport (#7302) and its owned unwinder.
#
# Two things need exercising, and neither is visible from behavior alone:
#
# 1. The owned single-phase walker must actually CARRY throws. If it
# silently starts declining every throw, every test still passes — the
# system unwinder is the fallback and semantics are identical — and the
# only symptom is that the optimization quietly stopped happening. So
# this job asserts `fallback=0` with a nonzero `fast=` count, not merely
# that the program produced the right answer.
# 2. The walker's agreement with the system unwinder must be re-proven, not
# assumed from the bring-up run. `PERRY_EH_WALKER=diff` predicts the
# landing for every throw and asserts it inside the personality against
# the system unwinder's answer; this job requires a nonzero verified
# count and zero declines.
#
# Both are the "assert the subject was LIVE" rule from CLAUDE.md's "Four
# ways a gate can be unable to fail" — a green run here means the fast path
# ran and agreed, not that nothing threw.
#
# NON-REQUIRED until it has run green once on main; promote afterwards (a
# new gate has never been green, so promoting first blocks every PR).
name: eh-transport
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# No trigger-level `paths:` — a required check whose workflow is
# path-filtered at the trigger never creates a check run for unrelated PRs,
# so the context waits forever and blocks the merge. Filtering lives in the
# `changes` job: a job skipped by `if:` still reports a check run.
on:
pull_request:
push:
branches: [main]

concurrency:
group: eh-transport-${{ github.ref }}
# Cancel superseded PR runs, never main runs — a busy merge day would
# otherwise starve the gate to zero executions.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
relevant: ${{ steps.filter.outputs.relevant }}
steps:
- id: filter
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ "${{ github.event_name }}" = "push" ]; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
exit 0
fi
files=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
--paginate --jq '.[].filename')
if echo "$files" | grep -qE '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
else
echo "relevant=false" >> "$GITHUB_OUTPUT"
fi
Comment on lines +58 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix the Cargo.toml path filter to match nested crate manifests.

The regex on Line 60 has Cargo\.toml$ inside the outer ^(...) group. Because the whole alternation is anchored at ^, this branch requires the entire filename to be exactly Cargo.toml. It does not match crates/perry-runtime/Cargo.toml, which is the file this PR's stack uses to add the CFI-decoding dependency (per the stack outline: "Adds the unwinder module, CFI decoding dependency..." in crates/perry-runtime/Cargo.toml). A future PR that only bumps or edits that crate-level manifest will get relevant=false and this gate silently will not run, even though the change is squarely in scope.

🐛 Proposed fix for the Cargo.toml path pattern
-            '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then
+            '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|(.*/)?Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
files=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
--paginate --jq '.[].filename')
if echo "$files" | grep -qE '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
else
echo "relevant=false" >> "$GITHUB_OUTPUT"
fi
files=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
--paginate --jq '.[].filename')
if echo "$files" | grep -qE '^(crates/perry-runtime/src/(eh|eh_walker|eh_windows|exception)\.rs$|crates/perry-codegen/src/stmt/|test-files/test_(gap|issue)_7302_|\.cargo/config\.toml$|(.*/)?Cargo\.toml$|\.github/workflows/eh-transport\.yml$)'; then
echo "relevant=true" >> "$GITHUB_OUTPUT"
else
echo "relevant=false" >> "$GITHUB_OUTPUT"
fi
🤖 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 @.github/workflows/eh-transport.yml around lines 58 - 64, Update the filename
regex in the relevant-file check to match Cargo.toml at the repository root and
within nested crate directories, including crates/perry-runtime/Cargo.toml,
while preserving the existing matches for other scoped paths.


eh-transport:
needs: changes
if: needs.changes.outputs.relevant == 'true'
# arm64 macOS: the owned walker's current platform. Other targets fall
# back to the system unwinder by design, so there is nothing to assert
# there beyond the behavior the parity suites already cover.
runs-on: macos-15
timeout-minutes: 60
steps:
- uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: .node-version

- uses: Swatinem/rust-cache@v2
with:
shared-key: "${{ runner.os }}-perry"
save-if: ${{ github.ref == 'refs/heads/main' }}

- name: Build compiler + runtime archives
run: |
cargo build --profile perry-dev -p perry -p perry-runtime-static \
-p perry-stdlib-static

- name: Owned transport — liveness, agreement, fallback, parity
run: |
set -euo pipefail
export PERRY_RUNTIME_DIR="$PWD/target/perry-dev"
export PERRY_NO_AUTO_OPTIMIZE=1
BIN=target/perry-dev/perry

# Two subjects: the structural try/catch matrix (nesting, finally
# on both edges, return-in-try, rethrow) and the GC probe that
# throws across a collection point.
for SRC in test-files/test_gap_7302_invoke_eh_paths.ts \
test-files/test_gap_7302_gc_throw_across_collection.ts; do
NAME=$(basename "$SRC" .ts)
"$BIN" "$SRC" -o "/tmp/$NAME"

# Oracle parity first: a fast path that is wrong is worse than
# no fast path, so nothing below is meaningful without this.
node --experimental-strip-types --no-warnings "$SRC" > "/tmp/$NAME.node"
"/tmp/$NAME" > "/tmp/$NAME.perry"
diff "/tmp/$NAME.node" "/tmp/$NAME.perry"

# LIVENESS: the walker carried every throw. `fallback=0` alone
# would pass vacuously on a program that never threw, so require
# a nonzero fast count too.
stats=$(PERRY_EH_WALKER=stats "/tmp/$NAME" 2>&1 >/dev/null | grep "eh-walker: fast=")
echo "$NAME: $stats"
fast=$(echo "$stats" | sed -E 's/.*fast=([0-9]+).*/\1/')
fb=$(echo "$stats" | sed -E 's/.*fallback=([0-9]+).*/\1/')
[ "$fast" -gt 0 ] || { echo "walker never took the fast path"; exit 1; }
[ "$fb" -eq 0 ] || { echo "walker fell back $fb time(s)"; exit 1; }

# AGREEMENT: re-prove the prediction against the system
# unwinder on this build, rather than trusting the bring-up run.
# The personality aborts on mismatch; the tally must show real
# verifications and no declines.
diffout=$(PERRY_EH_WALKER=diff "/tmp/$NAME" 2>&1 >/dev/null | grep "eh-walker diff:")
echo "$NAME: $diffout"
ver=$(echo "$diffout" | sed -E 's/.*verified=([0-9]+).*/\1/')
dec=$(echo "$diffout" | sed -E 's/.*declined=([0-9]+).*/\1/')
[ "$ver" -gt 0 ] || { echo "diff mode verified nothing"; exit 1; }
[ "$dec" -eq 0 ] || { echo "walk declined $dec throw(s)"; exit 1; }

# FALLBACK: the escape hatch must still produce correct output —
# it is the bisection knob and the every-other-platform path.
PERRY_EH_WALKER=off "/tmp/$NAME" > "/tmp/$NAME.off"
diff "/tmp/$NAME.node" "/tmp/$NAME.off"
done

- name: Throws on perry/thread workers
run: |
set -euo pipefail
export PERRY_RUNTIME_DIR="$PWD/target/perry-dev"
export PERRY_NO_AUTO_OPTIMIZE=1
# Each worker walks ITS OWN stack while the row cache and image
# index are process-global behind a mutex. node cannot run
# perry/thread, so the oracle here is the system unwinder itself:
# walker-on must equal walker-off, and diff mode must agree on
# every worker throw.
SRC=test-files/test_issue_7302_thread_throws.ts
target/perry-dev/perry "$SRC" -o /tmp/eh_threads
/tmp/eh_threads > /tmp/eh_threads.on
PERRY_EH_WALKER=off /tmp/eh_threads > /tmp/eh_threads.off
diff /tmp/eh_threads.on /tmp/eh_threads.off

stats=$(PERRY_EH_WALKER=stats /tmp/eh_threads 2>&1 >/dev/null | grep "eh-walker: fast=")
echo "threads: $stats"
fast=$(echo "$stats" | sed -E 's/.*fast=([0-9]+).*/\1/')
fb=$(echo "$stats" | sed -E 's/.*fallback=([0-9]+).*/\1/')
[ "$fast" -gt 0 ] || { echo "walker never took the fast path on workers"; exit 1; }
[ "$fb" -eq 0 ] || { echo "walker fell back $fb time(s) on workers"; exit 1; }

diffout=$(PERRY_EH_WALKER=diff /tmp/eh_threads 2>&1 >/dev/null | grep "eh-walker diff:")
echo "threads: $diffout"
dec=$(echo "$diffout" | sed -E 's/.*declined=([0-9]+).*/\1/')
ver=$(echo "$diffout" | sed -E 's/.*verified=([0-9]+).*/\1/')
[ "$ver" -gt 0 ] || { echo "diff mode verified nothing on workers"; exit 1; }
[ "$dec" -eq 0 ] || { echo "walk declined $dec worker throw(s)"; exit 1; }

- name: Cross-helper throws + unwind-table self-check
run: |
set -euo pipefail
export PERRY_RUNTIME_DIR="$PWD/target/perry-dev"
export PERRY_NO_AUTO_OPTIMIZE=1
# Throws crossing runtime Rust frames (throwing getter, throwing
# toString, JSON.parse, map callback, deep recursion). These are
# the frames the panic=abort + force-unwind-tables contract exists
# for; a runtime built without the tables aborts here with the
# self-check's message instead of stranding the throw silently.
SRC=test-files/test_gap_7302_throw_across_helper_frames.ts
target/perry-dev/perry "$SRC" -o /tmp/eh_helpers
node --experimental-strip-types --no-warnings "$SRC" > /tmp/eh_helpers.node
/tmp/eh_helpers > /tmp/eh_helpers.perry
diff /tmp/eh_helpers.node /tmp/eh_helpers.perry
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions changelog.d/7308-owned-unwinder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
### Exception transport: owned single-phase unwinder (#7302 follow-up)

The `invoke`/`landingpad` migration (#7305) traded `longjmp`'s O(1) register
restore for real stack unwinding, which made throws slower — honestly reported
at the time. This closes that gap without giving back any of the correctness.

The system unwinder walks every frame **twice** (search + cleanup) and
re-decodes each frame's CFI on **every throw** (measured: 512 ns per
frame-step on macOS arm64). Perry needs neither property: the handler stack
already *is* the search result, and throw paths repeat, so decoded rows can be
cached. `js_throw` now walks to the handler itself with a per-PC row cache and
installs the handler frame's register context directly.

| microbenchmark (20k iters, macOS arm64) | system unwinder | owned walker | node/V8 |
|---|---|---|---|
| deep unwind, 200 frames | 4096 ms | **287 ms** (14.3×) | 168 ms |
| shallow throw + catch | 451 ms | **80 ms** (5.6×) | 110 ms |

Deep-unwind throws went from 24× slower than V8 to within 1.7×; shallow throws
now beat V8. Non-throwing paths are untouched.

**Safety.** Every register reload dereferences a computed address, so a
misdecoded row is a wild read rather than a wrong answer. Three layers, each
required to prove it ran:
Comment on lines +22 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Correct the safety failure-mode description in both records.

An incorrect CFI row can cause either an invalid read or an in-bounds read from the wrong slot. The second case can silently restore incorrect register values. Describe both failure modes and explain which validation layer detects each one.

  • changelog.d/7308-owned-unwinder.md#L22-L24: replace the claim that a misdecoded row is always a wild read.
  • docs/invoke-eh-experiment.md#L308-L310: use the same corrected failure-mode description.
📍 Affects 2 files
  • changelog.d/7308-owned-unwinder.md#L22-L24 (this comment)
  • docs/invoke-eh-experiment.md#L308-L310
🤖 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 `@changelog.d/7308-owned-unwinder.md` around lines 22 - 24, Update the safety
failure-mode descriptions in changelog.d/7308-owned-unwinder.md lines 22-24 and
docs/invoke-eh-experiment.md lines 308-310 to state that an incorrect CFI row
may cause either an invalid read or an in-bounds read from the wrong slot, which
can silently restore incorrect register values; explain which validation layer
detects each failure mode and keep both records consistent.


- The owned walk reproduces `_Unwind_Backtrace`'s frame chain exactly (unit
differential).
- `PERRY_EH_WALKER=diff` predicts (landing pad, CFA) before each raise and
asserts it inside the personality against the system unwinder, tallying
verified/declined at exit so a silent run cannot pass for a verified one:
**20,000 deep unwinds (~4M frame steps), the GC throw-across-collection
probe, and the smoke corpus — zero mispredictions, zero declines.** The
checker's own liveness was proven by deliberately corrupting a prediction
and confirming the abort fires.
- Stepping is fail-safe: the CFA must climb a plausible stack monotonically
and every slot address must lie inside the walk's stack span, else the walk
declines and the system unwinder carries that throw with identical
semantics. Not theoretical — unguarded, the walker segfaulted stepping
`libtest`'s frame shapes, which compiled programs never produce.

`d8..d15` are tracked and restored alongside the integer callee-saves: a
handler frame holding a live `f64` across the `try` would otherwise resume
with a stale value (silent numeric corruption, not a crash).

**Acceptance.** The full gap suite under the owned transport returns the
byte-identical failure set as merged main (95.4%, same 21 mismatches, same
single known crash). The GC probe passes under default,
`PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1`, and `PERRY_GEN_GC=0`.
`PERRY_EH_WALKER=off` reverts to the system unwinder for bisection (verified
live: the deep-unwind benchmark returns to 4231 ms).

**Platform scope.** aarch64/macOS takes the fast path; every other target
keeps the system unwinder unchanged — the walk simply declines when it has no
image to decode. Linux bring-up needs `dl_iterate_phdr` + `PT_GNU_EH_FRAME`
discovery; the stepping and cache above it are platform-independent.
3 changes: 3 additions & 0 deletions crates/perry-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ dyn-eval = ["dep:perry-parser", "dep:perry-diagnostics"]
thiserror.workspace = true
anyhow.workspace = true
libc.workspace = true
# Owned single-phase unwinder (#7302 follow-up): decodes .eh_frame CFI for
# the per-PC step cache. Read-only, no_std-capable subset.
gimli = { version = "0.34", default-features = false, features = ["read"] }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
rand = "0.10"
regex = { workspace = true, optional = true }
# Taffy — flexbox / grid layout engine for the perry/tui module
Expand Down
15 changes: 14 additions & 1 deletion crates/perry-runtime/src/eh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ extern "C" {
fn _Unwind_GetRegionStart(ctx: *mut UnwindContext) -> usize;
fn _Unwind_SetGR(ctx: *mut UnwindContext, reg_index: c_int, value: usize);
fn _Unwind_SetIP(ctx: *mut UnwindContext, value: usize);
fn _Unwind_GetCFA(ctx: *mut UnwindContext) -> usize;
fn _Unwind_Backtrace(
trace: extern "C" fn(*mut UnwindContext, *mut core::ffi::c_void) -> UnwindReasonCode,
arg: *mut core::ffi::c_void,
Expand Down Expand Up @@ -156,6 +157,13 @@ thread_local! {
};
}

/// Address of this thread's `_Unwind_Exception` object — what a landing
/// pad receives in x0. The owned fast transport passes it explicitly
/// because it installs the context itself (#7302 follow-up).
pub(crate) fn exception_object_addr() -> u64 {
EXC_OBJECT.with(|c| c.get()) as u64
}

/// Raise the per-thread Perry exception. Returns ONLY if the unwinder found
/// no handler (the caller reports the uncaught exception and exits) — with a
/// handler-stack entry present this indicates lost unwind tables between the
Expand Down Expand Up @@ -213,6 +221,11 @@ pub unsafe extern "C" fn perry_eh_personality(
} else {
match lpad {
Some(lpad) => {
// W1 diff mode (#7302 follow-up): the owned walker predicted
// where this throw lands before the raise; the system
// unwinder is the oracle. Any mismatch is a walker bug —
// fail loudly here, where both answers are in hand.
crate::eh_walker::verify_prediction(lpad as u64, _Unwind_GetCFA(context) as u64);
_Unwind_SetGR(context, UNWIND_DATA_REG.0, exception_object as usize);
_Unwind_SetGR(context, UNWIND_DATA_REG.1, 0);
_Unwind_SetIP(context, lpad);
Expand Down Expand Up @@ -247,7 +260,7 @@ unsafe fn find_landing_pad(context: *mut UnwindContext) -> Result<Option<usize>,
/// call-site table sorted by start offset. Perry generates only catch-all
/// handlers, so the action/type tables need no interpretation: any non-zero
/// landing-pad offset is a handler.
unsafe fn find_landing_pad_in_lsda(
pub(crate) unsafe fn find_landing_pad_in_lsda(
lsda: *const u8,
ip: usize,
func_start: usize,
Expand Down
Loading
Loading