From 2966853bf500c31a110e4feeadd2bdae421541d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 05:40:44 +0200 Subject: [PATCH 1/9] fix(child_process): retain pipe EOF for late async readers --- .../pending-child-output-late-iterator.md | 6 + .../src/child_process/builder.rs | 1 + .../src/child_process/emitter.rs | 9 ++ crates/perry-runtime/src/child_process/mod.rs | 2 +- .../src/child_process/reactor.rs | 2 +- .../src/node_stream/async_iterator.rs | 21 +++ .../async_iterator/foreign_eof_tests.rs | 127 ++++++++++++++++++ .../perry/tests/child_output_late_iterator.rs | 20 +++ scripts/ci_e2e_scope.py | 1 + scripts/test-child-output-late-iterator.mjs | 38 ++++++ .../child_output_late_iterator/README.md | 20 +++ .../child_output_late_iterator/main.js | 37 +++++ 12 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 changelog.d/pending-child-output-late-iterator.md create mode 100644 crates/perry-runtime/src/node_stream/async_iterator/foreign_eof_tests.rs create mode 100644 crates/perry/tests/child_output_late_iterator.rs create mode 100644 scripts/test-child-output-late-iterator.mjs create mode 100644 tests/modules/child_output_late_iterator/README.md create mode 100644 tests/modules/child_output_late_iterator/main.js diff --git a/changelog.d/pending-child-output-late-iterator.md b/changelog.d/pending-child-output-late-iterator.md new file mode 100644 index 0000000000..888a21baac --- /dev/null +++ b/changelog.d/pending-child-output-late-iterator.md @@ -0,0 +1,6 @@ +- Retain real child stdout/stderr pipe EOF before dispatching end callbacks, so + async iterators first pulled or created after EOF finish instead of waiting + forever. Update `readable` and `readableEnded` consistently with that state. +- Add runtime regressions for late readers, delayed first pulls, pending empty + pulls, and buffered chunks, plus a bounded real-child Node/native parity fixture + at O0, Os, and Oz. The regression is independent of any application bundle. diff --git a/crates/perry-runtime/src/child_process/builder.rs b/crates/perry-runtime/src/child_process/builder.rs index 578b2fb03d..caf1ca631e 100644 --- a/crates/perry-runtime/src/child_process/builder.rs +++ b/crates/perry-runtime/src/child_process/builder.rs @@ -138,6 +138,7 @@ pub(crate) fn cp_build_readable() -> f64 { let obj = cp_build_object(&methods, CP_READABLE_SHAPE_ID + methods.len() as u32); let val = cp_box_ptr(obj as *const u8); cp_set_field(val, b"readable", TAG_TRUE_F64); + cp_set_field(val, b"readableEnded", TAG_FALSE_F64); cp_set_field(val, b"destroyed", TAG_FALSE_F64); // A child's `stdout`/`stderr` must be async-iterable, like Node's: both // `for await (const chunk of child.stdout)` and the `isAsyncIterable` probe diff --git a/crates/perry-runtime/src/child_process/emitter.rs b/crates/perry-runtime/src/child_process/emitter.rs index 511ce237db..adf0eec023 100644 --- a/crates/perry-runtime/src/child_process/emitter.rs +++ b/crates/perry-runtime/src/child_process/emitter.rs @@ -87,6 +87,15 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { fired } +/// Deliver real pipe EOF and retain it for readers attaching after the event. +/// Keep this separate from public `.emit("end")`, which is not a pipe EOF. +pub(crate) fn cp_readable_end(stream: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(stream); + crate::node_stream::async_iterator::mark_foreign_readable_ended(stream.get_nanbox_f64()); + cp_emit(stream.get_nanbox_f64(), "end", &[]); +} + // ----- method bodies (each receives the closure; slot 0 = host `this`) ----- pub(crate) extern "C" fn cp_method_on(closure: *const ClosureHeader, event: f64, cb: f64) -> f64 { diff --git a/crates/perry-runtime/src/child_process/mod.rs b/crates/perry-runtime/src/child_process/mod.rs index 487d6eddf7..beadd20280 100644 --- a/crates/perry-runtime/src/child_process/mod.rs +++ b/crates/perry-runtime/src/child_process/mod.rs @@ -94,7 +94,7 @@ pub(crate) use emitter::{ cp_method_kill, cp_method_on, cp_method_pipe, cp_method_read, cp_method_ref, cp_method_remove_all_listeners, cp_method_remove_listener, cp_method_send, cp_method_set_encoding, cp_method_stdin_end, cp_method_stdin_write, cp_method_this0, - cp_method_this1, cp_method_unref, cp_register, cp_send_callback_thunk, + cp_method_this1, cp_method_unref, cp_readable_end, cp_register, cp_send_callback_thunk, cp_stream_callback_thunk, js_fork_child, }; diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index 0b7653c2d6..2496b03052 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -1631,7 +1631,7 @@ fn cp_reactor_pump_inner() { for fd in end_fds { let stream = cp_stdio_stream(cp, fd); if super::cp_object_ptr(stream).is_some() { - cp_emit(stream, "end", &[]); + super::cp_readable_end(stream); } } } diff --git a/crates/perry-runtime/src/node_stream/async_iterator.rs b/crates/perry-runtime/src/node_stream/async_iterator.rs index 861456bbd8..3a2ede1dec 100644 --- a/crates/perry-runtime/src/node_stream/async_iterator.rs +++ b/crates/perry-runtime/src/node_stream/async_iterator.rs @@ -838,6 +838,24 @@ pub(crate) fn install_foreign_readable_async_iterator_symbol(stream: f64) { install_readable_async_iterator_symbol(stream); } +/// Record a foreign source's real EOF before invoking its end listeners. An +/// iterator first pulled after that event cannot rely on seeing it again. +pub(crate) fn mark_foreign_readable_ended(stream: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(stream); + for (key, bits) in [ + (STREAM_END_EMITTED_KEY, TAG_TRUE), + (STREAM_ENDED_KEY, TAG_TRUE), + (b"readable".as_slice(), TAG_FALSE), + (b"readableEnded".as_slice(), TAG_TRUE), + ] { + let key = scope.root_string_ptr(hidden_key(key)); + key.with_mut_ptr(|key| { + set_hidden_value(stream.get_nanbox_f64(), key, f64::from_bits(bits)) + }); + } +} + pub(crate) fn install_readable_async_iterator_symbol(stream: f64) { install_async_iterator_symbol(stream, ns_async_iterator); } @@ -878,6 +896,9 @@ pub(super) fn register_arities() { crate::closure::js_register_closure_arity(ns_readable_iter_on_error as *const u8, 1); } +#[cfg(test)] +mod foreign_eof_tests; + #[cfg(test)] mod fifo_pending_tests { use super::*; diff --git a/crates/perry-runtime/src/node_stream/async_iterator/foreign_eof_tests.rs b/crates/perry-runtime/src/node_stream/async_iterator/foreign_eof_tests.rs new file mode 100644 index 0000000000..04b09e56c9 --- /dev/null +++ b/crates/perry-runtime/src/node_stream/async_iterator/foreign_eof_tests.rs @@ -0,0 +1,127 @@ +use super::*; +use crate::promise::{Promise, PromiseState}; + +fn readable() -> f64 { + register_arities(); + crate::child_process::cp_register_arities(); + crate::child_process::cp_build_readable() +} + +fn pull(iterator: f64) -> f64 { + let next = get_hidden_value(iterator, hidden_key(b"next")).expect("iterator.next"); + crate::closure::js_closure_call0(raw_ptr_from_value(next) as *const ClosureHeader) +} + +fn result_field(promise: f64, name: &[u8]) -> f64 { + assert_ne!(crate::promise::js_value_is_promise(promise), 0); + let promise = raw_ptr_from_value(promise) as *const Promise; + let result = unsafe { + assert_eq!( + (*promise).state, + PromiseState::Fulfilled, + "pull must settle" + ); + (*promise).value + }; + get_hidden_value(result, hidden_key(name)).expect("iterator result field") +} + +#[test] +fn late_child_iterator_observes_retained_eof() { + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(readable()); + assert_eq!( + get_hidden_value(stream.get_nanbox_f64(), hidden_key(b"readableEnded")) + .unwrap() + .to_bits(), + TAG_FALSE + ); + // Same EOF helper called by the child-process reactor, without any + // iterator/end listener in existence when the pipe closes. + crate::child_process::cp_readable_end(stream.get_nanbox_f64()); + assert_eq!( + get_hidden_value(stream.get_nanbox_f64(), hidden_key(b"readable")) + .unwrap() + .to_bits(), + TAG_FALSE + ); + assert_eq!( + get_hidden_value(stream.get_nanbox_f64(), hidden_key(b"readableEnded")) + .unwrap() + .to_bits(), + TAG_TRUE + ); + for _ in 0..2 { + let iterator = + scope.root_nanbox_f64(build_readable_async_iterator(stream.get_nanbox_f64(), true)); + let promise = scope.root_nanbox_f64(pull(iterator.get_nanbox_f64())); + assert_eq!( + result_field(promise.get_nanbox_f64(), b"done").to_bits(), + TAG_TRUE + ); + } +} + +#[test] +fn child_eof_between_iterator_creation_and_first_pull_is_retained() { + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(readable()); + let iterator = + scope.root_nanbox_f64(build_readable_async_iterator(stream.get_nanbox_f64(), true)); + crate::child_process::cp_readable_end(stream.get_nanbox_f64()); + let promise = scope.root_nanbox_f64(pull(iterator.get_nanbox_f64())); + assert_eq!( + result_field(promise.get_nanbox_f64(), b"done").to_bits(), + TAG_TRUE + ); +} + +#[test] +fn child_eof_settles_an_already_pending_empty_pull() { + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(readable()); + let iterator = + scope.root_nanbox_f64(build_readable_async_iterator(stream.get_nanbox_f64(), true)); + let pending = scope.root_nanbox_f64(pull(iterator.get_nanbox_f64())); + let promise = raw_ptr_from_value(pending.get_nanbox_f64()) as *const Promise; + unsafe { + assert_eq!((*promise).state, PromiseState::Pending); + } + crate::child_process::cp_readable_end(stream.get_nanbox_f64()); + assert_eq!( + result_field(pending.get_nanbox_f64(), b"done").to_bits(), + TAG_TRUE + ); +} + +#[test] +fn child_eof_preserves_buffered_chunks_and_settles_live_pulls() { + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(readable()); + let iterator = + scope.root_nanbox_f64(build_readable_async_iterator(stream.get_nanbox_f64(), true)); + let first = scope.root_nanbox_f64(pull(iterator.get_nanbox_f64())); + let promise = raw_ptr_from_value(first.get_nanbox_f64()) as *const Promise; + unsafe { + assert_eq!((*promise).state, PromiseState::Pending); + } + crate::child_process::cp_emit(stream.get_nanbox_f64(), "data", &[11.0]); + crate::child_process::cp_emit(stream.get_nanbox_f64(), "data", &[22.0]); + crate::child_process::cp_readable_end(stream.get_nanbox_f64()); + assert_eq!(result_field(first.get_nanbox_f64(), b"value"), 11.0); + assert_eq!( + result_field(first.get_nanbox_f64(), b"done").to_bits(), + TAG_FALSE + ); + let second = scope.root_nanbox_f64(pull(iterator.get_nanbox_f64())); + assert_eq!(result_field(second.get_nanbox_f64(), b"value"), 22.0); + assert_eq!( + result_field(second.get_nanbox_f64(), b"done").to_bits(), + TAG_FALSE + ); + let end = scope.root_nanbox_f64(pull(iterator.get_nanbox_f64())); + assert_eq!( + result_field(end.get_nanbox_f64(), b"done").to_bits(), + TAG_TRUE + ); +} diff --git a/crates/perry/tests/child_output_late_iterator.rs b/crates/perry/tests/child_output_late_iterator.rs new file mode 100644 index 0000000000..d3c7e0dc09 --- /dev/null +++ b/crates/perry/tests/child_output_late_iterator.rs @@ -0,0 +1,20 @@ +//! CI-visible native regression for late async readers of child output pipes. +use std::{path::Path, process::Command}; + +#[test] +fn standalone_regression() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("node") + .arg(root.join("scripts/test-child-output-late-iterator.mjs")) + .env("PERRY_BIN", env!("CARGO_BIN_EXE_perry")) + .env("PERRY_WORKSPACE_ROOT", &root) + .current_dir(&root) + .output() + .expect("run bounded Node regression driver"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/scripts/ci_e2e_scope.py b/scripts/ci_e2e_scope.py index 4270cfe812..713eec06a4 100755 --- a/scripts/ci_e2e_scope.py +++ b/scripts/ci_e2e_scope.py @@ -129,6 +129,7 @@ "macos_bundle_chdir_gate", "manifest_consistency", "namespace_getter_binding_identity", + "child_output_late_iterator", "native_proof_buffer_views", "padding_single_evaluation", "shadow_slot_hygiene", diff --git a/scripts/test-child-output-late-iterator.mjs b/scripts/test-child-output-late-iterator.mjs new file mode 100644 index 0000000000..b2a2ebb429 --- /dev/null +++ b/scripts/test-child-output-late-iterator.mjs @@ -0,0 +1,38 @@ +// Application-independent regression for real child-pipe EOF before iteration. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); +const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-child-late-iterator-')); +const source = path.join(root, 'tests/modules/child_output_late_iterator/main.js'); +const env = { ...process.env, PERRY_TEST_CHILD_EXECUTABLE: process.execPath }; +if (env.PERRY_TEST_WASM === '1' && env.PERRY_RUNTIME_DIR) delete env.PERRY_WORKSPACE_ROOT; +let passed = false; +function run(name, executable, args, timeout, extraEnv = {}) { + const result = spawnSync(executable, args, { cwd: work, env: { ...env, ...extraEnv }, + encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024 }); + fs.writeFileSync(path.join(work, `${name}.log`), `${result.stdout ?? ''}${result.stderr ?? ''}`); + if (result.error || result.status !== 0) throw new Error(`${name}: ${result.error ?? result.status}`); + return result.stdout; +} +try { + const expected = 'PASS: child stdout/stderr late async iterators\n'; + if (run('node', process.execPath, [source], 15000) !== expected) throw new Error('Node witness missing'); + for (const opt of ['0', 's', 'z']) { + const output = path.join(work, `native-O${opt}${process.platform === 'win32' ? '.exe' : ''}`); + run(`compile-O${opt}`, compiler, ['compile', source, '-o', output, + '--cache-dir', path.join(work, `cache-O${opt}`), '--platform', 'bun', + '--no-auto-optimize', '--no-color', ...(env.PERRY_TEST_WASM === '1' ? ['--enable-wasm-runtime'] : [])], + 120000, { PERRY_LL_OPT_LEVEL: opt }); + if (run(`native-O${opt}`, output, [], 15000) !== expected) throw new Error(`O${opt} witness mismatch`); + console.log(`PASS child-output-late-iterator O${opt}`); + } + passed = true; +} finally { + if (passed) fs.rmSync(work, { recursive: true }); + else console.error(`Retained regression diagnostics: ${work}`); +} diff --git a/tests/modules/child_output_late_iterator/README.md b/tests/modules/child_output_late_iterator/README.md new file mode 100644 index 0000000000..b0cca25854 --- /dev/null +++ b/tests/modules/child_output_late_iterator/README.md @@ -0,0 +1,20 @@ +# Child output: async iteration after EOF + +Run `node scripts/test-child-output-late-iterator.mjs` with `PERRY_BIN` pointing +to a compiler and `PERRY_RUNTIME_DIR` pointing to matching static libraries. +For the Wasm-enabled archive configuration, also set `PERRY_TEST_WASM=1`. + +The runner uses the executing Node as an explicitly selected real child, checks +the Node oracle, and compiles/runs the same fixture at O0, Os, and Oz. Each +compile and execution has a timeout, and failures retain their diagnostic files. +No application bundle, network, credentials, or downloaded npm dependency is +needed. + +Both output pipes must emit EOF and expose `readable === false` and +`readableEnded === true` inside their end callbacks. Only after child close does +the fixture first pull an iterator created before EOF, then create and consume +fresh iterators. All must finish without waiting for a second end event. + +Before the fix, the native program fails the visible EOF-state assertion; without +that assertion, it waits forever on the first late pull. Runtime unit tests also +cover pending empty pulls and preserving chunks buffered before EOF. diff --git a/tests/modules/child_output_late_iterator/main.js b/tests/modules/child_output_late_iterator/main.js new file mode 100644 index 0000000000..eae0e7a1b4 --- /dev/null +++ b/tests/modules/child_output_late_iterator/main.js @@ -0,0 +1,37 @@ +import { spawn } from 'node:child_process'; + +const executable = process.env.PERRY_TEST_CHILD_EXECUTABLE; +if (!executable) throw new Error('Set PERRY_TEST_CHILD_EXECUTABLE to a Node executable'); +const watchdog = setTimeout(() => { console.error('FAIL: late iterator did not settle'); process.exit(1); }, 5000); +function check(value, message) { if (!value) throw new Error(message); } + +async function main() { + const child = spawn(executable, ['-e', 'process.stdout.write("out"); process.stderr.write("err")'], + { stdio: ['ignore', 'pipe', 'pipe'] }); + let ends = 0; + const earlyIterator = child.stdout[Symbol.asyncIterator](); + for (const stream of [child.stdout, child.stderr]) { + stream.on('end', () => { + check(stream.readable === false && stream.readableEnded === true, + 'EOF state must be visible inside the end callback'); + ends++; + }); + stream.resume(); + } + await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', code => code === 0 ? resolve() : reject(new Error('child failed'))); + }); + check(ends === 2, 'both real output pipes must have delivered EOF'); + check((await earlyIterator.next()).done === true, 'first pull after EOF must complete'); + for (const stream of [child.stdout, child.stderr]) { + let count = 0; + for await (const _chunk of stream) count++; + check(count === 0, 'already drained stream must remain empty'); + check((await stream[Symbol.asyncIterator]().next()).done === true, + 'a fresh iterator must also observe retained EOF'); + } + clearTimeout(watchdog); + console.log('PASS: child stdout/stderr late async iterators'); +} +main().catch(error => { console.error(error.message); process.exit(1); }); From f1f617190fbf76826f46064e4c962dce6a2a417a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 05:41:29 +0200 Subject: [PATCH 2/9] docs: key child pipe EOF changeset to PR 10042 --- ...utput-late-iterator.md => 10042-child-output-late-iterator.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{pending-child-output-late-iterator.md => 10042-child-output-late-iterator.md} (100%) diff --git a/changelog.d/pending-child-output-late-iterator.md b/changelog.d/10042-child-output-late-iterator.md similarity index 100% rename from changelog.d/pending-child-output-late-iterator.md rename to changelog.d/10042-child-output-late-iterator.md From 634eff948d76a8a2c4dd31c14baa49678ba47e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 05:50:47 +0200 Subject: [PATCH 3/9] test: rely on diff selection for the perry child-output suite --- scripts/ci_e2e_scope.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/ci_e2e_scope.py b/scripts/ci_e2e_scope.py index 713eec06a4..4270cfe812 100755 --- a/scripts/ci_e2e_scope.py +++ b/scripts/ci_e2e_scope.py @@ -129,7 +129,6 @@ "macos_bundle_chdir_gate", "manifest_consistency", "namespace_getter_binding_identity", - "child_output_late_iterator", "native_proof_buffer_views", "padding_single_evaluation", "shadow_slot_hygiene", From 84ecd01af44c7cb8a745297f6db5f79eb2f92595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 06:06:47 +0200 Subject: [PATCH 4/9] fix(child_process): finish output collectors after spawn failure --- .../10042-child-output-late-iterator.md | 3 + .../src/child_process/failed_spawn.rs | 67 +++++++++++++++++++ crates/perry-runtime/src/child_process/mod.rs | 1 + .../src/child_process/reactor.rs | 7 +- scripts/test-child-output-late-iterator.mjs | 30 +++++---- .../child_output_late_iterator/README.md | 7 ++ .../failed-spawn.js | 42 ++++++++++++ 7 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 crates/perry-runtime/src/child_process/failed_spawn.rs create mode 100644 tests/modules/child_output_late_iterator/failed-spawn.js diff --git a/changelog.d/10042-child-output-late-iterator.md b/changelog.d/10042-child-output-late-iterator.md index 888a21baac..3436229321 100644 --- a/changelog.d/10042-child-output-late-iterator.md +++ b/changelog.d/10042-child-output-late-iterator.md @@ -1,6 +1,9 @@ - Retain real child stdout/stderr pipe EOF before dispatching end callbacks, so async iterators first pulled or created after EOF finish instead of waiting forever. Update `readable` and `readableEnded` consistently with that state. +- Finish empty output pipes after a failed spawn as well: no live reactor entry + exists to deliver their EOF. This prevents ENOENT/EACCES cleanup from hanging + while awaiting stdout/stderr or extra-pipe collectors. - Add runtime regressions for late readers, delayed first pulls, pending empty pulls, and buffered chunks, plus a bounded real-child Node/native parity fixture at O0, Os, and Oz. The regression is independent of any application bundle. diff --git a/crates/perry-runtime/src/child_process/failed_spawn.rs b/crates/perry-runtime/src/child_process/failed_spawn.rs new file mode 100644 index 0000000000..7236ad2878 --- /dev/null +++ b/crates/perry-runtime/src/child_process/failed_spawn.rs @@ -0,0 +1,67 @@ +//! A failed spawn has no live reactor entry to deliver output-pipe EOF. +//! Finish those empty streams after the child error and before child close. +use super::*; + +pub(super) fn finish_outputs(cp: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let cp = scope.root_nanbox_f64(cp); + let stdio = scope.root_nanbox_f64(cp_get_field(cp.get_nanbox_f64(), b"stdio")); + let count = cp_array_ptr(stdio.get_nanbox_f64()) + .map(|array| crate::array::js_array_length(array)) + .unwrap_or(0); + // fd 0 is writable stdin; every other pipe built by spawn is readable. + // Ignored/inherited fds are null and must not receive synthetic events. + for fd in 1..count { + let Some(array) = cp_array_ptr(stdio.get_nanbox_f64()) else { + break; + }; + let stream = crate::array::js_array_get_f64(array, fd); + finish_output(stream); + } +} + +fn finish_output(stream: f64) { + if cp_object_ptr(stream).is_none() { + return; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(stream); + if cp_get_field(stream.get_nanbox_f64(), b"closed").to_bits() == TAG_TRUE_F64.to_bits() { + return; + } + cp_readable_end(stream.get_nanbox_f64()); + cp_set_field(stream.get_nanbox_f64(), b"destroyed", TAG_TRUE_F64); + cp_set_field(stream.get_nanbox_f64(), b"closed", TAG_TRUE_F64); + cp_emit(stream.get_nanbox_f64(), "close", &[]); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_output_retains_end_and_closed_state() { + cp_register_arities(); + let scope = crate::gc::RuntimeHandleScope::new(); + let stream = scope.root_nanbox_f64(cp_build_readable()); + for _ in 0..2 { + finish_output(stream.get_nanbox_f64()); + assert_eq!( + cp_get_field(stream.get_nanbox_f64(), b"readable").to_bits(), + TAG_FALSE_F64.to_bits() + ); + for key in [b"readableEnded".as_slice(), b"destroyed", b"closed"] { + assert_eq!( + cp_get_field(stream.get_nanbox_f64(), key).to_bits(), + TAG_TRUE_F64.to_bits() + ); + } + } + } + + #[test] + fn absent_failed_output_is_ignored() { + finish_output(TAG_NULL_F64); + finish_output(cp_undefined()); + } +} diff --git a/crates/perry-runtime/src/child_process/mod.rs b/crates/perry-runtime/src/child_process/mod.rs index beadd20280..91e1c73c33 100644 --- a/crates/perry-runtime/src/child_process/mod.rs +++ b/crates/perry-runtime/src/child_process/mod.rs @@ -55,6 +55,7 @@ use crate::value::JSValue; mod builder; mod emitter; mod exec; +mod failed_spawn; mod options; mod output; mod registry; diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index 2496b03052..c37b1aa387 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -1197,8 +1197,11 @@ pub(super) extern "C" fn cp_emit_spawn_error(closure: *const ClosureHeader) -> f } extern "C" fn cp_emit_spawn_close(closure: *const ClosureHeader) -> f64 { - let cp = cp_this(closure); - cp_emit(cp, "close", &[cp_get_field(cp, b"exitCode"), TAG_NULL_F64]); + let scope = crate::gc::RuntimeHandleScope::new(); + let cp = scope.root_nanbox_f64(cp_this(closure)); + super::failed_spawn::finish_outputs(cp.get_nanbox_f64()); + let code = cp_get_field(cp.get_nanbox_f64(), b"exitCode"); + cp_emit(cp.get_nanbox_f64(), "close", &[code, TAG_NULL_F64]); cp_undefined() } diff --git a/scripts/test-child-output-late-iterator.mjs b/scripts/test-child-output-late-iterator.mjs index b2a2ebb429..c6e8525650 100644 --- a/scripts/test-child-output-late-iterator.mjs +++ b/scripts/test-child-output-late-iterator.mjs @@ -8,8 +8,8 @@ import { spawnSync } from 'node:child_process'; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-child-late-iterator-')); -const source = path.join(root, 'tests/modules/child_output_late_iterator/main.js'); -const env = { ...process.env, PERRY_TEST_CHILD_EXECUTABLE: process.execPath }; +const env = { ...process.env, PERRY_TEST_CHILD_EXECUTABLE: process.execPath, + PERRY_TEST_MISSING_CHILD: path.join(work, 'definitely-absent-child') }; if (env.PERRY_TEST_WASM === '1' && env.PERRY_RUNTIME_DIR) delete env.PERRY_WORKSPACE_ROOT; let passed = false; function run(name, executable, args, timeout, extraEnv = {}) { @@ -20,16 +20,22 @@ function run(name, executable, args, timeout, extraEnv = {}) { return result.stdout; } try { - const expected = 'PASS: child stdout/stderr late async iterators\n'; - if (run('node', process.execPath, [source], 15000) !== expected) throw new Error('Node witness missing'); - for (const opt of ['0', 's', 'z']) { - const output = path.join(work, `native-O${opt}${process.platform === 'win32' ? '.exe' : ''}`); - run(`compile-O${opt}`, compiler, ['compile', source, '-o', output, - '--cache-dir', path.join(work, `cache-O${opt}`), '--platform', 'bun', - '--no-auto-optimize', '--no-color', ...(env.PERRY_TEST_WASM === '1' ? ['--enable-wasm-runtime'] : [])], - 120000, { PERRY_LL_OPT_LEVEL: opt }); - if (run(`native-O${opt}`, output, [], 15000) !== expected) throw new Error(`O${opt} witness mismatch`); - console.log(`PASS child-output-late-iterator O${opt}`); + for (const [fixture, expected] of [ + ['main', 'PASS: child stdout/stderr late async iterators\n'], + ['failed-spawn', 'PASS: failed child output collectors\n'], + ]) { + const source = path.join(root, `tests/modules/child_output_late_iterator/${fixture}.js`); + if (run(`node-${fixture}`, process.execPath, [source], 15000) !== expected) throw new Error('Node witness missing'); + for (const opt of ['0', 's', 'z']) { + const label = `${fixture}-O${opt}`; + const output = path.join(work, `native-${label}${process.platform === 'win32' ? '.exe' : ''}`); + run(`compile-${label}`, compiler, ['compile', source, '-o', output, + '--cache-dir', path.join(work, `cache-${label}`), '--platform', 'bun', + '--no-auto-optimize', '--no-color', ...(env.PERRY_TEST_WASM === '1' ? ['--enable-wasm-runtime'] : [])], + 120000, { PERRY_LL_OPT_LEVEL: opt }); + if (run(`native-${label}`, output, [], 15000) !== expected) throw new Error(`${label} witness mismatch`); + console.log(`PASS child-output-late-iterator ${label}`); + } } passed = true; } finally { diff --git a/tests/modules/child_output_late_iterator/README.md b/tests/modules/child_output_late_iterator/README.md index b0cca25854..b3091750c1 100644 --- a/tests/modules/child_output_late_iterator/README.md +++ b/tests/modules/child_output_late_iterator/README.md @@ -18,3 +18,10 @@ fresh iterators. All must finish without waiting for a second end event. Before the fix, the native program fails the visible EOF-state assertion; without that assertion, it waits forever on the first late pull. Runtime unit tests also cover pending empty pulls and preserving chunks buffered before EOF. + +`failed-spawn.js` checks a definitely absent executable supplied by the runner. +It starts collectors on stdout, stderr, and an extra output pipe before ENOENT, +then yields, destroys the streams, and awaits the collectors (Execa-style error +cleanup). All must finish empty, with end/closed state retained for late readers. +The failed child has no reactor entry, so normal OS-pipe EOF delivery cannot +finish these streams. Child error must precede their end events. diff --git a/tests/modules/child_output_late_iterator/failed-spawn.js b/tests/modules/child_output_late_iterator/failed-spawn.js new file mode 100644 index 0000000000..be84b5ea2e --- /dev/null +++ b/tests/modules/child_output_late_iterator/failed-spawn.js @@ -0,0 +1,42 @@ +import { spawn } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; + +const watchdog = setTimeout(() => { console.error('FAIL: failed child collectors stalled'); process.exit(1); }, 5000); +function check(value, message) { if (!value) throw new Error(message); } +async function collect(stream) { + let bytes = 0; + for await (const chunk of stream) bytes += chunk.length; + return bytes; +} +async function main() { + const missing = process.env.PERRY_TEST_MISSING_CHILD; + check(typeof missing === 'string', 'runner must supply a definitely absent executable'); + const child = spawn(missing, [], { stdio: ['ignore', 'pipe', 'pipe', 'pipe'] }); + const streams = [child.stdout, child.stderr, child.stdio[3]]; + let errorSeen = false, ends = 0; + for (const stream of streams) stream.on('end', () => { + check(errorSeen, 'failed spawn must emit its child error before stream end'); + ends++; + }); + const readers = streams.map(collect); + const closed = new Promise(resolve => child.on('close', resolve)); + const code = await new Promise(resolve => child.on('error', error => { + errorSeen = true; + resolve(error.code); + })); + check(code === 'ENOENT', 'must exercise the real OS spawn failure'); + // Execa-shaped cleanup: yield, destroy output streams, then await collectors. + await delay(0); + for (const stream of streams) stream.destroy(); + const results = await Promise.all(readers); + await closed; + check(ends === 3 && results.every(bytes => bytes === 0), 'all three empty output pipes must end'); + for (const stream of streams) { + check(stream.readable === false && stream.readableEnded === true && + stream.destroyed === true && stream.closed === true, 'failed output must be terminal'); + check((await stream[Symbol.asyncIterator]().next()).done === true, 'late failed-output reader must finish'); + } + clearTimeout(watchdog); + console.log('PASS: failed child output collectors'); +} +main().catch(error => { console.error(error.message); process.exit(1); }); From 7dc2a270004b4cf5c27ae9673f855142b0eb9487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 06:23:59 +0200 Subject: [PATCH 5/9] fix(child_process): order failed-spawn close after error delivery --- .../10042-child-output-late-iterator.md | 2 ++ .../src/child_process/failed_spawn.rs | 13 ++++++++++ .../src/child_process/reactor.rs | 26 +++++++++++-------- .../failed-spawn.js | 7 ++++- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/changelog.d/10042-child-output-late-iterator.md b/changelog.d/10042-child-output-late-iterator.md index 3436229321..d16db425af 100644 --- a/changelog.d/10042-child-output-late-iterator.md +++ b/changelog.d/10042-child-output-late-iterator.md @@ -4,6 +4,8 @@ - Finish empty output pipes after a failed spawn as well: no live reactor entry exists to deliver their EOF. This prevents ENOENT/EACCES cleanup from hanging while awaiting stdout/stderr or extra-pipe collectors. +- Schedule failed-spawn close after its error callback, preventing overdue + close timers from reversing error/end ordering during slow startup. - Add runtime regressions for late readers, delayed first pulls, pending empty pulls, and buffered chunks, plus a bounded real-child Node/native parity fixture at O0, Os, and Oz. The regression is independent of any application bundle. diff --git a/crates/perry-runtime/src/child_process/failed_spawn.rs b/crates/perry-runtime/src/child_process/failed_spawn.rs index 7236ad2878..d15ec5e5c7 100644 --- a/crates/perry-runtime/src/child_process/failed_spawn.rs +++ b/crates/perry-runtime/src/child_process/failed_spawn.rs @@ -2,6 +2,19 @@ //! Finish those empty streams after the child error and before child close. use super::*; +/// Schedule close only after delivering the error. A close timer armed at spawn +/// time can already be overdue before the error's setImmediate callback runs. +/// Keep fork's separate failure contract unchanged by using this for spawn only. +pub(super) extern "C" fn emit_error_then_close(closure: *const ClosureHeader) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let cp = scope.root_nanbox_f64(cp_this(closure)); + reactor::cp_emit_spawn_error(closure); + let close = crate::closure::js_closure_alloc(reactor::cp_emit_spawn_close as *const u8, 1); + crate::closure::js_closure_set_capture_ptr(close, 0, cp.get_nanbox_f64().to_bits() as i64); + crate::timer::js_set_timeout_callback(close as i64, 1.0); + cp_undefined() +} + pub(super) fn finish_outputs(cp: f64) { let scope = crate::gc::RuntimeHandleScope::new(); let cp = scope.root_nanbox_f64(cp); diff --git a/crates/perry-runtime/src/child_process/reactor.rs b/crates/perry-runtime/src/child_process/reactor.rs index c37b1aa387..3d8f4f360b 100644 --- a/crates/perry-runtime/src/child_process/reactor.rs +++ b/crates/perry-runtime/src/child_process/reactor.rs @@ -1171,13 +1171,12 @@ pub extern "C" fn js_child_process_spawn_streams( cp_set_field(cp, b"__cpError", err); cp_set_field(cp, b"__cpSpawnErrno", super::cp_errno_number(code)); cp_set_field(cp, b"exitCode", super::cp_errno_number(code)); - let emit_closure = - crate::closure::js_closure_alloc(cp_emit_spawn_error as *const u8, 1); + let emit_closure = crate::closure::js_closure_alloc( + super::failed_spawn::emit_error_then_close as *const u8, + 1, + ); crate::closure::js_closure_set_capture_ptr(emit_closure, 0, cp.to_bits() as i64); crate::timer::js_set_immediate_callback(emit_closure as i64); - let close = crate::closure::js_closure_alloc(cp_emit_spawn_close as *const u8, 1); - crate::closure::js_closure_set_capture_ptr(close, 0, cp.to_bits() as i64); - crate::timer::js_set_timeout_callback(close as i64, 1.0); } } @@ -1187,16 +1186,17 @@ pub extern "C" fn js_child_process_spawn_streams( /// Deferred single-`error` emit for the spawn/fork failure path. Slot 0 /// captures the ChildProcess value. pub(super) extern "C" fn cp_emit_spawn_error(closure: *const ClosureHeader) -> f64 { - let cp = cp_this(closure); - let err = cp_get_field(cp, b"__cpError"); - if !JSValue::from_bits(err.to_bits()).is_undefined() { - cp_emit(cp, "error", &[err]); - cp_set_field(cp, b"signalCode", TAG_NULL_F64); + let scope = crate::gc::RuntimeHandleScope::new(); + let cp = scope.root_nanbox_f64(cp_this(closure)); + let err = scope.root_nanbox_f64(cp_get_field(cp.get_nanbox_f64(), b"__cpError")); + if !JSValue::from_bits(err.get_nanbox_f64().to_bits()).is_undefined() { + cp_emit(cp.get_nanbox_f64(), "error", &[err.get_nanbox_f64()]); + cp_set_field(cp.get_nanbox_f64(), b"signalCode", TAG_NULL_F64); } cp_undefined() } -extern "C" fn cp_emit_spawn_close(closure: *const ClosureHeader) -> f64 { +pub(super) extern "C" fn cp_emit_spawn_close(closure: *const ClosureHeader) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let cp = scope.root_nanbox_f64(cp_this(closure)); super::failed_spawn::finish_outputs(cp.get_nanbox_f64()); @@ -1206,6 +1206,10 @@ extern "C" fn cp_emit_spawn_close(closure: *const ClosureHeader) -> f64 { } pub(super) fn cp_register_reactor_arities() { + crate::closure::js_register_closure_arity( + super::failed_spawn::emit_error_then_close as *const u8, + 0, + ); crate::closure::js_register_closure_arity(cp_emit_spawn_error as *const u8, 0); crate::closure::js_register_closure_arity(cp_emit_spawn_close as *const u8, 0); crate::closure::js_register_closure_arity(cp_abort_listener as *const u8, 0); diff --git a/tests/modules/child_output_late_iterator/failed-spawn.js b/tests/modules/child_output_late_iterator/failed-spawn.js index be84b5ea2e..5df32f99ea 100644 --- a/tests/modules/child_output_late_iterator/failed-spawn.js +++ b/tests/modules/child_output_late_iterator/failed-spawn.js @@ -20,10 +20,15 @@ async function main() { }); const readers = streams.map(collect); const closed = new Promise(resolve => child.on('close', resolve)); - const code = await new Promise(resolve => child.on('error', error => { + const failure = new Promise(resolve => child.on('error', error => { errorSeen = true; resolve(error.code); })); + // Make a prematurely armed close timer overdue before yielding. Error must + // still precede pipe EOF, irrespective of native optimization/startup speed. + const until = Date.now() + 20; + while (Date.now() < until) {} + const code = await failure; check(code === 'ENOENT', 'must exercise the real OS spawn failure'); // Execa-shaped cleanup: yield, destroy output streams, then await collectors. await delay(0); From 76fa5774bab29b525f265c89cdd5b86ac0929924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 06:48:39 +0200 Subject: [PATCH 6/9] fix(child_process): root event receivers across listener relocation --- .../10042-child-output-late-iterator.md | 2 + .../src/child_process/emitter.rs | 35 ++++--- .../child_process/emitter/relocation_tests.rs | 97 +++++++++++++++++++ 3 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 crates/perry-runtime/src/child_process/emitter/relocation_tests.rs diff --git a/changelog.d/10042-child-output-late-iterator.md b/changelog.d/10042-child-output-late-iterator.md index d16db425af..b7c6dcffa2 100644 --- a/changelog.d/10042-child-output-late-iterator.md +++ b/changelog.d/10042-child-output-late-iterator.md @@ -6,6 +6,8 @@ while awaiting stdout/stderr or extra-pipe collectors. - Schedule failed-spawn close after its error callback, preventing overdue close timers from reversing error/end ordering during slow startup. +- Root and reload child-event receivers and arguments across listener callbacks, + including forwarding to the shared stream listener registry after EOF. - Add runtime regressions for late readers, delayed first pulls, pending empty pulls, and buffered chunks, plus a bounded real-child Node/native parity fixture at O0, Os, and Oz. The regression is independent of any application bundle. diff --git a/crates/perry-runtime/src/child_process/emitter.rs b/crates/perry-runtime/src/child_process/emitter.rs index adf0eec023..581a9b59ce 100644 --- a/crates/perry-runtime/src/child_process/emitter.rs +++ b/crates/perry-runtime/src/child_process/emitter.rs @@ -34,17 +34,19 @@ pub(crate) fn cp_register(target: f64, event: f64, cb: f64) { /// any fired. The listener array is re-read each iteration so a moving GC /// during a handler call can't strand us on a stale array pointer. pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { + let scope = crate::gc::RuntimeHandleScope::new(); + let target = scope.root_nanbox_f64(target); + let args = scope.root_nanbox_f64_slice(args); if event == "message" - && args - .first() - .copied() - .is_some_and(|msg| crate::cluster::consume_internal_message(target, msg)) + && args.first().is_some_and(|msg| { + crate::cluster::consume_internal_message(target.get_nanbox_f64(), msg.get_nanbox_f64()) + }) { return true; } - let async_ids = - cp_handle_of(target).and_then(|handle| reactor::cp_async_scope_for_target(handle, target)); + let async_ids = cp_handle_of(target.get_nanbox_f64()) + .and_then(|handle| reactor::cp_async_scope_for_target(handle, target.get_nanbox_f64())); if let Some(ids) = async_ids { crate::async_hooks::enter_resource_scope(ids); } @@ -56,7 +58,7 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { // #9445: the displaced receiver is rooted ONCE here, not once per callback. let prev = this_scope.root_nanbox_f64(crate::object::js_implicit_this_get()); loop { - let arr = match cp_array_ptr(cp_get_field(target, &key)) { + let arr = match cp_array_ptr(cp_get_field(target.get_nanbox_f64(), &key)) { Some(a) => a, None => break, }; @@ -64,9 +66,10 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { break; } let cb = crate::array::js_array_get_f64(arr, i); - js_implicit_this_set(target); + js_implicit_this_set(target.get_nanbox_f64()); + let current_args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&args); unsafe { - let _ = js_native_call_value(cb, args.as_ptr(), args.len()); + let _ = js_native_call_value(cb, current_args.as_ptr(), current_args.len()); } js_implicit_this_set(prev.get_nanbox_f64()); fired = true; @@ -77,8 +80,15 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { // and that iterator registers its `data`/`end`/`error` listeners in node:stream's // registry rather than the one above. Forward there too, so a `for await` over a // child's output sees the chunks the reactor delivers. - if !JSValue::from_bits(cp_get_field(target, b"readable").to_bits()).is_undefined() { - crate::node_stream::emit_to_stream_listeners(target, event.as_bytes(), args); + if !JSValue::from_bits(cp_get_field(target.get_nanbox_f64(), b"readable").to_bits()) + .is_undefined() + { + let current_args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&args); + crate::node_stream::emit_to_stream_listeners( + target.get_nanbox_f64(), + event.as_bytes(), + ¤t_args, + ); } if let Some(ids) = async_ids { @@ -87,6 +97,9 @@ pub(crate) fn cp_emit(target: f64, event: &str, args: &[f64]) -> bool { fired } +#[cfg(test)] +mod relocation_tests; + /// Deliver real pipe EOF and retain it for readers attaching after the event. /// Keep this separate from public `.emit("end")`, which is not a pipe EOF. pub(crate) fn cp_readable_end(stream: f64) { diff --git a/crates/perry-runtime/src/child_process/emitter/relocation_tests.rs b/crates/perry-runtime/src/child_process/emitter/relocation_tests.rs new file mode 100644 index 0000000000..e8d77a1119 --- /dev/null +++ b/crates/perry-runtime/src/child_process/emitter/relocation_tests.rs @@ -0,0 +1,97 @@ +use super::*; + +/// Simulate the runtime-root rewrite performed by a moving collection inside +/// the first listener, without requiring a native stack map in a Rust test. +extern "C" fn relocate(closure: *const ClosureHeader, _arg: f64) -> f64 { + for pair in 0..2 { + let source = js_closure_get_capture_ptr(closure, pair * 2) as *mut u8; + let destination = js_closure_get_capture_ptr(closure, pair * 2 + 1) as *mut u8; + unsafe { + let header = source.sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + crate::gc::set_forwarding_address(header, destination); + } + } + crate::gc::test_rewrite_runtime_handles_for_forwarded_objects(); + cp_undefined() +} + +extern "C" fn observe(_closure: *const ClosureHeader, arg: f64) -> f64 { + let target = crate::object::js_implicit_this_get(); + cp_set_field(target, b"seen", arg); + cp_undefined() +} + +struct RestoreForwarding([(*mut u8, usize); 2]); + +impl Drop for RestoreForwarding { + fn drop(&mut self) { + for (source, first_word) in self.0 { + unsafe { + // GC_STORE_AUDIT(POINTER_FREE): restore the original object + // header word after this synthetic forwarding-only test. + source.cast::().write(first_word); + let header = source.sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*header).gc_flags &= !crate::gc::GC_FLAG_FORWARDED; + } + } + } +} + +#[test] +fn child_dispatch_reloads_receiver_and_arguments_after_listener_relocation() { + cp_register_arities(); + js_register_closure_arity(relocate as *const u8, 1); + js_register_closure_arity(observe as *const u8, 1); + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + let destination = + scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + let argument = scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + let moved_argument = + scope.root_nanbox_f64(cp_box_ptr(crate::object::js_object_alloc(0, 0).cast())); + let first = scope.root_nanbox_f64(cp_box_ptr( + js_closure_alloc(relocate as *const u8, 4).cast(), + )); + let second = + scope.root_nanbox_f64(cp_box_ptr(js_closure_alloc(observe as *const u8, 0).cast())); + let event = scope.root_nanbox_f64(cp_box_string("end")); + for target in [&source, &destination] { + cp_register( + target.get_nanbox_f64(), + event.get_nanbox_f64(), + first.get_nanbox_f64(), + ); + cp_register( + target.get_nanbox_f64(), + event.get_nanbox_f64(), + second.get_nanbox_f64(), + ); + } + let sources = [source.get_nanbox_f64(), argument.get_nanbox_f64()] + .map(|v| crate::value::js_nanbox_get_pointer(v) as *mut u8); + let destinations = [ + destination.get_nanbox_f64(), + moved_argument.get_nanbox_f64(), + ] + .map(|v| crate::value::js_nanbox_get_pointer(v) as *mut u8); + let _restore = RestoreForwarding(sources.map(|p| (p, unsafe { p.cast::().read() }))); + let callback = + crate::value::js_nanbox_get_pointer(first.get_nanbox_f64()) as *mut ClosureHeader; + for pair in 0..2 { + js_closure_set_capture_ptr(callback, pair * 2, sources[pair as usize] as i64); + js_closure_set_capture_ptr(callback, pair * 2 + 1, destinations[pair as usize] as i64); + } + assert!(cp_emit( + source.get_nanbox_f64(), + "end", + &[argument.get_nanbox_f64()] + )); + assert_eq!( + source.get_nanbox_f64().to_bits(), + destination.get_nanbox_f64().to_bits() + ); + assert_eq!( + cp_get_field(destination.get_nanbox_f64(), b"seen").to_bits(), + moved_argument.get_nanbox_f64().to_bits() + ); +} From 6736e3cd5ef472c9d33a6de08381a4487d91b7dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 07:06:37 +0200 Subject: [PATCH 7/9] test(child_process): use trusted GC header accessor in relocation fixture --- .../src/child_process/emitter/relocation_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/child_process/emitter/relocation_tests.rs b/crates/perry-runtime/src/child_process/emitter/relocation_tests.rs index e8d77a1119..bc5f27b481 100644 --- a/crates/perry-runtime/src/child_process/emitter/relocation_tests.rs +++ b/crates/perry-runtime/src/child_process/emitter/relocation_tests.rs @@ -7,7 +7,8 @@ extern "C" fn relocate(closure: *const ClosureHeader, _arg: f64) -> f64 { let source = js_closure_get_capture_ptr(closure, pair * 2) as *mut u8; let destination = js_closure_get_capture_ptr(closure, pair * 2 + 1) as *mut u8; unsafe { - let header = source.sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + // Both addresses originate from js_object_alloc in this fixture. + let header = crate::gc::header_from_trusted_user_ptr(source).cast_mut(); crate::gc::set_forwarding_address(header, destination); } } @@ -30,7 +31,8 @@ impl Drop for RestoreForwarding { // GC_STORE_AUDIT(POINTER_FREE): restore the original object // header word after this synthetic forwarding-only test. source.cast::().write(first_word); - let header = source.sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + // Restore the same fixture-owned js_object_alloc allocation. + let header = crate::gc::header_from_trusted_user_ptr(source).cast_mut(); (*header).gc_flags &= !crate::gc::GC_FLAG_FORWARDED; } } From 079b8a3ab45cf0531e8928381e84295bde7ea7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 08:05:13 +0200 Subject: [PATCH 8/9] test(child_process): publish failed native compiler diagnostics --- scripts/test-child-output-late-iterator.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/test-child-output-late-iterator.mjs b/scripts/test-child-output-late-iterator.mjs index c6e8525650..a9476bbe2a 100644 --- a/scripts/test-child-output-late-iterator.mjs +++ b/scripts/test-child-output-late-iterator.mjs @@ -16,7 +16,10 @@ function run(name, executable, args, timeout, extraEnv = {}) { const result = spawnSync(executable, args, { cwd: work, env: { ...env, ...extraEnv }, encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024 }); fs.writeFileSync(path.join(work, `${name}.log`), `${result.stdout ?? ''}${result.stderr ?? ''}`); - if (result.error || result.status !== 0) throw new Error(`${name}: ${result.error ?? result.status}`); + if (result.error || result.status !== 0) { + throw new Error(`${name}: ${result.error ?? result.status} (signal ${result.signal ?? 'none'})\n` + + `${result.stdout ?? ''}${result.stderr ?? ''}`); + } return result.stdout; } try { From 83d94c03fb4574542c8a4e3cd89672169f200b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 08:38:57 +0200 Subject: [PATCH 9/9] test(child_process): prepare coherent native providers before bounded fixtures --- .github/workflows/test.yml | 8 +++++++- changelog.d/10042-child-output-late-iterator.md | 2 ++ crates/perry/tests/child_output_late_iterator.rs | 1 + scripts/test-child-output-late-iterator.mjs | 2 ++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1683f89136..7c37f7c236 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1452,6 +1452,12 @@ jobs: if: steps.scope.outputs.rust_work == 'true' run: rm -rf target/perry-auto-* target/debug/libperry_ext_*.a 2>/dev/null || true + - name: Install the pinned integration-test Node oracle + if: steps.scope.outputs.suites != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version-file: .node-version + - name: Run scoped integration suites if: steps.scope.outputs.suites != '' env: @@ -1475,7 +1481,7 @@ jobs: # RFC-2945 abort guards that a JS throw trips — the opposite of the # shipped semantics. See the longer note in `cargo-test`. if printf '%s\n' "$SUITES" | grep -qE '^(perry|perry-stdlib) '; then - if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value) '; then + if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value|child_output_late_iterator|async_resource_own_bind) '; then # Prepare all require providers in one graph, outside the fixture's # timeout. A second perry-dev graph inside cargo test exceeded its # ten-minute bound on fresh runners (#9989/#9990). diff --git a/changelog.d/10042-child-output-late-iterator.md b/changelog.d/10042-child-output-late-iterator.md index b7c6dcffa2..57a06206ee 100644 --- a/changelog.d/10042-child-output-late-iterator.md +++ b/changelog.d/10042-child-output-late-iterator.md @@ -11,3 +11,5 @@ - Add runtime regressions for late readers, delayed first pulls, pending empty pulls, and buffered chunks, plus a bounded real-child Node/native parity fixture at O0, Os, and Oz. The regression is independent of any application bundle. +- Prepare its native providers in the same CI Cargo graph as stdlib, outside + per-fixture timeouts, use the pinned Node oracle, and publish compiler errors. diff --git a/crates/perry/tests/child_output_late_iterator.rs b/crates/perry/tests/child_output_late_iterator.rs index d3c7e0dc09..baaa653a07 100644 --- a/crates/perry/tests/child_output_late_iterator.rs +++ b/crates/perry/tests/child_output_late_iterator.rs @@ -8,6 +8,7 @@ fn standalone_regression() { .arg(root.join("scripts/test-child-output-late-iterator.mjs")) .env("PERRY_BIN", env!("CARGO_BIN_EXE_perry")) .env("PERRY_WORKSPACE_ROOT", &root) + .env("PERRY_TEST_BUILD_RUNTIME", "1") .current_dir(&root) .output() .expect("run bounded Node regression driver"); diff --git a/scripts/test-child-output-late-iterator.mjs b/scripts/test-child-output-late-iterator.mjs index a9476bbe2a..4cf400fd42 100644 --- a/scripts/test-child-output-late-iterator.mjs +++ b/scripts/test-child-output-late-iterator.mjs @@ -4,8 +4,10 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; +import { prepareRequireRuntime } from './test-require-runtime.mjs'; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +prepareRequireRuntime(root); const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry'); const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-child-late-iterator-')); const env = { ...process.env, PERRY_TEST_CHILD_EXECUTABLE: process.execPath,