From c3a98d8f039856fd286e44ff6d50cbb4067c8f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 1 Sep 2026 18:15:41 +0200 Subject: [PATCH 1/2] fix(runtime): ignore SIGPIPE so a truncating consumer cannot kill the program (#9402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claude auto-mode defaults | head -2` exited 141 (128 + SIGPIPE) under Perry and 0 under Node, deterministically. Every pipeline that stops reading early hit it: `| head`, `| grep -q`, `| less` then `q`, a client that closed its socket. A Perry program has its own C `main`, emitted by codegen, so it never runs Rust's `std::rt` startup — and that startup is where an ordinary Rust binary gets SIGPIPE set to SIG_IGN. The compiled program therefore inherited the signal's default disposition and died mid-write, with no JS-visible event and nothing to catch. Node ignores the signal and lets the failing write(2) return EPIPE to the writer instead. `ignore_sigpipe_at_startup()` installs SIG_IGN once per process and only over SIG_DFL, so an embedder's own disposition and a later `process.on('SIGPIPE')` are both untouched. It is called from `js_gc_init`, the first runtime call of every `main` / `perry_module_init`, so every compiled program gets it before a byte can be written. Unix only: Windows has no SIGPIPE. Ignoring the signal alone would have traded exit 141 for exit 134 — `std`'s `println!` turns the resulting EPIPE into a panic and Perry builds with `panic = "abort"`. Node's console is specified never to throw, so the `console.*` family's print macros are shadowed with writers that drop the write error. The shadowing is confined to the `builtins` tree, alongside the existing harmonyos hilog override; diagnostics elsewhere keep `std`'s macros. `fs.writeSync(1, …)` to a closed pipe now throws EPIPE, matching Node — write errors still reach JavaScript rather than being swallowed. test-files/test_gap_9402_sigpipe_truncating_consumer.ts re-runs itself through bash, pipes 50000 lines into `head -2` and reports the WRITER's status. Byte-compared against node 26.5.1: node `writer-status=0`; a compiler built from unfixed origin/main reports `writer-status=141`; with this change, identical to node. --- changelog.d/9402-sigpipe-ignored.md | 45 ++++++++++++ crates/perry-runtime/src/builtins/mod.rs | 71 +++++++++++++++++-- crates/perry-runtime/src/gc/mod.rs | 6 ++ crates/perry-runtime/src/os.rs | 1 + crates/perry-runtime/src/os/signal.rs | 50 +++++++++++++ ...st_gap_9402_sigpipe_truncating_consumer.ts | 34 +++++++++ 6 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 changelog.d/9402-sigpipe-ignored.md create mode 100644 test-files/test_gap_9402_sigpipe_truncating_consumer.ts diff --git a/changelog.d/9402-sigpipe-ignored.md b/changelog.d/9402-sigpipe-ignored.md new file mode 100644 index 0000000000..b108507c92 --- /dev/null +++ b/changelog.d/9402-sigpipe-ignored.md @@ -0,0 +1,45 @@ +### Fixed + +- **A truncating consumer no longer kills a compiled program.** + `claude auto-mode defaults | head -2` exited **141** (128 + SIGPIPE) under + Perry and 0 under node — deterministically, 3 runs out of 3. Every pipeline + that stops reading early hit it: `| head`, `| grep -q`, `| less` followed by + `q`, a client that closed its socket. + + The cause is structural rather than a mistake in any one function. A Perry + program has its **own C `main`**, emitted by codegen, so it never runs Rust's + `std::rt` startup — and that startup is where an ordinary Rust binary gets + `SIGPIPE` set to `SIG_IGN`. A compiled program therefore inherited the + signal's default disposition and died mid-write, with no JavaScript-visible + event and nothing to catch. Node (through libuv) ignores the signal and lets + the failing `write(2)` return `EPIPE` to the writer instead. + + - `crates/perry-runtime/src/os/signal.rs` — `ignore_sigpipe_at_startup()` + installs `SIG_IGN`, once per process, and only over `SIG_DFL`, so an + embedder's own disposition and a later `process.on('SIGPIPE', …)` are both + left alone. Unix only: Windows has no `SIGPIPE`. + - `crates/perry-runtime/src/gc/mod.rs` — called from `js_gc_init`, which is + the first runtime call of every `main` / `perry_module_init`, so every + compiled program gets it before a byte can be written. + + Ignoring the signal alone would have traded exit 141 for exit **134**: + `std`'s `println!` turns the resulting `EPIPE` into a panic, and Perry builds + with `panic = "abort"`. Node's console is specified never to throw + (`node -e 'for(;;) console.log(1)' | head -2` exits 0), so: + + - `crates/perry-runtime/src/builtins/mod.rs` — the `console.*` family's + `println!` / `print!` / `eprintln!` are shadowed with writers that drop the + write error, which is exactly that contract. The shadowing is confined to + the `builtins` tree, alongside the pre-existing harmonyos hilog override; + diagnostics elsewhere in the runtime keep `std`'s macros. + + Validation: `test-files/test_gap_9402_sigpipe_truncating_consumer.ts` + re-runs itself through `bash`, pipes 50 000 lines into `head -2`, and reports + the **writer's** status. Byte-compared against node 26.5.1: node + `writer-status=0`, Perry built from unfixed `origin/main` `writer-status=141`, + Perry with this change `writer-status=0`. + + Known remaining gap, not addressed here: `process.stdout.write` **swallows** + the `EPIPE` (`os_process_streams.rs` has always discarded the write result), + where node emits an `'error'` event on the stream and exits 1 if it is + unhandled. That is a stream-plumbing change, not a signal one. diff --git a/crates/perry-runtime/src/builtins/mod.rs b/crates/perry-runtime/src/builtins/mod.rs index a902e9dc8f..eeedb79b71 100644 --- a/crates/perry-runtime/src/builtins/mod.rs +++ b/crates/perry-runtime/src/builtins/mod.rs @@ -28,10 +28,73 @@ macro_rules! println { }; } -// Make the override visible to the topical submodules below via `use -// super::println;` (each submodule that prints needs the same shadow so -// stdout output on harmonyos routes through hilog the same way it did -// pre-split). +// #9402: with SIGPIPE ignored, a truncated consumer no longer KILLS the +// writer — the failing `write(2)` returns `EPIPE` instead. `std`'s `println!` +// turns that error into a panic ("failed printing to stdout"), and Perry +// builds with `panic = "abort"`, so `prog | head -2` would have traded exit +// 141 for exit 134. Node's console is specified never to throw: it writes +// through a stream whose errors it ignores, and `node -e 'for(;;) +// console.log(1)' | head -2` exits 0. Shadow the print macros for the +// user-facing `console.*` family with writers that drop the error, which is +// exactly that contract. Diagnostics elsewhere in the runtime keep `std`'s +// macros. +#[cfg(not(feature = "ohos-napi"))] +pub(crate) fn console_write_line(args: std::fmt::Arguments<'_>) { + use std::io::Write; + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = handle.write_fmt(args); + let _ = handle.write_all(b"\n"); +} + +#[cfg(not(feature = "ohos-napi"))] +pub(crate) fn console_write_fragment(args: std::fmt::Arguments<'_>) { + use std::io::Write; + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = handle.write_fmt(args); +} + +#[cfg(not(feature = "ohos-napi"))] +pub(crate) fn console_write_err_line(args: std::fmt::Arguments<'_>) { + use std::io::Write; + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_fmt(args); + let _ = handle.write_all(b"\n"); +} + +#[cfg(not(feature = "ohos-napi"))] +macro_rules! println { + () => { + $crate::builtins::console_write_line(format_args!("")) + }; + ($($arg:tt)*) => { + $crate::builtins::console_write_line(format_args!($($arg)*)) + }; +} + +#[cfg(not(feature = "ohos-napi"))] +macro_rules! print { + ($($arg:tt)*) => { + $crate::builtins::console_write_fragment(format_args!($($arg)*)) + }; +} + +#[cfg(not(feature = "ohos-napi"))] +macro_rules! eprintln { + () => { + $crate::builtins::console_write_err_line(format_args!("")) + }; + ($($arg:tt)*) => { + $crate::builtins::console_write_err_line(format_args!($($arg)*)) + }; +} + +// `macro_rules!` textual scope already reaches the topical submodules declared +// below, so every `console.*` printer in this tree picks up the shadows above. +// The harmonyos build additionally re-exports `println` because its submodules +// name it explicitly (`use super::println;`). #[cfg(feature = "ohos-napi")] pub(crate) use println; diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 8d7e471097..ea794f7e55 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1165,6 +1165,12 @@ pub extern "C" fn js_gc_init() { // call lands. A host that loads several application images on several // threads gets one image per thread; a plain executable gets one. crate::object::class_image::enter_current_thread_image(); + // #9402: a compiled program has its own `main` and never runs Rust's + // `std::rt` startup, so SIGPIPE arrives with its DEFAULT disposition and + // any truncating consumer (`| head`, `| grep -q`, a closed socket) kills + // the writer mid-write. Node ignores the signal and surfaces `EPIPE` to + // the writer instead; do the same before a single byte can be written. + crate::os::ignore_sigpipe_at_startup(); // Prime the process wall-clock epoch NOW: it lazily initializes on first // read, and the first read is otherwise whatever GC event happens to // consult it — which would make the exit-time GC-share denominator start diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index ff8c6fd0c5..700babe28d 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -834,6 +834,7 @@ pub use chdir::js_process_chdir; // Signal normalization is shared with `util.convertProcessSignalToExitCode`. mod signal; pub use signal::{js_process_kill, js_util_convert_process_signal_to_exit_code}; +pub(crate) use signal::ignore_sigpipe_at_startup; #[path = "os_process_streams.rs"] mod process_streams; diff --git a/crates/perry-runtime/src/os/signal.rs b/crates/perry-runtime/src/os/signal.rs index b2f1becdca..925eb7894c 100644 --- a/crates/perry-runtime/src/os/signal.rs +++ b/crates/perry-runtime/src/os/signal.rs @@ -11,6 +11,56 @@ use std::sync::{LazyLock, Mutex}; static SIGNAL_ASYNC_IDS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +/// Ignore `SIGPIPE` for the life of the process (#9402). +/// +/// A Perry program has its own C `main`, emitted by codegen — it never runs +/// Rust's `std::rt` startup, which is where an ordinary Rust binary gets +/// `SIGPIPE` set to `SIG_IGN`. A compiled program therefore inherited the +/// signal's DEFAULT disposition, so **any** truncating consumer killed it +/// mid-write: `| head`, `| grep -q`, `| less` followed by `q`, a client that +/// closed its socket. `claude auto-mode defaults | head -2` exited 141 under +/// Perry and 0 under Node. +/// +/// Node (through libuv) does exactly this: the signal is ignored and the +/// failing `write(2)` returns `EPIPE` to the writer, which is a value the +/// program can see and act on rather than a death it cannot. +/// +/// Idempotent and safe from any thread — `js_gc_init` calls it once per image +/// entry, and a second entry on another thread re-applies the same +/// disposition. A `SIGPIPE` disposition an embedder installed BEFORE loading +/// the runtime is left alone: only `SIG_DFL` (the state that kills us) is +/// replaced. `process.on('SIGPIPE', …)` still installs its own handler +/// afterwards through [`install_process_signal_handler`], exactly as it did. +/// +/// Not inherited by children: Rust's `Command` restores `SIGPIPE` to +/// `SIG_DFL` in the forked child before `exec`, matching Node. +#[cfg(unix)] +pub(crate) fn ignore_sigpipe_at_startup() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| unsafe { + // SAFETY: a query (`act` = NULL) followed by a plain disposition + // install. `sigaction` is async-signal-safe and touches no Perry state. + let mut current: libc::sigaction = std::mem::zeroed(); + if libc::sigaction(libc::SIGPIPE, std::ptr::null(), &mut current) != 0 { + return; + } + if current.sa_sigaction != libc::SIG_DFL { + return; + } + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = libc::SIG_IGN; + sa.sa_flags = 0; + libc::sigemptyset(&mut sa.sa_mask); + libc::sigaction(libc::SIGPIPE, &sa, std::ptr::null_mut()); + }); +} + +/// Windows has no `SIGPIPE`: a write to a closed pipe already fails with +/// `ERROR_BROKEN_PIPE` instead of raising a signal. +#[cfg(not(unix))] +pub(crate) fn ignore_sigpipe_at_startup() {} + fn signal_number_by_name(name: &str) -> Option { #[cfg(unix)] { diff --git a/test-files/test_gap_9402_sigpipe_truncating_consumer.ts b/test-files/test_gap_9402_sigpipe_truncating_consumer.ts new file mode 100644 index 0000000000..112e027dc1 --- /dev/null +++ b/test-files/test_gap_9402_sigpipe_truncating_consumer.ts @@ -0,0 +1,34 @@ +// #9402: a truncating consumer (`| head -2`, `| grep -q`, `| less` + quit, a +// closed socket) must not kill the process. Node ignores SIGPIPE and lets the +// failing write surface as EPIPE; Perry inherited SIGPIPE's DEFAULT +// disposition, because a compiled program has its own C `main` and never runs +// Rust's `std::rt` startup (which is what installs SIG_IGN for a normal Rust +// binary). Every truncated pipe therefore killed the writer with signal 13. +// +// The witness spawns THIS program again with a marker argument, pipes it into +// `head -2`, and reports the WRITER's exit status (not the pipeline's). +// Pre-fix Perry reports 141 (128 + SIGPIPE); Node reports 0. +import { spawnSync } from "node:child_process"; + +const MARKER = "--emit-many-lines"; + +if (process.argv.indexOf(MARKER) >= 0) { + // Well past a 64 KiB pipe buffer, so `head -2` is guaranteed to have closed + // the read end long before the last line is written. + for (let i = 0; i < 50000; i++) { + console.log("line " + i); + } + console.log("writer finished"); +} else { + // `$1` is the script path. Node needs it on the command line; a compiled + // Perry binary does not, but accepts it as an ignored positional argument — + // the marker is located with indexOf, not by position, so both runtimes read + // the same value. + const script = + '"$0" "$1" ' + MARKER + ' | head -2 >/dev/null; echo "writer-status=${PIPESTATUS[0]}"'; + const child = spawnSync("/bin/bash", ["-c", script, process.argv[0], process.argv[1]], { + encoding: "utf8", + }); + process.stdout.write(child.stdout ?? ""); + console.log("shell-status:", child.status); +} From a3138c1b2dc6cc1d2f63cc573abcea8069956ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 1 Sep 2026 18:16:00 +0200 Subject: [PATCH 2/2] fix(runtime): a non-UTF-8 argv byte must not abort the process (#9401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claude -p $'\xff\xfe\x80abc\xc3\x28'` died with SIGABRT and a raw Rust backtrace — "panicked at library/std/src/env.rs:878:51: called `Result::unwrap()` on an `Err` value" — where Node prints the program's own output. `std::env::args()` panics on an argument that is not valid Unicode, and non-UTF-8 filenames are ordinary on Linux, so anything that passes a path through reached it. Node decodes argv leniently: every invalid byte becomes U+FFFD. Verified against node 26.5.1 — `$'\xff\xfe\x80abc\xc3\x28'` arrives as the eight code points fffd fffd fffd 61 62 63 fffd 28, byte-for-byte `String::from_utf8_lossy`. One `process_args_lossy()` over `std::env::args_os()` now backs every argv reader in the runtime, so a single bad byte cannot resurrect the abort in a path nobody thought to check. There were NINE, all reachable, and the panic was not confined to `process.argv`: - os.rs `js_process_argv` — `process.argv` - node_submodules/trace_events.rs — reads argv from `js_gc_init`, so the process died before a line of JavaScript ran, whatever the program did - process/permission.rs (x3) — the permission-model flag scan - process/report.rs (x2) — `process.report` - process/attributes.rs — `process.title` - cluster.rs (x2) — cluster exec-path defaulting - child_process/options.rs — self-launch detection in `spawn` - process.rs `process_argv0_string` — `process.argv0` / `execPath` Three more outside the runtime, same shape, same fix: perry-stdlib and perry-ext-commander (`program.parse()` with no explicit argv), and the compiler CLI's own arguments in perry/src/{main,update_policy}.rs, so `perry compile` on a non-UTF-8 path reports a diagnostic instead of a backtrace. `std::env::var()` needs no equivalent change: it returns Err for a non-Unicode value rather than panicking, and the runtime has no `env::var(..).unwrap()`. test-files/test_gap_9401_non_utf8_argv.ts re-runs itself through `sh` (which is byte-oriented, so it can build an argument the source file cannot contain) and prints the decoded length, code points and UTF-8 bytes. Byte-compared against node 26.5.1: a compiler built from unfixed origin/main reports `child-status: null / child-signal: SIGABRT`; with this change, identical to node. Not touched, same shape, reported rather than changed: perry-ui-gtk4 src/tray.rs, perry-ui-macos src/app.rs, perry-ui src/bin/styling-matrix.rs. --- changelog.d/9401-non-utf8-argv.md | 61 +++++++++++++++++++ crates/perry-ext-commander/src/lib.rs | 7 ++- .../src/child_process/options.rs | 2 +- crates/perry-runtime/src/cluster.rs | 4 +- .../src/node_submodules/trace_events.rs | 4 +- crates/perry-runtime/src/os.rs | 3 +- crates/perry-runtime/src/process.rs | 22 ++++++- .../perry-runtime/src/process/attributes.rs | 2 +- .../perry-runtime/src/process/permission.rs | 6 +- crates/perry-runtime/src/process/report.rs | 4 +- crates/perry-stdlib/src/commander.rs | 7 ++- crates/perry/src/main.rs | 15 ++++- crates/perry/src/update_policy.rs | 6 +- test-files/test_gap_9401_non_utf8_argv.ts | 42 +++++++++++++ 14 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 changelog.d/9401-non-utf8-argv.md create mode 100644 test-files/test_gap_9401_non_utf8_argv.ts diff --git a/changelog.d/9401-non-utf8-argv.md b/changelog.d/9401-non-utf8-argv.md new file mode 100644 index 0000000000..ddf1f8f40d --- /dev/null +++ b/changelog.d/9401-non-utf8-argv.md @@ -0,0 +1,61 @@ +### Fixed + +- **A non-UTF-8 byte in `argv` no longer aborts the process.** + `claude -p $'\xff\xfe\x80abc\xc3\x28'` died with **SIGABRT** and a raw Rust + backtrace — + + ``` + panicked at library/std/src/env.rs:878:51: + called `Result::unwrap()` on an `Err` value: "\xFF\xFE\x80abc\xC3(" + ``` + + — where node prints the program's own output. `std::env::args()` panics on an + argument that is not valid Unicode, and non-UTF-8 filenames are ordinary on + Linux, so this was trivially reachable by anything that passes a path + through. + + Node decodes `argv` leniently: every invalid byte becomes U+FFFD. Verified + against node 26.5.1 — `$'\xff\xfe\x80abc\xc3\x28'` arrives as the eight code + points `fffd fffd fffd 61 62 63 fffd 28`, which is byte-for-byte + `String::from_utf8_lossy`. + + - `crates/perry-runtime/src/process.rs` — one `process_args_lossy()` over + `std::env::args_os()`, so a single bad byte cannot resurrect the abort in + a path nobody thought to check. + + Every `std::env::args()` reader in the runtime now goes through it. There + were **nine**, all reachable, and the panic was not confined to + `process.argv`: + + - `os.rs` `js_process_argv` — `process.argv`; + - `node_submodules/trace_events.rs` — reads `argv` from **`js_gc_init`**, so + the process died before a line of JavaScript ran, whatever the program did; + - `process/permission.rs` (×3) — the permission-model flag scan; + - `process/report.rs` (×2) — `process.report`; + - `process/attributes.rs` — `process.title`; + - `cluster.rs` (×2) — `cluster` exec-path defaulting; + - `child_process/options.rs` — self-launch detection in `spawn`; + - `process.rs` `process_argv0_string` — `process.argv0` / `execPath`. + + Three more outside the runtime, same shape, same fix: + + - `crates/perry-stdlib/src/commander.rs` and + `crates/perry-ext-commander/src/lib.rs` — `program.parse()` with no + explicit argv; + - `crates/perry/src/main.rs` and `crates/perry/src/update_policy.rs` — the + compiler CLI's own arguments, so `perry compile` on a non-UTF-8 path + reports a diagnostic instead of a backtrace. + + Not touched (UI crates, out of this change's scope): `perry-ui-gtk4` + `src/tray.rs`, `perry-ui-macos` `src/app.rs`, `perry-ui` `src/bin/styling-matrix.rs`. + + `std::env::var()` needs no equivalent change: it *returns* `Err` for a + non-Unicode value rather than panicking, and the runtime has no + `env::var(..).unwrap()`. + + Validation: `test-files/test_gap_9401_non_utf8_argv.ts` re-runs itself + through `sh` (which is byte-oriented, so it can build an argument the source + file cannot contain) and prints the decoded length, code points and UTF-8 + bytes. Byte-compared against node 26.5.1; Perry built from unfixed + `origin/main` reports `child-status: null / child-signal: SIGABRT`, and with + this change is identical to node. diff --git a/crates/perry-ext-commander/src/lib.rs b/crates/perry-ext-commander/src/lib.rs index 8dec1b59ec..ba6eede278 100644 --- a/crates/perry-ext-commander/src/lib.rs +++ b/crates/perry-ext-commander/src/lib.rs @@ -290,7 +290,12 @@ fn resolve_parse_args(argv: f64) -> Vec { return out.into_iter().skip(2).collect(); } } - std::env::args().skip(1).collect() + // #9401: `std::env::args()` panics on a non-UTF-8 argument; Node decodes + // argv leniently (every invalid byte becomes U+FFFD) and so must this. + std::env::args_os() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() } /// Top-level parse entry. The second arg is the user's `parse(argv)` diff --git a/crates/perry-runtime/src/child_process/options.rs b/crates/perry-runtime/src/child_process/options.rs index 6b67ea4ab3..41fe9cf91a 100644 --- a/crates/perry-runtime/src/child_process/options.rs +++ b/crates/perry-runtime/src/child_process/options.rs @@ -470,7 +470,7 @@ pub(crate) fn cp_command_for_program(program: &str, opts_val: f64) -> Command { /// Whether a self-launch uses a Node CLI mode that evaluates source text. fn cp_should_use_node_interpreter(cmd: &str, args: &[String]) -> bool { - let is_self = std::env::args().next().as_deref() == Some(cmd) + let is_self = crate::process::process_args_lossy().next().as_deref() == Some(cmd) || std::env::current_exe().is_ok_and(|current| current == std::path::Path::new(cmd)); is_self && args diff --git a/crates/perry-runtime/src/cluster.rs b/crates/perry-runtime/src/cluster.rs index 6f9636f37d..aa4284e025 100644 --- a/crates/perry-runtime/src/cluster.rs +++ b/crates/perry-runtime/src/cluster.rs @@ -945,7 +945,7 @@ fn default_args_array_value() -> f64 { } fn default_exec_path() -> String { - std::env::args() + crate::process::process_args_lossy() .nth(1) .filter(|s| !s.is_empty()) .or_else(|| { @@ -953,7 +953,7 @@ fn default_exec_path() -> String { .ok() .map(|p| p.to_string_lossy().into_owned()) }) - .or_else(|| std::env::args().next()) + .or_else(|| crate::process::process_args_lossy().next()) .unwrap_or_default() } diff --git a/crates/perry-runtime/src/node_submodules/trace_events.rs b/crates/perry-runtime/src/node_submodules/trace_events.rs index e92d718bb6..9b7bfc0e26 100644 --- a/crates/perry-runtime/src/node_submodules/trace_events.rs +++ b/crates/perry-runtime/src/node_submodules/trace_events.rs @@ -542,7 +542,9 @@ pub(crate) fn init_trace_events_runtime() { if slot.borrow().initialized { return; } - let mut output = trace_options_from_args(std::env::args()); + // #9401: this runs from `js_gc_init`, so a raw `std::env::args()` + // aborted the process on a non-UTF-8 argument before any JS ran. + let mut output = trace_options_from_args(crate::process::process_args_lossy()); seed_legacy_categories(&mut output); output.initialized = true; TRACE_ENABLED_COUNTS.with(|counts| { diff --git a/crates/perry-runtime/src/os.rs b/crates/perry-runtime/src/os.rs index 700babe28d..5f3f91619f 100644 --- a/crates/perry-runtime/src/os.rs +++ b/crates/perry-runtime/src/os.rs @@ -583,7 +583,8 @@ pub extern "C" fn js_process_argv() -> *mut ArrayHeader { use crate::array::{js_array_alloc, js_array_push_f64}; use crate::value::js_nanbox_string; - let args: Vec = std::env::args().collect(); + // #9401: never `std::env::args()` — it panics on a non-UTF-8 argument. + let args: Vec = crate::process::process_args_lossy().collect(); // Match Node.js behavior: argv[0] = binary path (like node path), // argv[1] = source entry path (like script path), argv[2+] = user args. // Node.js: ["/usr/bin/node", "/path/to/script.js", ...user_args] diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index 995d40da8c..62a472b46f 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -396,8 +396,28 @@ pub(crate) fn module_set_value(items: &[&str]) -> f64 { crate::value::js_nanbox_pointer(set as i64) } +/// The process's arguments, decoded the way Node decodes them: every byte that +/// is not valid UTF-8 becomes U+FFFD (#9401). +/// +/// `std::env::args()` PANICS on an argument that is not valid Unicode +/// (`library/std/src/env.rs`: `called Result::unwrap() on an Err value`), and +/// with `panic = "abort"` that is a SIGABRT with a raw Rust backtrace where +/// Node prints the program's own output. Non-UTF-8 filenames are ordinary on +/// Linux, so `prog $'\xff\xfe\x80abc'` reached it trivially — and +/// `init_trace_events_runtime` reads argv from `js_gc_init`, so the process +/// died before a line of JavaScript ran. +/// +/// Node's decoding is byte-for-byte `String::from_utf8_lossy`: verified +/// against Node 26.5.1, `$'\xff\xfe\x80abc\xc3\x28'` arrives as the eight +/// code points `fffd fffd fffd 61 62 63 fffd 28`. Every argv reader in the +/// runtime goes through here so one bad byte cannot resurrect the abort in a +/// path nobody thought to check. +pub(crate) fn process_args_lossy() -> impl Iterator { + std::env::args_os().map(|arg| arg.to_string_lossy().into_owned()) +} + pub(crate) fn process_argv0_string() -> String { - std::env::args().next().unwrap_or_default() + process_args_lossy().next().unwrap_or_default() } pub(crate) fn node_arch_name() -> &'static str { diff --git a/crates/perry-runtime/src/process/attributes.rs b/crates/perry-runtime/src/process/attributes.rs index 9ba0f319c1..2f62761abb 100644 --- a/crates/perry-runtime/src/process/attributes.rs +++ b/crates/perry-runtime/src/process/attributes.rs @@ -109,7 +109,7 @@ pub extern "C" fn js_process_resource_usage() -> f64 { pub extern "C" fn js_process_title() -> f64 { use crate::value::JSValue; let stored: Option = PROCESS_TITLE.with(|c| c.borrow().clone()); - let s = stored.unwrap_or_else(|| std::env::args().next().unwrap_or_default()); + let s = stored.unwrap_or_else(super::process_argv0_string); let bytes = s.as_bytes(); let ptr = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); f64::from_bits(JSValue::string_ptr(ptr).bits()) diff --git a/crates/perry-runtime/src/process/permission.rs b/crates/perry-runtime/src/process/permission.rs index be0e5174e7..e8b2fb5eb6 100644 --- a/crates/perry-runtime/src/process/permission.rs +++ b/crates/perry-runtime/src/process/permission.rs @@ -7,7 +7,7 @@ use crate::value::JSValue; pub(crate) fn process_permission_enabled() -> bool { let mut enabled = false; - for arg in std::env::args().skip(1) { + for arg in super::process_args_lossy().skip(1) { match arg.as_str() { "--permission" => enabled = true, "--no-permission" => enabled = false, @@ -20,7 +20,7 @@ pub(crate) fn process_permission_enabled() -> bool { fn process_permission_flag_values(flag: &str) -> Vec { let mut values = Vec::new(); let prefix = format!("{flag}="); - let mut args = std::env::args().skip(1).peekable(); + let mut args = super::process_args_lossy().skip(1).peekable(); while let Some(arg) = args.next() { if let Some(value) = arg.strip_prefix(&prefix) { values.extend( @@ -52,7 +52,7 @@ fn process_permission_flag_values(flag: &str) -> Vec { } fn process_permission_has_flag(flag: &str) -> bool { - std::env::args().skip(1).any(|arg| arg == flag) + super::process_args_lossy().skip(1).any(|arg| arg == flag) } fn permission_canonical_path(path: &str) -> Option { diff --git a/crates/perry-runtime/src/process/report.rs b/crates/perry-runtime/src/process/report.rs index a6f1290407..e0e995b59c 100644 --- a/crates/perry-runtime/src/process/report.rs +++ b/crates/perry-runtime/src/process/report.rs @@ -313,7 +313,7 @@ fn process_report_user_limits_object() -> f64 { } fn process_report_command_line_array() -> f64 { - let args: Vec = std::env::args().collect(); + let args: Vec = super::process_args_lossy().collect(); let items = if args.is_empty() { vec![process_argv0_string()] } else { @@ -344,7 +344,7 @@ fn process_report_unix_time_ms() -> f64 { #[cfg(feature = "diagnostics")] fn process_report_json_string(trigger: &str, filename: Option<&str>) -> String { - let args: Vec = std::env::args().collect(); + let args: Vec = super::process_args_lossy().collect(); let command_line = if args.is_empty() { vec![process_argv0_string()] } else { diff --git a/crates/perry-stdlib/src/commander.rs b/crates/perry-stdlib/src/commander.rs index 8e94b6edc0..8be1558a61 100644 --- a/crates/perry-stdlib/src/commander.rs +++ b/crates/perry-stdlib/src/commander.rs @@ -318,7 +318,12 @@ unsafe fn resolve_parse_args(argv: f64) -> Vec { return out.into_iter().skip(2).collect(); } } - std::env::args().skip(1).collect() + // #9401: `std::env::args()` panics on a non-UTF-8 argument; Node decodes + // argv leniently (every invalid byte becomes U+FFFD) and so must this. + std::env::args_os() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() } /// Top-level parse entry. The second arg is the user's `parse(argv)` diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index 80ec75f1d8..f900d5ee4b 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -359,11 +359,22 @@ fn install_panic_hook() { })); } +/// The CLI's own arguments, decoded leniently (#9401). +/// +/// `std::env::args()` panics on an argument that is not valid Unicode, so +/// `perry compile $'\xff'.ts` aborted with a raw Rust backtrace instead of a +/// diagnostic. A non-UTF-8 path is an ordinary Linux filename. +fn argv_lossy() -> Vec { + std::env::args_os() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() +} + fn main_inner() -> Result<()> { env_logger::init(); #[cfg(windows)] { - let raw_args: Vec = std::env::args().collect(); + let raw_args: Vec = argv_lossy(); if let Some(result) = update_checker::maybe_run_windows_update_helper(&raw_args) { return result; } @@ -371,7 +382,7 @@ fn main_inner() -> Result<()> { update_checker::recover_interrupted_self_update()?; // Handle legacy invocation (perry file.ts -o out) - let args: Vec = std::env::args().collect(); + let args: Vec = argv_lossy(); let effective_args = if is_legacy_invocation(&args) { transform_legacy_args(args) } else { diff --git a/crates/perry/src/update_policy.rs b/crates/perry/src/update_policy.rs index 79012afc73..bc58c4360b 100644 --- a/crates/perry/src/update_policy.rs +++ b/crates/perry/src/update_policy.rs @@ -326,7 +326,11 @@ impl UpdatePolicy { /// because the policy is resolved before dispatch and this is the one input /// that has to be right on the very first line of output. fn structured_output_selected() -> bool { - let mut args = std::env::args().skip(1); + // #9401: a non-UTF-8 argument must not abort the CLI before it can + // report anything. + let mut args = std::env::args_os() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()); while let Some(arg) = args.next() { if let Some(value) = arg.strip_prefix("--format=") { return !value.eq_ignore_ascii_case("text"); diff --git a/test-files/test_gap_9401_non_utf8_argv.ts b/test-files/test_gap_9401_non_utf8_argv.ts new file mode 100644 index 0000000000..de240bb23a --- /dev/null +++ b/test-files/test_gap_9401_non_utf8_argv.ts @@ -0,0 +1,42 @@ +// #9401: a non-UTF-8 byte in argv must not abort the process. +// +// `std::env::args()` PANICS on an argument that is not valid Unicode, and +// `js_process_argv` collected through it — so `claude -p $'\xff\xfe\x80abc\xc3\x28'` +// died with SIGABRT and a raw Rust backtrace +// (`library/std/src/env.rs: called Result::unwrap() on an Err value`). +// Non-UTF-8 filenames are ordinary on Linux, so this is trivially reachable. +// Node decodes argv leniently: every invalid byte becomes U+FFFD. +// +// The witness re-runs THIS program through `sh`, which is byte-oriented and +// can therefore construct an argument the source file itself cannot contain. +import { spawnSync } from "node:child_process"; + +const MARKER = "--decode-argv"; +const at = process.argv.indexOf(MARKER); + +if (at >= 0) { + const bad = process.argv[at + 1]; + console.log("typeof:", typeof bad); + console.log("length:", bad.length); + console.log( + "codepoints:", + Array.from(bad) + .map((c) => c.codePointAt(0)!.toString(16)) + .join(","), + ); + console.log("utf8-hex:", Buffer.from(bad, "utf8").toString("hex")); + console.log("next-arg:", process.argv[at + 2]); +} else { + // \377\376\200 abc \303 \050 — three lone invalid bytes, ASCII "abc", a + // truncated 2-byte lead byte, then '('. + const script = + 'exec "$0" "$1" ' + + MARKER + + ' "$(printf \'\\377\\376\\200abc\\303\\050\')" tail-marker'; + const child = spawnSync("/bin/sh", ["-c", script, process.argv[0], process.argv[1]], { + encoding: "utf8", + }); + process.stdout.write(child.stdout ?? ""); + console.log("child-status:", child.status); + console.log("child-signal:", child.signal); +}