Skip to content

fix(streams): a throwing ReadableStream pull() errors the stream, not crash#6476

Open
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/stream-pull-throw-resilience
Open

fix(streams): a throwing ReadableStream pull() errors the stream, not crash#6476
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/stream-pull-throw-resilience

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

A ReadableStream's pull() callback that throws no longer crashes the process — it errors the stream per the Web Streams spec (pending read()s reject with the thrown value), exactly as Node does.

The bug

Perry drives a stream's pull() from a Rust-owned microtask (streams::readable_pull_microtask, invoking js_closure_call1 / pull_deferred_byte_chunk). If the pull callback threw, perry's setjmp/longjmp exception unwind jumped straight out to the microtask loop — past readable_pull_microtask's frame — skipping the pulling = false reset and leaving the stream wedged. Under concurrency this corrupted stream state and killed the process, instead of surfacing a normal rejected read().

Found while running a real Next.js app: its byte-stream content-length pull hits a cold-path throw under concurrent load, which took down the whole server rather than failing the one request.

The fix

  • exception::js_call_catching(f) -> Result<f64, f64> (new pub): a setjmp-based Rust try/catch that runs f (which may call into JS and js_throw) and returns Ok(value) or Err(exception). The setjmp lives in its own frame, so a throw unwinds only up to there — not past the caller. Mirrors the existing combinators::combinator_catch_js.
  • streams::readable_pull_microtask now wraps the pull invocation in js_call_catching; on a throw it calls the existing error_readable_stream(...) (spec's ReadableStreamDefaultControllerError), so reader.read() rejects and the consumer's own handler observes the error.

Tests

New gap test test_gap_readable_stream_pull_throws.ts (default + type:"bytes" pull paths): read() rejects with the thrown message and the process survives — byte-for-byte with node --experimental-strip-types. Verified normal (non-throwing) stream reads/tees are unchanged.

Scope note

This is a general robustness fix (a throwing pull should never crash the server). It is not the complete fix for the Next.js app that surfaced it: that app also hits an underlying concurrency race that makes its pull read a null in the first place (a separate microtask-ordering issue, filed separately). This PR removes the crash-on-throw failure mode.

https://claude.ai/code/session_01KD4GTmiwZjRrxShzQydJpF

Summary by CodeRabbit

  • Bug Fixes

    • Fixed ReadableStream behavior when pull() throws.
    • Stream reads now properly reject with the thrown error instead of crashing or leaving the stream in an inconsistent state.
    • Improved handling for both regular and byte streams, including reliable recovery of stream processing after errors.
  • Tests

    • Added coverage for thrown errors during regular and byte-stream pulls.

… crash

Perry invokes a ReadableStream's `pull()` from a Rust-owned microtask
(`readable_pull_microtask`). If the pull callback threw, the exception
`longjmp`ed straight out to the microtask loop — past this Rust frame —
skipping the `pulling = false` reset and leaving the stream wedged; under
concurrency this corrupted stream state and crashed the process. (Found
driving a real Next.js app whose byte-stream pull hit a cold-path throw.)

Per the Web Streams spec a throwing pull must run
ReadableStreamDefaultControllerError: the stream errors and its pending
reads reject with the thrown value. Add a public
`exception::js_call_catching(f) -> Result<f64, f64>` (setjmp-based Rust
try/catch, mirroring `combinator_catch_js`) and wrap the pull invocation
with it; on a throw, call `error_readable_stream(...)` so `reader.read()`
rejects and the consumer's own handler observes it — exactly as Node does.

Gap test `test_gap_readable_stream_pull_throws.ts` covers the default and
byte-stream pull paths: read() rejects with the thrown message and the
process survives.

Claude-Session: https://claude.ai/code/session_01KD4GTmiwZjRrxShzQydJpF
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 29abb727-ed79-48ef-ab34-32639f25f209

📥 Commits

Reviewing files that changed from the base of the PR and between be2e23f and 56ded68.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/exception.rs
  • crates/perry-stdlib/src/streams.rs
  • test-files/test_gap_readable_stream_pull_throws.ts

📝 Walkthrough

Walkthrough

Adds runtime exception capture for Rust closures and uses it in ReadableStream pull microtasks so thrown errors reject pending reads without unwinding the process. Tests cover default and byte-stream pull callbacks.

Changes

ReadableStream pull error handling

Layer / File(s) Summary
Runtime exception capture
crates/perry-runtime/src/exception.rs
Adds js_call_catching, which returns normal closure results or captured JavaScript exceptions as Result<f64, f64>.
Stream pull integration and validation
crates/perry-stdlib/src/streams.rs, test-files/test_gap_readable_stream_pull_throws.ts
Routes pull exceptions through error_readable_stream, resets the pulling flag, and tests default and byte-stream rejection behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, specific, and matches the main change: a throwing ReadableStream pull now errors the stream instead of crashing.
Description check ✅ Passed The description covers the bug, fix, and tests, but it omits an explicit related issue and uses custom headings instead of the template.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ 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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

The underlying concurrency race that surfaced this (Next's byte-stream pull reading a null before its Flight state is ready) is filed as #6477. This PR removes the crash-on-throw failure mode; #6477 tracks the null itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready PR triaged: CodeRabbit feedback + conflicts addressed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant