Skip to content

fix(stream): complete Node.js 26.5 stream parity - #7067

Open
TheHypnoo wants to merge 5 commits into
mainfrom
fix/node-stream-node-26-parity
Open

fix(stream): complete Node.js 26.5 stream parity#7067
TheHypnoo wants to merge 5 commits into
mainfrom
fix/node-stream-node-26-parity

Conversation

@TheHypnoo

@TheHypnoo TheHypnoo commented Jul 30, 2026

Copy link
Copy Markdown
Member

Summary

Completes node:stream compatibility with Node.js 26.5.0 across classic streams, iterator helpers, pipeline/compose, and Web Streams. The changes address shared runtime causes rather than individual fixtures and bring the complete 801-case parity module to Node-observable behavior.

Changes

  • Align async iteration, iterator helpers, Readable.from(), pipeline, and compose lifecycle/error semantics with Node.js 26.5.0.
  • Complete TransformStream wiring, writer state, backpressure, cancellation, prevent* propagation, locks, and microtask ordering.
  • Preserve Buffer iteration and coded error formatting across fatal and deferred stream failures.
  • Treat ordinary non-zero Node exits as valid oracle results while keeping timeouts and signals as NODE_FAIL.
  • Make cancel-during-pipeto settle its active pipeline deterministically without weakening its locked-stream assertion.

Related issue

Closes #6774

Test plan

  • cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static
  • cargo test -p perry-runtime node_stream --lib -- --test-threads=1 — 83 passed
  • cargo test -p perry-runtime promise --lib -- --test-threads=1 — 71 passed, 1 pre-existing ignored
  • cargo test -p perry-stdlib streams:: --lib -- --test-threads=1 — 17 passed
  • cargo test -p perry-codegen --lib -- --test-threads=1 — 282 passed
  • cargo test -p perry-transform --lib -- --test-threads=1 — 55 passed
  • shellcheck -S warning run_parity_tests.sh
  • cargo test --workspace --exclude perry-ui-ios --exclude perry-ui-tvos --exclude perry-ui-watchos --exclude perry-ui-gtk4 --exclude perry-ui-android --exclude perry-ui-windows — intentionally not run; validation was scoped to affected crates and the task explicitly prohibited workspace-wide tests
  • Added focused Rust regressions and updated the non-terminating parity fixture
  • Documentation update — not applicable; this restores documented Node-compatible behavior without adding API surface
  • Platform UI backend build — not applicable

Screenshots / output

./run_parity_tests.sh --suite node-suite --module stream
Parity Pass:   801
Parity Fail:   0
Compile Fail:  0
Crashed:       0
Skipped:       0
Parity Rate:   100.0%

Review scope

This is intentionally a single compatibility PR: the runtime, codegen, stdlib, and parity-runner changes are coupled by the same Node 26.5 oracle and were validated as one 801-fixture gate. The maintainer requested publication after reviewing the full 2,454-line diff summary.

Checklist

  • I have NOT bumped the workspace version or edited CLAUDE.md / CHANGELOG.md (maintainer handles these at merge)
  • My commits follow the loose feat: / fix: / docs: / chore: prefix convention used in the log
  • I've read CONTRIBUTING.md and agree to the Code of Conduct

Summary by CodeRabbit

  • New Features
    • Improved Web Streams pipeThrough support with options and AbortSignal validation, and more consistent piping behavior.
    • Enhanced readable async-iterator handling (including source iterator transitions) and improved take() behavior with retained sources.
    • Stronger TransformStream backpressure/deferral to better match spec timing.
  • Bug Fixes
    • Fixed/strengthened stream error propagation, including missing method errors and uncaught-exception reporting with richer context.
    • Improved Readable.from() input validation and clearer error descriptions for Promise values.
    • Corrected Promise/microtask diagnostics and iterable/@@asyncIterator resolution behavior.

@TheHypnoo TheHypnoo added the type:bug Bug fix label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR updates Web Streams and Node stream semantics, including pipe validation, cancellation, backpressure, async iteration, pipeline errors, uncaught diagnostics, compiler lowering, generator suspension analysis, and parity-test normalization.

Changes

Stream runtime behavior

