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
20 changes: 14 additions & 6 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion crates/perry-codegen/src/expr/string_regex_proc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
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)]);
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
73 changes: 61 additions & 12 deletions crates/perry-runtime/src/process/env_misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,23 @@ 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
// a non-numeric string (`Number()` → NaN) throws
// 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)
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test-parity/expected-exit/exitcode-async.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
7
1 change: 1 addition & 0 deletions test-parity/expected-exit/exitcode-exit-noarg.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3
1 change: 1 addition & 0 deletions test-parity/expected-exit/exitcode-exit-override.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0
1 change: 1 addition & 0 deletions test-parity/expected-exit/exitcode-natural.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1
1 change: 1 addition & 0 deletions test-parity/expected-exit/exitcode-out-of-range.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1
1 change: 1 addition & 0 deletions test-parity/expected-exit/exitcode-string-coerced.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2
1 change: 1 addition & 0 deletions test-parity/expected-exit/exitcode-throw-precedence.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1
1 change: 1 addition & 0 deletions test-parity/expected/exitcode-async.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
scheduled
1 change: 1 addition & 0 deletions test-parity/expected/exitcode-exit-noarg.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
x
1 change: 1 addition & 0 deletions test-parity/expected/exitcode-exit-override.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
x
1 change: 1 addition & 0 deletions test-parity/expected/exitcode-natural.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
done
1 change: 1 addition & 0 deletions test-parity/expected/exitcode-out-of-range.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
stored: 257
1 change: 1 addition & 0 deletions test-parity/expected/exitcode-string-coerced.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
type: number value: 2
2 changes: 2 additions & 0 deletions test-parity/expected/exitcode-throw-precedence.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
before throw
Error: boom
8 changes: 8 additions & 0 deletions test-parity/node-suite/process/exit-code/exitcode-async.ts
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 5 additions & 0 deletions test-parity/node-suite/process/exit-code/exitcode-natural.ts
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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");
Loading