diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 66c881ef4e..22c6164c10 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -1051,11 +1051,11 @@ pub(super) fn compile_module_entry( // we ret. Mirrors Node's "event loop drained → one // beforeExit pass" semantics. // - // We pass `0` as the code today: Perry doesn't yet wire - // `process.exitCode` into this codegen path, and the test - // surface in #2135 only pins the firing + the default - // code. Explicit `process.exit(N)` bypasses this whole - // block via libc::_exit. + // We still pass `0` to the `beforeExit` emit (the #2135 test + // surface only pins the firing + default code); the *process* + // status, by contrast, now consults `process.exitCode` at the + // `ret` below (#6666). Explicit `process.exit(N)` bypasses this + // whole block via libc::_exit. ctx.current_block = exit_idx; let zero_code = "0x0".to_string(); ctx.block() @@ -1080,7 +1080,15 @@ pub(super) fn compile_module_entry( "js_gc_release_current_thread_collection_side_allocations", &[], ); - ctx.block().ret(I32, "0"); + // #6666: natural exit (event loop drained / main returned with + // no explicit `process.exit()`) returns the stored + // `process.exitCode` (default 0), matching Node. An uncaught + // throw (exits 1 via `js_throw`) or an unhandled rejection + // (exits 1 via `js_promise_report_unhandled_rejections` above) + // has already terminated the process before reaching here, so + // those keep their own status and never fall through to this. + let final_exit_code = ctx.block().call(I32, "js_process_pending_exit_code", &[]); + ctx.block().ret(I32, &final_exit_code); } } let ic_globals = std::mem::take(&mut ctx.ic_globals); diff --git a/crates/perry-codegen/src/expr/string_regex_proc.rs b/crates/perry-codegen/src/expr/string_regex_proc.rs index ff83d36e1e..f06634240b 100644 --- a/crates/perry-codegen/src/expr/string_regex_proc.rs +++ b/crates/perry-codegen/src/expr/string_regex_proc.rs @@ -140,7 +140,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let code_val = if let Some(e) = code { lower_expr(ctx, e)? } else { - "0.0".to_string() + // #6666: bare `process.exit()` passes `undefined` (not `0`) so + // the runtime falls back to the stored `process.exitCode` — + // matching Node, where `process.exit()` honours a previously + // set `process.exitCode`. An explicit `process.exit(0)` still + // forces 0. + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; ctx.block() .call_void("js_process_exit", &[(DOUBLE, &code_val)]); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 91e5711e39..4738862a07 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -881,6 +881,9 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_process_emit_before_exit", VOID, &[DOUBLE]); module.declare_function("js_process_run_finalization_exit", VOID, &[]); module.declare_function("js_promise_report_unhandled_rejections", VOID, &[]); + // #6666: the natural-exit epilogue returns the stored `process.exitCode` + // (default 0) as the process status instead of a hardcoded 0. + module.declare_function("js_process_pending_exit_code", I32, &[]); module.declare_function( "js_gc_release_current_thread_collection_side_allocations", VOID, diff --git a/crates/perry-runtime/src/process/env_misc.rs b/crates/perry-runtime/src/process/env_misc.rs index fbce745054..749cfab09a 100644 --- a/crates/perry-runtime/src/process/env_misc.rs +++ b/crates/perry-runtime/src/process/env_misc.rs @@ -84,8 +84,8 @@ pub extern "C" fn js_process_add_uncaught_exception_capture_callback(callback: f #[no_mangle] pub extern "C" fn js_process_exit(code: f64) { // #3041 — match Node's `parseAndValidateExitCode`: - // * `undefined` / `null` → exit with the prior `process.exitCode` - // (0 by default here, since the validated path never stored one). + // * `undefined` / `null` → exit with the stored `process.exitCode` + // (0 when it was never set / was reset to nullish — #6666). // * number → must be a finite integer, else // RangeError [ERR_OUT_OF_RANGE] ("It must be an integer"). // * string → coerced with `Number()`; empty string or @@ -93,7 +93,14 @@ pub extern "C" fn js_process_exit(code: f64) { // TypeError [ERR_INVALID_ARG_TYPE], otherwise it is validated as a // number (so `"2.5"` → RangeError, `"2"` → exit 2). // * anything else (boolean/object/array) → TypeError. - let exit_code = validate_exit_code(code).unwrap_or_default(); + // + // `validate_exit_code` returns `None` *only* for nullish input, so a bare + // `process.exit()` falls back to `process.exitCode` while an explicit arg + // (`process.exit(0)`) overrides it — matching Node (#6666). + let exit_code = match validate_exit_code(code) { + Some(code) => code, + None => js_process_pending_exit_code(), + }; js_process_run_finalization_exit(); crate::gc::js_gc_release_current_thread_collection_side_allocations(); terminate_without_atexit(exit_code) @@ -819,21 +826,63 @@ pub extern "C" fn js_process_exit_code_get() -> f64 { f64::from_bits(bits) } -/// `process.exitCode = v`. Stores the raw NaN-boxed bits verbatim so -/// the read round-trips byte-for-byte — Node forwards e.g. the string -/// `"0"` as a string and only coerces when `process.exit()` runs. +/// `process.exitCode = v`. Node validates + coerces the value *at +/// assignment time* (`process.set [as exitCode]` → `parseAndValidateExitCode`, +/// verified against node v26): a nullish value clears the code, a string is +/// `Number()`-coerced, and a non-integer / NaN-string / wrong-type value +/// throws synchronously (RangeError [ERR_OUT_OF_RANGE] or +/// TypeError [ERR_INVALID_ARG_TYPE]). The *stored* value is the coerced +/// integer, so `process.exitCode = "2"` reads back as the number `2`. We +/// reuse the same `validate_exit_code` the `process.exit(code)` path uses +/// (#1350 / #6666). /// -/// Returns `value` so the call site can use it as the result of the -/// assignment expression (JS assignment evaluates to the RHS value). -/// That keeps the codegen path uniform with other `js_*` runtime -/// helpers that return f64 — see `lower_call/extern_func.rs:330` for -/// the direct-call path. +/// Returns `value` (the *original* RHS) so the call site uses it as the +/// assignment-expression result: JS yields the assigned value *before* the +/// setter's coercion, so `(process.exitCode = "2") === "2"`. That also keeps +/// the codegen path uniform with other `js_*` runtime helpers that return +/// f64 — see `lower_call/extern_func.rs:330` for the direct-call path. #[no_mangle] pub extern "C" fn js_process_exit_code_set(value: f64) -> f64 { - PROCESS_EXIT_CODE.with(|c| c.set(value.to_bits())); + match validate_exit_code(value) { + Some(code) => PROCESS_EXIT_CODE.with(|c| c.set(JSValue::number(code as f64).bits())), + // Nullish (`process.exitCode = null` / `undefined`) resets to the + // unset state, so natural exit falls back to 0. + None => PROCESS_EXIT_CODE.with(|c| c.set(JSValue::undefined().bits())), + } value } +/// Resolve the process's final exit status from the stored `process.exitCode`. +/// +/// Used on **natural** termination — the event loop drained and generated +/// `main` returned with no explicit `process.exit()` call — and as the +/// fallback for a bare `process.exit()` (#6666). The cell holds either +/// `undefined` (never set / reset → 0) or an already-validated integer (the +/// setter coerced it), so no re-validation is needed here. Truncating to +/// `i32` mirrors what `_exit()` does with the value; the OS then reduces it +/// to the 0-255 wait-status byte, matching Node's modulo-256 (e.g. +/// `process.exitCode = 257` → status 1, `= -1` → 255). +#[no_mangle] +pub extern "C" fn js_process_pending_exit_code() -> i32 { + let jv = JSValue::from_bits(PROCESS_EXIT_CODE.with(|c| c.get())); + if jv.is_undefined() || jv.is_null() { + return 0; + } + if jv.is_int32() { + jv.as_int32() + } else { + jv.as_number() as i32 + } +} + +// The natural-exit epilogue emits a call to `js_process_pending_exit_code` +// unconditionally into generated `_main`, but the only other caller inside the +// runtime is `js_process_exit`'s nullish fallback. Anchor the symbol so the +// auto-optimize whole-program dead-strip cannot drop it (same guard the +// unhandled-rejection reporter uses, #4876). +#[used] +static KEEP_PROCESS_PENDING_EXIT_CODE: extern "C" fn() -> i32 = js_process_pending_exit_code; + /// Set an environment variable. Backs `process.env.X = v` (#1344). /// /// Reads via `js_getenv_value` already hit `std::env::var`, so writing diff --git a/test-parity/expected-exit/exitcode-async.txt b/test-parity/expected-exit/exitcode-async.txt new file mode 100644 index 0000000000..7f8f011eb7 --- /dev/null +++ b/test-parity/expected-exit/exitcode-async.txt @@ -0,0 +1 @@ +7 diff --git a/test-parity/expected-exit/exitcode-exit-noarg.txt b/test-parity/expected-exit/exitcode-exit-noarg.txt new file mode 100644 index 0000000000..00750edc07 --- /dev/null +++ b/test-parity/expected-exit/exitcode-exit-noarg.txt @@ -0,0 +1 @@ +3 diff --git a/test-parity/expected-exit/exitcode-exit-override.txt b/test-parity/expected-exit/exitcode-exit-override.txt new file mode 100644 index 0000000000..573541ac97 --- /dev/null +++ b/test-parity/expected-exit/exitcode-exit-override.txt @@ -0,0 +1 @@ +0 diff --git a/test-parity/expected-exit/exitcode-natural.txt b/test-parity/expected-exit/exitcode-natural.txt new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/test-parity/expected-exit/exitcode-natural.txt @@ -0,0 +1 @@ +1 diff --git a/test-parity/expected-exit/exitcode-out-of-range.txt b/test-parity/expected-exit/exitcode-out-of-range.txt new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/test-parity/expected-exit/exitcode-out-of-range.txt @@ -0,0 +1 @@ +1 diff --git a/test-parity/expected-exit/exitcode-string-coerced.txt b/test-parity/expected-exit/exitcode-string-coerced.txt new file mode 100644 index 0000000000..0cfbf08886 --- /dev/null +++ b/test-parity/expected-exit/exitcode-string-coerced.txt @@ -0,0 +1 @@ +2 diff --git a/test-parity/expected-exit/exitcode-throw-precedence.txt b/test-parity/expected-exit/exitcode-throw-precedence.txt new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/test-parity/expected-exit/exitcode-throw-precedence.txt @@ -0,0 +1 @@ +1 diff --git a/test-parity/expected/exitcode-async.txt b/test-parity/expected/exitcode-async.txt new file mode 100644 index 0000000000..35740cec1a --- /dev/null +++ b/test-parity/expected/exitcode-async.txt @@ -0,0 +1 @@ +scheduled diff --git a/test-parity/expected/exitcode-exit-noarg.txt b/test-parity/expected/exitcode-exit-noarg.txt new file mode 100644 index 0000000000..587be6b4c3 --- /dev/null +++ b/test-parity/expected/exitcode-exit-noarg.txt @@ -0,0 +1 @@ +x diff --git a/test-parity/expected/exitcode-exit-override.txt b/test-parity/expected/exitcode-exit-override.txt new file mode 100644 index 0000000000..587be6b4c3 --- /dev/null +++ b/test-parity/expected/exitcode-exit-override.txt @@ -0,0 +1 @@ +x diff --git a/test-parity/expected/exitcode-natural.txt b/test-parity/expected/exitcode-natural.txt new file mode 100644 index 0000000000..19f86f493a --- /dev/null +++ b/test-parity/expected/exitcode-natural.txt @@ -0,0 +1 @@ +done diff --git a/test-parity/expected/exitcode-out-of-range.txt b/test-parity/expected/exitcode-out-of-range.txt new file mode 100644 index 0000000000..77c68bf180 --- /dev/null +++ b/test-parity/expected/exitcode-out-of-range.txt @@ -0,0 +1 @@ +stored: 257 diff --git a/test-parity/expected/exitcode-string-coerced.txt b/test-parity/expected/exitcode-string-coerced.txt new file mode 100644 index 0000000000..4b4c0eaccc --- /dev/null +++ b/test-parity/expected/exitcode-string-coerced.txt @@ -0,0 +1 @@ +type: number value: 2 diff --git a/test-parity/expected/exitcode-throw-precedence.txt b/test-parity/expected/exitcode-throw-precedence.txt new file mode 100644 index 0000000000..b56a305192 --- /dev/null +++ b/test-parity/expected/exitcode-throw-precedence.txt @@ -0,0 +1,2 @@ +before throw +Error: boom diff --git a/test-parity/node-suite/process/exit-code/exitcode-async.ts b/test-parity/node-suite/process/exit-code/exitcode-async.ts new file mode 100644 index 0000000000..cdb060391e --- /dev/null +++ b/test-parity/node-suite/process/exit-code/exitcode-async.ts @@ -0,0 +1,8 @@ +// #6666: exitCode set inside an async task that completes before the event +// loop drains. Natural exit still honours the stored code (node rc=7). +async function work() { + await Promise.resolve(); + process.exitCode = 7; +} +work(); +console.log("scheduled"); diff --git a/test-parity/node-suite/process/exit-code/exitcode-exit-noarg.ts b/test-parity/node-suite/process/exit-code/exitcode-exit-noarg.ts new file mode 100644 index 0000000000..6482959c26 --- /dev/null +++ b/test-parity/node-suite/process/exit-code/exitcode-exit-noarg.ts @@ -0,0 +1,5 @@ +// #6666: a bare process.exit() (no argument) exits with the stored +// process.exitCode rather than forcing 0 (node rc=3). +process.exitCode = 3; +console.log("x"); +process.exit(); diff --git a/test-parity/node-suite/process/exit-code/exitcode-exit-override.ts b/test-parity/node-suite/process/exit-code/exitcode-exit-override.ts new file mode 100644 index 0000000000..bea0652a3c --- /dev/null +++ b/test-parity/node-suite/process/exit-code/exitcode-exit-override.ts @@ -0,0 +1,5 @@ +// #6666: an explicit process.exit(0) overrides a previously set nonzero +// process.exitCode (node rc=0). +process.exitCode = 3; +console.log("x"); +process.exit(0); diff --git a/test-parity/node-suite/process/exit-code/exitcode-natural.ts b/test-parity/node-suite/process/exit-code/exitcode-natural.ts new file mode 100644 index 0000000000..79b0e13356 --- /dev/null +++ b/test-parity/node-suite/process/exit-code/exitcode-natural.ts @@ -0,0 +1,5 @@ +// #6666: process.exitCode set at top level with no explicit process.exit(). +// The natural-exit epilogue (event loop drained / main returned) must return +// it as the process status. Node exits 1 here; pre-fix Perry exited 0. +process.exitCode = 1; +console.log("done"); diff --git a/test-parity/node-suite/process/exit-code/exitcode-out-of-range.ts b/test-parity/node-suite/process/exit-code/exitcode-out-of-range.ts new file mode 100644 index 0000000000..1e68db06c7 --- /dev/null +++ b/test-parity/node-suite/process/exit-code/exitcode-out-of-range.ts @@ -0,0 +1,4 @@ +// #6666: an out-of-range integer exitCode is stored verbatim (getter returns +// 257) but reduced modulo 256 by the OS at exit, so the process status is 1. +process.exitCode = 257; +console.log("stored:", process.exitCode); diff --git a/test-parity/node-suite/process/exit-code/exitcode-string-coerced.ts b/test-parity/node-suite/process/exit-code/exitcode-string-coerced.ts new file mode 100644 index 0000000000..04dac6b443 --- /dev/null +++ b/test-parity/node-suite/process/exit-code/exitcode-string-coerced.ts @@ -0,0 +1,5 @@ +// #6666: node v26 coerces a string exitCode to a number at assignment time +// (`process.exitCode = "2"` reads back as the number 2), and natural exit +// uses the coerced integer (node rc=2). +process.exitCode = "2"; +console.log("type:", typeof process.exitCode, "value:", process.exitCode); diff --git a/test-parity/node-suite/process/exit-code/exitcode-throw-precedence.ts b/test-parity/node-suite/process/exit-code/exitcode-throw-precedence.ts new file mode 100644 index 0000000000..dac50d5da4 --- /dev/null +++ b/test-parity/node-suite/process/exit-code/exitcode-throw-precedence.ts @@ -0,0 +1,6 @@ +// #6666: an uncaught throw exits 1 regardless of a set process.exitCode — +// the uncaught exception takes precedence over the natural-exit code (node +// rc=1). This path terminates before the natural-exit epilogue runs. +process.exitCode = 3; +console.log("before throw"); +throw new Error("boom");