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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions changelog.d/9401-non-utf8-argv.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions changelog.d/9402-sigpipe-ignored.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 6 additions & 1 deletion crates/perry-ext-commander/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,12 @@ fn resolve_parse_args(argv: f64) -> Vec<String> {
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)`
Expand Down
71 changes: 67 additions & 4 deletions crates/perry-runtime/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/child_process/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-runtime/src/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -945,15 +945,15 @@ 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(|| {
std::env::current_exe()
.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()
}

Expand Down
6 changes: 6 additions & 0 deletions crates/perry-runtime/src/gc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-runtime/src/node_submodules/trace_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-runtime/src/os.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = std::env::args().collect();
// #9401: never `std::env::args()` — it panics on a non-UTF-8 argument.
let args: Vec<String> = 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]
Expand Down Expand Up @@ -834,6 +835,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;
Expand Down
50 changes: 50 additions & 0 deletions crates/perry-runtime/src/os/signal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,56 @@ use std::sync::{LazyLock, Mutex};
static SIGNAL_ASYNC_IDS: LazyLock<Mutex<HashMap<String, u64>>> =
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<i32> {
#[cfg(unix)]
{
Expand Down
22 changes: 21 additions & 1 deletion crates/perry-runtime/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item = String> {
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 {
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/process/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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())
Expand Down
6 changes: 3 additions & 3 deletions crates/perry-runtime/src/process/permission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,7 +20,7 @@ pub(crate) fn process_permission_enabled() -> bool {
fn process_permission_flag_values(flag: &str) -> Vec<String> {
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(
Expand Down Expand Up @@ -52,7 +52,7 @@ fn process_permission_flag_values(flag: &str) -> Vec<String> {
}

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<std::path::PathBuf> {
Expand Down
Loading
Loading