Layer / File(s) Summary
Web Stream actions and backpressure
crates/perry-stdlib/src/streams*
Stream callbacks now capture exceptions, await returned promises, validate controller state, defer initially backpressured transforms, and schedule writable writes through microtasks.
PipeThrough validation and PipeState flow
crates/perry-codegen/src/..., crates/perry-stdlib/src/streams/pipe.rs, crates/perry-stdlib/src/streams/subclass.rs
pipeThrough validates stream locks and abort signals before routing through pipeTo; pipe state and abort cleanup are carried through asynchronous callbacks.
Readable sources and iterator cleanup
crates/perry-runtime/src/node_stream*
Readable source iterators support promise rejection and cleanup, take wraps retained iterators, buffering recognizes source iterators, and missing stream methods throw explicit errors.
Pipeline composition and uncaught errors
crates/perry-runtime/src/node_stream_pipeline.rs, crates/perry-runtime/src/node_stream_constructors/*, crates/perry-runtime/src/{exception.rs,node_submodules/*,os/*,promise/*,symbol/*}
Pipeline collection and composition install error guards and defer priming failures; uncaught exceptions now reach process events or terminate after printing.
Generator and async iterator updates
crates/perry-transform/src/generator/per_iteration.rs, crates/perry-runtime/src/array/*, crates/perry-runtime/src/promise/async_step.rs
Loop analysis recognizes await as a suspension point, and async iterator lookup is routed through the shared helper.

Compiler and parity validation

Layer / File(s) Summary
Parity failure normalization
run_parity_tests.sh, test-parity/node-suite/stream/web/cancel-during-pipeto.ts
Parity runs classify abnormal Node exits separately, normalize failure signatures, compare exit codes, and await the pipeTo scenario explicitly.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • PerryTS/perry#6476: Both changes add exception handling around ReadableStream pull callbacks.
  • PerryTS/perry#6608: Both changes update stream handle eviction and cleanup for backpressure-related state.

Suggested labels: parity

Suggested reviewers: proggeramlug

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ReadableStream
  participant PipeThroughValidation
  participant PipeTo
  participant TransformStream
  Caller->>ReadableStream: pipeThrough(transform, options)
  ReadableStream->>PipeThroughValidation: validate locks, handles, and signal
  PipeThroughValidation->>PipeTo: start piping to transform writable
  PipeTo->>TransformStream: write and schedule transform work
  TransformStream-->>Caller: return transform readable
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main stream-parity change.
Description check ✅ Passed The description matches the template and includes summary, changes, related issue, test plan, and checklist.
Linked Issues check ✅ Passed The changes address the listed node:stream parity failures in async iteration, pipeline/compose, Web Streams, and cancel-during-pipeto.
Out of Scope Changes check ✅ Passed No clear unrelated changes stand out; the additional runtime, codegen, and parity-runner edits support the stream parity objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/node-stream-node-26-parity

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🧹 Nitpick comments (3)
crates/perry-runtime/src/node_stream_readwrite.rs (1)

1499-1510: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename call_symbol_async_iterator_for_flat_map for the generic Readable.from path.

The helper name says flatMap, but normalize_readable_from_input also uses it directly; Readable.from(asyncIterable) will short-circuit before flatten_async_iterable_with_source, so a clearer generic async-iterator probe name avoids implying flatMap-specific behavior.

🤖 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 `@crates/perry-runtime/src/node_stream_readwrite.rs` around lines 1499 - 1510,
Rename call_symbol_async_iterator_for_flat_map to a generic async-iterator probe
name, then update all references, including normalize_readable_from_input and
any flatMap callers. Preserve the existing detection and short-circuit behavior
for Readable.from(asyncIterable), while reserving
flatten_async_iterable_with_source for inputs that require flattening.
crates/perry-transform/src/generator/per_iteration.rs (1)

1073-1096: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good regression test; consider mirroring it for the for-loop path.

loop_local_used_after_await_stays_hoisted correctly locks in the While case. The same bug class (loop-carried binding read after await) can also occur through analyze_loop's Stmt::For branch (Lines 141-157) and loop_contains_suspend's For arm (Lines 236-248), neither of which has a dedicated test.

🤖 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 `@crates/perry-transform/src/generator/per_iteration.rs` around lines 1073 -
1096, Mirror loop_local_used_after_await_stays_hoisted with a dedicated test
using Stmt::For, declaring a loop-local binding, suspending with Await, and
reading it afterward. Ensure the test exercises analyze_loop’s Stmt::For path
and loop_contains_suspend’s For arm, and asserts the binding remains hoisted by
not appearing in collect_per_iteration_ids.
crates/perry-stdlib/src/streams/transform.rs (1)

553-575: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider a readable→transform reverse index instead of scanning TRANSFORM_STREAMS.

transform_initial_backpressure / set_transform_initial_backpressure linear-scan all transform streams and are hit on every transform_write and every transform_release_writes (i.e. every pipe step). A HashMap<readable_id, transform_id> (mirroring TRANSFORM_PAIRS) would make these O(1) and keep eviction bookkeeping in idalloc::evict_ids explicit.

🤖 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 `@crates/perry-stdlib/src/streams/transform.rs` around lines 553 - 575, Replace
the linear scans in transform_initial_backpressure and
set_transform_initial_backpressure with a readable_id-to-transform_id reverse
index, mirroring TRANSFORM_PAIRS. Maintain this index whenever transform streams
are created or removed, and use it to access the corresponding TRANSFORM_STREAMS
entry in O(1) while keeping eviction cleanup explicit in idalloc::evict_ids.
🤖 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 `@crates/perry-codegen/src/lower_call/options/fetch.rs`:
- Around line 969-988: Update the codegen path in the pipeThrough lowering to
retain the promise returned by js_readable_stream_pipe_to, then emit
js_promise_mark_internally_handled for that promise before returning the
readable stream. Preserve the existing validation call and return behavior, or
reuse js_readable_stream_pipe_through if appropriate.

In `@crates/perry-runtime/src/node_stream_constructors/web_adapter.rs`:
- Around line 151-152: Update build_web_read_result to construct the enumerable
object with the value property before done, preserving observable Object.keys()
and JSON serialization order while leaving the existing values unchanged.

In `@crates/perry-runtime/src/node_stream_iter_helpers.rs`:
- Around line 1021-1043: Update ns_take_source_next to check remaining <= 0
before invoking the source iterator’s next operation, including take(0), and
immediately finish/close the source and return the done result. Preserve the
existing promise-resolution handling only for pulls already initiated before the
limit was reached, without performing or discarding an extra source pull.
- Around line 1076-1090: Update ns_iter_take’s retained-source early-return path
to call propagate_stream_state(this, TAG_UNDEFINED, result) after creating the
empty result stream and before returning it, preserving the source hidden error
and abort state. Simplify the source-iterator guard to use .is_some() since the
binding is unused.

In `@crates/perry-runtime/src/node_stream_pipeline.rs`:
- Around line 403-411: Root all retained JS values with RuntimeHandleScope
across allocations and JS-invoking operations, then reload them before
subsequent use. In crates/perry-runtime/src/node_stream_pipeline.rs at lines
403-411, 480-484, 538-550, 1016-1031, and 1098-1122, root the indicated value,
iterator, stage, composite, and source values and preserve reloading after
collection, stream construction, listener allocation or installation, and
priming. In crates/perry-runtime/src/node_stream_tests_extra.rs at lines
923-931, 1044-1061, 1101-1102, 1130-1131, and 1161-1162, root opts, streams,
errors, and derived handles across closure/listener allocation and derive
handles only after allocation as needed; do not rely on Rust locals
conservatively retaining NaN-boxed values or raw handles.

In `@crates/perry-runtime/src/node_stream/async_iterator.rs`:
- Around line 522-557: The source-iterator pull paths reuse unrooted NaN-boxed
locals after invoking user code, allowing GC relocation to invalidate them. In
crates/perry-runtime/src/node_stream/async_iterator.rs lines 522-557, root
iterator and stream with crate::gc::RuntimeHandleScope and reload them before
call_source_iterator_return, destroy_stream, and js_closure_set_capture_f64; in
crates/perry-runtime/src/node_stream_iter_helpers.rs lines 994-1053, root
iterator, source, and source_iterator similarly and reload them before
finish_take_source, take_source_fulfilled, and closure captures.
- Around line 325-327: Update ns_readable_iter_on_data so it resumes the
readable stream after delivering a chunk whenever iterator waiters remain,
rather than always pausing it. Preserve the existing pause behavior when no
pending waiters remain, and align this with ns_readable_iterator_next’s
pending-pull handling.

In `@crates/perry-runtime/src/promise/microtasks.rs`:
- Around line 237-241: Update schedule_uncaught and
diagnostics_channel_drain_uncaught so pending uncaught exceptions remain rooted
across the deferred diagnostics delivery, replacing the plain Vec<f64> storage
with the runtime’s rooted or reloadable value mechanism. Ensure the drained
value is reloaded into a valid JS handle before passing it to
emit_process_event("uncaughtException", …), while preserving the existing
scheduling and drain behavior.

---

Nitpick comments:
In `@crates/perry-runtime/src/node_stream_readwrite.rs`:
- Around line 1499-1510: Rename call_symbol_async_iterator_for_flat_map to a
generic async-iterator probe name, then update all references, including
normalize_readable_from_input and any flatMap callers. Preserve the existing
detection and short-circuit behavior for Readable.from(asyncIterable), while
reserving flatten_async_iterable_with_source for inputs that require flattening.

In `@crates/perry-stdlib/src/streams/transform.rs`:
- Around line 553-575: Replace the linear scans in
transform_initial_backpressure and set_transform_initial_backpressure with a
readable_id-to-transform_id reverse index, mirroring TRANSFORM_PAIRS. Maintain
this index whenever transform streams are created or removed, and use it to
access the corresponding TRANSFORM_STREAMS entry in O(1) while keeping eviction
cleanup explicit in idalloc::evict_ids.

In `@crates/perry-transform/src/generator/per_iteration.rs`:
- Around line 1073-1096: Mirror loop_local_used_after_await_stays_hoisted with a
dedicated test using Stmt::For, declaring a loop-local binding, suspending with
Await, and reading it afterward. Ensure the test exercises analyze_loop’s
Stmt::For path and loop_contains_suspend’s For arm, and asserts the binding
remains hoisted by not appearing in collect_per_iteration_ids.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fffce239-99fc-481b-b24b-ce0e472f6970

📥 Commits

Reviewing files that changed from the base of the PR and between c0d9862 and 1589026.

📒 Files selected for processing (32)
  • crates/perry-codegen/src/ext_registry.rs
  • crates/perry-codegen/src/lower_call/builtin.rs
  • crates/perry-codegen/src/lower_call/options/fetch.rs
  • crates/perry-codegen/src/runtime_decls/strings_part2.rs
  • crates/perry-runtime/src/exception.rs
  • crates/perry-runtime/src/fs/validate.rs
  • crates/perry-runtime/src/node_stream.rs
  • crates/perry-runtime/src/node_stream/async_iterator.rs
  • crates/perry-runtime/src/node_stream_constructors/builders.rs
  • crates/perry-runtime/src/node_stream_constructors/pipeline.rs
  • crates/perry-runtime/src/node_stream_constructors/web_adapter.rs
  • crates/perry-runtime/src/node_stream_destroy_state.rs
  • crates/perry-runtime/src/node_stream_dispatch.rs
  • crates/perry-runtime/src/node_stream_iter_helpers.rs
  • crates/perry-runtime/src/node_stream_pipeline.rs
  • crates/perry-runtime/src/node_stream_readwrite.rs
  • crates/perry-runtime/src/node_stream_tests.rs
  • crates/perry-runtime/src/node_stream_tests_extra.rs
  • crates/perry-runtime/src/node_submodules/diagnostics.rs
  • crates/perry-runtime/src/os/os_process_emitter.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • crates/perry-runtime/src/symbol/iterator.rs
  • crates/perry-stdlib/src/streams.rs
  • crates/perry-stdlib/src/streams/idalloc.rs
  • crates/perry-stdlib/src/streams/pipe.rs
  • crates/perry-stdlib/src/streams/subclass.rs
  • crates/perry-stdlib/src/streams/tests.rs
  • crates/perry-stdlib/src/streams/transform.rs
  • crates/perry-stdlib/src/streams/writable.rs
  • crates/perry-transform/src/generator/per_iteration.rs
  • run_parity_tests.sh
  • test-parity/node-suite/stream/web/cancel-during-pipeto.ts

Comment thread crates/perry-codegen/src/lower_call/options/fetch.rs
Comment on lines 151 to +152
fn build_web_read_result(value: f64, done: bool) -> f64 {
build_enumerable_object(&[(b"value", value), (b"done", bool_value(done))])
build_enumerable_object(&[(b"done", bool_value(done)), (b"value", value)])

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 | 🟡 Minor | ⚡ Quick win

Restore value before done.

This makes observable key order ["done", "value"] for Object.keys() and JSON serialization. Web Streams read() results are created with value before done; keep the original field order. (streams.spec.whatwg.org)

Proposed fix
-    build_enumerable_object(&[(b"done", bool_value(done)), (b"value", value)])
+    build_enumerable_object(&[(b"value", value), (b"done", bool_value(done))])
📝 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
fn build_web_read_result(value: f64, done: bool) -> f64 {
build_enumerable_object(&[(b"value", value), (b"done", bool_value(done))])
build_enumerable_object(&[(b"done", bool_value(done)), (b"value", value)])
fn build_web_read_result(value: f64, done: bool) -> f64 {
build_enumerable_object(&[(b"value", value), (b"done", bool_value(done))])
🤖 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 `@crates/perry-runtime/src/node_stream_constructors/web_adapter.rs` around
lines 151 - 152, Update build_web_read_result to construct the enumerable object
with the value property before done, preserving observable Object.keys() and
JSON serialization order while leaving the existing values unchanged.

Comment on lines +1021 to +1043
if remaining <= 0.0 {
finish_take_source(iterator, None, true);
return take_source_done_result();
}
finish_take_source(iterator, Some(reason), true);
return rejected_promise(reason);
}
};
if remaining <= 0.0 {
if crate::promise::js_value_is_promise(next) == 0 {
let close_source = matches!(pipeline_iterator_result(next), Some((false, _)));
finish_take_source(iterator, None, close_source);
return take_source_done_result();
}
let promise = crate::value::js_nanbox_get_pointer(next) as *mut crate::promise::Promise;
let fulfilled = js_closure_alloc(ns_take_limit_fulfilled as *const u8, 1);
let rejected = js_closure_alloc(ns_take_limit_rejected as *const u8, 1);
js_closure_set_capture_f64(fulfilled, 0, iterator);
js_closure_set_capture_f64(rejected, 0, iterator);
return box_pointer(
crate::promise::js_promise_then(promise, fulfilled, rejected) as *const u8,
);
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C4 'fn ns_iter_take|TAKE_SOURCE_REMAINING_KEY' crates/perry-runtime/src/node_stream_iter_helpers.rs
rg -rn --iglob '*take*' -l test-parity/node-suite/stream 2>/dev/null || fd -i take test-parity/node-suite/stream

Repository: PerryTS/perry

Length of output: 2507


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- node_stream_iter_helpers.rs relevant section ---\n'
sed -n '900,1105p' crates/perry-runtime/src/node_stream_iter_helpers.rs | cat -n -v

printf '\n--- take tests ---\n'
for f in test-parity/node-suite/stream/iter-helpers/take.ts \
         test-parity/node-suite/stream/iter-helpers/take-zero.ts \
         test-parity/node-suite/stream/iter-helpers/take-then-filter.ts \
         test-parity/node-suite/stream/iter-helpers/take-then-foreach.ts \
         test-parity/node-suite/stream/readable/from-infinite-take.ts; do
  if [ -f "$f" ]; then
    echo "### $f"
    sed -n '1,220p' "$f"
  fi
done

printf '\n--- source pull count assertions ---\n'
rg -n "count|reads|next\\(|return\\(" test-parity/node-suite/stream/iter-helpers/take.ts test-parity/node-suite/stream/iter-helpers/take-zero.ts test-parity/node-suite/stream/iter-helpers/take-then-filter.ts test-parity/node-suite/stream/iter-helpers/take-then-foreach.ts test-parity/node-suite/stream/readable/from-infinite-take.ts

Repository: PerryTS/perry

Length of output: 242


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- node_stream_iter_helpers.rs relevant section ---'
sed -n '900,1105p' crates/perry-runtime/src/node_stream_iter_helpers.rs | cat -n -v

echo
echo '--- take tests ---'
for f in test-parity/node-suite/stream/iter-helpers/take.ts \
         test-parity/node-suite/stream/iter-helpers/take-zero.ts \
         test-parity/node-suite/stream/iter-helpers/take-then-filter.ts \
         test-parity/node-suite/stream/iter-helpers/take-then-foreach.ts \
         test-parity/node-suite/stream/readable/from-infinite-take.ts; do
  if [ -f "$f" ]; then
    echo "### $f"
    sed -n '1,220p' "$f"
  fi
done

echo
echo '--- source pull count assertions ---'
rg -n "count|reads|next\\(|return\\(" test-parity/node-suite/stream/iter-helpers/take.ts \
  test-parity/node-suite/stream/iter-helpers/take-zero.ts \
  test-parity/node-suite/stream/iter-helpers/take-then-filter.ts \
  test-parity/node-suite/stream/iter-helpers/take-then-foreach.ts \
  test-parity/node-suite/stream/readable/from-infinite-take.ts || true

Repository: PerryTS/perry

Length of output: 11525


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- search for source-read-count assertions in repo ---'
rg -n "count ==|next\\(\\)|extra|read|source|take\\(0\\)|infinite" test-parity crates -g '*.ts' -g '*.rs' || true

echo
echo '--- read relevant StreamNodeIterable source wrappers ---'
rg -n "READABLE_SOURCE_ITERATOR_KEY|readable_source_iterator|async_iterator::call_source_iterator_return|js_native_call_method\\(" crates/perry-runtime/src node_stream_iter_helpers.rs || true
sed -n '1,180p' crates/perry-runtime/src/async_iterator.rs | catsed 1 180 || sed -n '1,180p' crates/perry-runtime/src/async_iterator.rs

Repository: PerryTS/perry

Length of output: 50370


🌐 Web query:

Node.js Readable.prototype.take implementation iterator next return behavior take(0)

💡 Result:

In Node.js, the Readable.prototype.take(limit) method is an iterator helper that consumes a specified number of items from a readable stream and then closes the underlying stream [1][2]. When take(0) is called, the method immediately closes the stream without consuming any items from it. Because the take operator is designed to terminate the stream once the specified limit is reached—or immediately if the limit is 0—the internal iterator's next() method, when called subsequently, will return { done: true } [2]. The return behavior of the underlying iterator in this scenario involves triggering the stream's destruction or closure, ensuring that resources are cleaned up once the "take" operation is complete [2]. Technical notes regarding take: - Implementation: The take method is part of Node.js's alignment with the ECMAScript iterator helpers proposal [1][2]. - Stream Closure: A significant aspect of this implementation is that take destroys the source stream by default upon completion [2]. This behavior has been a point of discussion in the Node.js community, with some users seeking options to keep the stream open (e.g., for further consumption after a "take" operation), though standard take semantics mandate closure [3][2]. - Error Handling: Users have occasionally encountered AbortError issues when using take in certain patterns, which often stems from this forced stream closure after the requested number of items has been processed [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- inspect async_iterator source return ---'
rg -n "call_source_iterator_return|fn call_source_iterator_return|source_iterator_return|READABLE_SOURCE_ITERATOR_KEY|READABLE_SOURCE_ITERATOR" crates/perry-runtime/src

echo
echo '--- read async_iterator.rs around return implementations ---'
file=$(rg -l "call_source_iterator_return\(" crates/perry-runtime/src | head -n1)
if [ -n "$file" ]; then
  line=$(rg -n "fn call_source_iterator_return|call_source_iterator_return" "$file" | head -n1 | cut -d: -f1)
  start=$((line-30)); [ "$start" -lt 1 ] && start=1
  end=$((line+120))
  sed -n "${start},${end}p" "$file" | cat -n
fi

echo
echo '--- read node_stream_iter_helpers around creation and stream state ---'
sed -n '800,900p' crates/perry-runtime/src/node_stream_iter_helpers.rs | cat -n -v
sed -n '1320,1470p' crates/perry-runtime/src/node_stream_iter_helpers.rs | cat -n -v || true

Repository: PerryTS/perry

Length of output: 15191


Don’t pull the source after reaching the take limit.

ns_take_source_next still calls the source .next() when remaining <= 0, then just discards the response. That can observe one extra pull for shared/side-effecting streams and can also run once for take(0); Node’s iterator helper closes the source after the requested chunks without consuming an extra value.

🤖 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 `@crates/perry-runtime/src/node_stream_iter_helpers.rs` around lines 1021 -
1043, Update ns_take_source_next to check remaining <= 0 before invoking the
source iterator’s next operation, including take(0), and immediately
finish/close the source and return the done result. Preserve the existing
promise-resolution handling only for pulls already initiated before the limit
was reached, without performing or discarding an extra source pull.

Comment thread crates/perry-runtime/src/node_stream_iter_helpers.rs
Comment thread crates/perry-runtime/src/node_stream_pipeline.rs Outdated
Comment thread crates/perry-runtime/src/node_stream/async_iterator.rs
Comment thread crates/perry-runtime/src/node_stream/async_iterator.rs Outdated
Comment on lines +237 to 241
} else {
crate::node_submodules::diagnostics::schedule_uncaught(exc);
ran += 1;
}
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep run --pattern 'fn schedule_uncaught($$$) { $$$ }' --lang rust crates/perry-runtime/src
rg -nP -C6 'fn diagnostics_channel_drain_uncaught' crates/perry-runtime/src

Repository: PerryTS/perry

Length of output: 1361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== microtasks around the call =="
sed -n '210,255p' crates/perry-runtime/src/promise/microtasks.rs

echo "== diagnostics queue declaration and emit_process_event =="
sed -n '730,790p' crates/perry-runtime/src/node_submodules/diagnostics.rs
rg -n 'fn emit_process_event|fn run_microtasks|pub fn run.*microtasks|DIAG_PENDING_UNCAUGHT' crates/perry-runtime/src -S

echo "== GC/rooting references for NaN-boxed values =="
rg -n 'NaN-box|NaN box|nan_box|unboxed|F64|rooted|root\\(|handle|diagnostics_channel_drain_uncaught|emit_process_event' crates/perry-runtime/src crates/perry -S | head -n 200

Repository: PerryTS/perry

Length of output: 5338


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== microtasks loop continuation =="
sed -n '255,360p' crates/perry-runtime/src/promise/microtasks.rs

echo "== emit_process_event implementation =="
sed -n '300,380p' crates/perry-runtime/src/os/os_process_emitter.rs

echo "== run_microtasks callers and diagnostics drain integration =="
rg -n 'run_microtasks|diagnostics_channel_drain_uncaught|try_catch|catch_js|emit_process_event\\(' crates/perry-runtime/src -S

echo "== value handling around f64 exception args and rooting APIs =="
rg -n 'js_value|new_unary|f64|root\\(|Rooted|Handle|JSValue|nan_box|boxed_ptr|unboxed|Boxed|HandleScope|FunctionCallback|Arguments' crates/perry-runtime/src -S | head -n 250

echo "== diagnostic pending declaration =="
sed -n '260,295p' crates/perry-runtime/src/node_submodules/diagnostics.rs

Repository: PerryTS/perry

Length of output: 9422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== microtasks continuation and exception handling boundary =="
sed -n '300,520p' crates/perry-runtime/src/promise/microtasks.rs
sed -n '520,620p' crates/perry-runtime/src/promise/microtasks.rs

echo "== emit_process_event implementation =="
sed -n '300,370p' crates/perry-runtime/src/os/os_process_emitter.rs
sed -n '370,440p' crates/perry-runtime/src/os/os_process_emitter.rs

echo "== diagnostics pending declaration =="
sed -n '260,295p' crates/perry-runtime/src/node_submodules/diagnostics.rs
sed -n '828,845p' crates/perry-runtime/src/node_submodules/diagnostics.rs

echo "== run_microtasks/drain integration =="
rg -n 'run_microtasks|diagnostics_channel_drain_uncaught|try_catch|catch_js|emit_process_event\(' crates/perry-runtime/src -S

echo "== rooting and value APIs =="
rg -n 'RuntimeHandleScope|root_raw_mut_ptr|js_value|new_unary|JSValue::|unboxed|nan_box|boxed_ptr|HandleScope|Handle|NaN-box|NaN box' crates/perry-runtime/src crates/perry -S | head -n 300

Repository: PerryTS/perry

Length of output: 50369


Root the uncaught exception before storing it for diagnostics drain

schedule_uncaught(exc) stores the exception only as f64 in DIAG_PENDING_UNCAUGHT, then diagnostics_channel_drain_uncaught() runs later and passes it to emit_process_event("uncaughtException", &[err]). This is exactly the “local Rust f64 across allocating JS” case, so the error object may move or be collected before delivery. Root/reload the value in schedule_uncaught()/diagnostics_channel_drain_uncaught() before passing it to the event emitter, instead of keeping the moved error in a plain Vec<f64>.

🤖 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 `@crates/perry-runtime/src/promise/microtasks.rs` around lines 237 - 241,
Update schedule_uncaught and diagnostics_channel_drain_uncaught so pending
uncaught exceptions remain rooted across the deferred diagnostics delivery,
replacing the plain Vec<f64> storage with the runtime’s rooted or reloadable
value mechanism. Ensure the drained value is reloaded into a valid JS handle
before passing it to emit_process_event("uncaughtException", …), while
preserving the existing scheduling and drain behavior.

Source: Learnings

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-runtime/src/node_stream_pipeline.rs (1)

558-574: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Confirm the no-op error guards can't mask errors past collection.

These listeners are not removed until the streams are destroyed, so later error events on those stages can be dropped silently. Since collection completes before failures, propagate or remove these guards at pipeline completion/removal.

🤖 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 `@crates/perry-runtime/src/node_stream_pipeline.rs` around lines 558 - 574, The
no-op listeners installed by install_collected_pipeline_error_guards must not
suppress errors after pipeline collection completes. Update the pipeline
completion or removal path to propagate later stage errors or remove these
guards when collection finishes, ensuring the existing guard behavior remains
limited to the collection period.
crates/perry-runtime/src/node_stream_readwrite.rs (1)

1499-1504: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Verify read() and readableLength drain the lazy source.

normalize_readable_from_input now returns empty chunks plus source_iterator for Symbol.asyncIterator, and collect_pipeline_chunks / node_stream_iterator_next drain it, but js_node_stream_readable_length and the read() path still use hidden_buffered_key() / visible chunks directly, so those can observe 0 / null for async-iterator sources. Apply collect_pipeline_chunks before exposing buffered chunks/length, or handle READABLE_SOURCE_ITERATOR_KEY in those paths.

🤖 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 `@crates/perry-runtime/src/node_stream_readwrite.rs` around lines 1499 - 1504,
Update the read() implementation and js_node_stream_readable_length to drain
READABLE_SOURCE_ITERATOR_KEY via collect_pipeline_chunks before inspecting
hidden_buffered_key() or visible chunks, ensuring async-iterator-backed streams
expose available data instead of empty/null results.
🧹 Nitpick comments (2)
crates/perry-runtime/src/array/iterator.rs (1)

991-994: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the async iterator helper.

call_symbol_async_iterator_impl is only called by call_symbol_async_iterator, so the private helper can be inlined into the public API.

🤖 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 `@crates/perry-runtime/src/array/iterator.rs` around lines 991 - 994, Inline
the logic of call_symbol_async_iterator_impl into call_symbol_async_iterator and
remove the now-unused private helper. Preserve the existing Symbol.asyncIterator
resolution and invocation behavior.
crates/perry-runtime/src/node_stream/async_iterator.rs (1)

881-883: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the existing readable_from_chunks helper.

Line 798 in this same module already uses readable_from_chunks, which is exactly js_node_stream_readable_from(box_pointer(chunks as *const u8)).

♻️ Proposed tidy-up
-        let chunks = crate::array::js_array_alloc(0);
-        let stream = js_node_stream_readable_from(box_pointer(chunks as *const u8));
+        let stream = readable_from_chunks(crate::array::js_array_alloc(0));
         let iterator = build_readable_async_iterator(stream, true);
🤖 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 `@crates/perry-runtime/src/node_stream/async_iterator.rs` around lines 881 -
883, Update the setup around build_readable_async_iterator to reuse the existing
readable_from_chunks helper instead of manually allocating chunks and calling
js_node_stream_readable_from; preserve the current empty-stream behavior and
iterator construction.
🤖 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 `@crates/perry-runtime/src/node_stream_pipeline.rs`:
- Around line 1237-1268: Ensure __perryStreamComposePriming is cleared when
prime_composed_duplex_from_source unwinds through an exception, not only on
normal return. Add a scoped drop guard around the priming call that resets the
flag, using a rooted handle if the composite may move; preserve the existing
pending-error handling and normal-path reset without leaving the flag set.

In `@crates/perry-runtime/src/node_stream_tests_extra.rs`:
- Around line 923-932: Complete RuntimeHandleScope rooting in
crates/perry-runtime/src/node_stream_tests_extra.rs:923-932 by rooting closure
and stream and refreshing them before raw-handle calls; at 1044-1059 root the
error listener and event string before deriving handle; at 1108-1152 apply the
same ordering to stream and ended; and at 1161-1194 root both listeners and
reload each stream before deriving its handle. Do not retain stream or obj raw
addresses across allocations.

---

Outside diff comments:
In `@crates/perry-runtime/src/node_stream_pipeline.rs`:
- Around line 558-574: The no-op listeners installed by
install_collected_pipeline_error_guards must not suppress errors after pipeline
collection completes. Update the pipeline completion or removal path to
propagate later stage errors or remove these guards when collection finishes,
ensuring the existing guard behavior remains limited to the collection period.

In `@crates/perry-runtime/src/node_stream_readwrite.rs`:
- Around line 1499-1504: Update the read() implementation and
js_node_stream_readable_length to drain READABLE_SOURCE_ITERATOR_KEY via
collect_pipeline_chunks before inspecting hidden_buffered_key() or visible
chunks, ensuring async-iterator-backed streams expose available data instead of
empty/null results.

---

Nitpick comments:
In `@crates/perry-runtime/src/array/iterator.rs`:
- Around line 991-994: Inline the logic of call_symbol_async_iterator_impl into
call_symbol_async_iterator and remove the now-unused private helper. Preserve
the existing Symbol.asyncIterator resolution and invocation behavior.

In `@crates/perry-runtime/src/node_stream/async_iterator.rs`:
- Around line 881-883: Update the setup around build_readable_async_iterator to
reuse the existing readable_from_chunks helper instead of manually allocating
chunks and calling js_node_stream_readable_from; preserve the current
empty-stream behavior and iterator construction.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f6bdac5f-6513-4236-9060-a32dde36fdcc

📥 Commits

Reviewing files that changed from the base of the PR and between 1589026 and f038e57.

📒 Files selected for processing (13)
  • crates/perry-codegen/src/lower_call/options/fetch.rs
  • crates/perry-codegen/src/runtime_decls/strings_part2.rs
  • crates/perry-runtime/src/array/iterator.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs
  • crates/perry-runtime/src/node_stream/async_iterator.rs
  • crates/perry-runtime/src/node_stream_iter_helpers.rs
  • crates/perry-runtime/src/node_stream_pipeline.rs
  • crates/perry-runtime/src/node_stream_readwrite.rs
  • crates/perry-runtime/src/node_stream_tests_extra.rs
  • crates/perry-runtime/src/node_submodules/mod.rs
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-transform/src/generator/per_iteration.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-transform/src/generator/per_iteration.rs

Comment on lines +1237 to 1268
set_hidden_value(
composite.get_nanbox_f64(),
hidden_key(b"__perryStreamComposePriming"),
f64::from_bits(TAG_TRUE),
);
prime_composed_duplex_from_source(
composite.get_nanbox_f64(),
source.get_nanbox_f64(),
stages_value.get_nanbox_f64(),
);
set_hidden_value(
composite.get_nanbox_f64(),
hidden_key(b"__perryStreamComposePriming"),
f64::from_bits(TAG_FALSE),
);
if let Some(err) = get_hidden_value(
composite.get_nanbox_f64(),
hidden_key(b"__perryStreamComposePendingError"),
) {
set_hidden_value(
composite.get_nanbox_f64(),
hidden_key(b"__perryStreamComposePendingError"),
f64::from_bits(TAG_UNDEFINED),
);
fail_composed_duplex(
composite.get_nanbox_f64(),
source.get_nanbox_f64(),
stages_value.get_nanbox_f64(),
err,
);
}
}

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

__perryStreamComposePriming is leaked if priming unwinds.

prime_composed_duplex_from_source calls prepare_readable_for_iteration, compose_stage_values, and schedule_readable_end outside any catch_pipeline_throw. If any of those throws, the reset at Line 1247 never runs and the composite stays flagged as priming forever — from then on every fail_composed_duplex parks the error in __perryStreamComposePendingError and nothing ever drains it, so the composed duplex silently swallows all future errors instead of destroying.

Clear the flag from a drop guard (or reset it at the top of the pending-error read) so the invariant holds on the abrupt path too.

🛡️ Sketch: reset via a guard
+            // Reset on both the normal and unwinding paths so a throw during
+            // priming cannot leave the composite permanently "priming".
+            struct PrimingGuard(f64);
+            impl Drop for PrimingGuard {
+                fn drop(&mut self) {
+                    set_hidden_value(
+                        self.0,
+                        hidden_key(b"__perryStreamComposePriming"),
+                        f64::from_bits(TAG_FALSE),
+                    );
+                }
+            }
             set_hidden_value(
                 composite.get_nanbox_f64(),
                 hidden_key(b"__perryStreamComposePriming"),
                 f64::from_bits(TAG_TRUE),
             );
+            let _priming_guard = PrimingGuard(composite.get_nanbox_f64());
             prime_composed_duplex_from_source(
                 composite.get_nanbox_f64(),
                 source.get_nanbox_f64(),
                 stages_value.get_nanbox_f64(),
             );
-            set_hidden_value(
-                composite.get_nanbox_f64(),
-                hidden_key(b"__perryStreamComposePriming"),
-                f64::from_bits(TAG_FALSE),
-            );
+            drop(_priming_guard);

Note the guard holds a raw NaN-boxed f64; if the composite can move during priming, capture it as a rooted handle instead and reload in drop.

🤖 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 `@crates/perry-runtime/src/node_stream_pipeline.rs` around lines 1237 - 1268,
Ensure __perryStreamComposePriming is cleared when
prime_composed_duplex_from_source unwinds through an exception, not only on
normal return. Add a scoped drop guard around the priming call that resets the
flag, using a rooted handle if the composite may move; preserve the existing
pending-error handling and normal-path reset without leaving the flag set.

Comment on lines +923 to +932
let scope = crate::gc::RuntimeHandleScope::new();
let opts = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 1));
let closure = js_closure_alloc(write_capture as *const u8, 0);
crate::closure::js_register_closure_arity(write_capture as *const u8, 3);
js_object_set_field_by_name(
opts.get_raw_mut_ptr(),
hidden_key(b"write"),
f64::from_bits(JSValue::pointer(closure as *const u8).bits()),
);
let stream = js_node_stream_writable_new(box_pointer(opts.get_raw_const_ptr()));

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

Complete RuntimeHandleScope use across these stream tests. Allocated strings can relocate managed values; closures, streams, event values, and derived raw handles must stay rooted and be refreshed before raw-handle calls.

  • crates/perry-runtime/src/node_stream_tests_extra.rs#L923-L932: root closure and stream; do not retain stream/obj raw addresses across later allocations.
  • crates/perry-runtime/src/node_stream_tests_extra.rs#L1044-L1059: root the error listener and event string; derive handle after them.
  • crates/perry-runtime/src/node_stream_tests_extra.rs#L1108-L1152: apply the same ordering to both stream and ended.
  • crates/perry-runtime/src/node_stream_tests_extra.rs#L1161-L1194: root both listeners and reload each stream before deriving its handle.
📍 Affects 1 file
  • crates/perry-runtime/src/node_stream_tests_extra.rs#L923-L932 (this comment)
  • crates/perry-runtime/src/node_stream_tests_extra.rs#L1044-L1059
  • crates/perry-runtime/src/node_stream_tests_extra.rs#L1108-L1152
  • crates/perry-runtime/src/node_stream_tests_extra.rs#L1161-L1194
🤖 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 `@crates/perry-runtime/src/node_stream_tests_extra.rs` around lines 923 - 932,
Complete RuntimeHandleScope rooting in
crates/perry-runtime/src/node_stream_tests_extra.rs:923-932 by rooting closure
and stream and refreshing them before raw-handle calls; at 1044-1059 root the
error listener and event string before deriving handle; at 1108-1152 apply the
same ordering to stream and ended; and at 1161-1194 root both listeners and
reload each stream before deriving its handle. Do not retain stream or obj raw
addresses across allocations.

Source: Learnings

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

Labels

type:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[parity] node:stream — 27 failing node-suite tests (2026-07-22 baseline)

1 participant