From e3a42c52a81eba1e2de088285416405f9c72a1f7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 16:00:39 -0500 Subject: [PATCH] Ignore SIGPIPE and SIGXFSZ in executables, as Node does Node sets SIGPIPE and SIGXFSZ to SIG_IGN at startup, so a write to a closed pipe or past RLIMIT_FSIZE fails with EPIPE or EFBIG, and libuv's spawns start children with every signal at its default action. The runtime instead blocked both signals in the writing thread. On Linux the signal a write raises goes to that thread, but on macOS it goes to the process, so a write to a closed pipe from the fs pool's threads ended the program with SIGPIPE. - scr_init ignores SIGPIPE and SIGXFSZ. A process.on listener replaces the disposition while it is registered, as before. - scr_fs_write_once writes directly in executables, and the fs pool's threads no longer block the two signals. Library builds keep the per-thread blocking, since their host owns the dispositions. - scr_spawn_command starts children with SIGPIPE and SIGXFSZ at their default actions (POSIX_SPAWN_SETSIGDEF), for spawnSync, execFileSync and spawn alike. write-signals-ignored.ts and write-signals-listener.mjs check the ignored signals, a listener, and each spawn path's child against Node. --- .../corpus/write-signals-ignored.ts.json | 6 ++++ .../corpus/write-signals-listener.mjs.json | 6 ++++ packages/runtime/src/scr_async.c | 8 +++-- packages/runtime/src/scr_child.c | 32 ++++++++++++++++--- packages/runtime/src/scr_console.c | 12 +++++++ packages/runtime/src/scr_lib.c | 11 ++++--- tests/corpus/write-signals-ignored.ts | 30 +++++++++++++++++ tests/corpus/write-signals-listener.mjs | 12 +++++++ .../write-signals-ignored.ts.json | 1 + .../write-signals-listener.mjs.json | 1 + tests/harness/windows-differential.test.ts | 3 ++ 11 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json create mode 100644 tests/corpus/write-signals-ignored.ts create mode 100644 tests/corpus/write-signals-listener.mjs create mode 100644 tests/harness/corpus-costs/write-signals-ignored.ts.json create mode 100644 tests/harness/corpus-costs/write-signals-listener.mjs.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json new file mode 100644 index 000000000..7712a33bd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-ignored.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/write-signals-ignored.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json new file mode 100644 index 000000000..090e24684 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/write-signals-listener.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/write-signals-listener.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index c82cb9052..43b38e77a 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3764,9 +3764,10 @@ static DWORD WINAPI scr_fs_rename_worker(LPVOID payload) { #else static void *scr_fs_rename_worker(void *payload) { (void)payload; - /* A pool thread keeps SIGPIPE and SIGXFSZ blocked: its file writes then - * report EPIPE and EFBIG without changing the signal mask per write - * (scr_fs_write_once). */ +#ifdef SCR_LIB + /* In a library, a pool thread keeps SIGPIPE and SIGXFSZ blocked: its file + * writes then report EPIPE and EFBIG without changing the signal mask per + * write (scr_fs_write_once). Executables ignore both signals (scr_init). */ sigset_t write_set; sigemptyset(&write_set); #ifdef SIGPIPE @@ -3776,6 +3777,7 @@ static void *scr_fs_rename_worker(void *payload) { sigaddset(&write_set, SIGXFSZ); #endif (void)pthread_sigmask(SIG_BLOCK, &write_set, NULL); +#endif scr_fs_rename_worker_loop(); return NULL; } diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 93169be80..324faf6a7 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -3208,10 +3208,9 @@ static void scr_exec_envp_free(char **envp); * next entry. The last such failure is EACCES if any candidate was denied, * else ENOENT. Relative entries are resolved by the child, which starts in * `cwd`. Returns posix_spawn's error number. */ -static int scr_spawn_command(pid_t *pid, const char *file, const posix_spawn_file_actions_t *fa, - const posix_spawnattr_t *attr, char *const *argv, - char *const *envp, const char *cwd) { - (void)cwd; /* the file actions change into it before the exec */ +static int scr_spawn_resolved(pid_t *pid, const char *file, const posix_spawn_file_actions_t *fa, + const posix_spawnattr_t *attr, char *const *argv, + char *const *envp) { if (envp == NULL) return posix_spawnp(pid, file, fa, attr, argv, environ); if (strchr(file, '/')) return posix_spawn(pid, file, fa, attr, argv, envp); const char *path = "/usr/bin:/bin"; @@ -3243,6 +3242,31 @@ static int scr_spawn_command(pid_t *pid, const char *file, const posix_spawn_fil return denied ? EACCES : ENOENT; } +/* scr_spawn_resolved with the child's SIGPIPE and SIGXFSZ at their default + * actions, as libuv's spawns start them: executables ignore both + * (scr_init), and an exec keeps ignored dispositions. `attr` may be NULL. */ +static int scr_spawn_command(pid_t *pid, const char *file, const posix_spawn_file_actions_t *fa, + posix_spawnattr_t *attr, char *const *argv, char *const *envp, + const char *cwd) { + (void)cwd; /* the file actions change into it before the exec */ + posix_spawnattr_t local; + if (attr == NULL) { + posix_spawnattr_init(&local); + attr = &local; + } + sigset_t defaults; + sigemptyset(&defaults); + sigaddset(&defaults, SIGPIPE); + sigaddset(&defaults, SIGXFSZ); + short flags = 0; + (void)posix_spawnattr_getflags(attr, &flags); + (void)posix_spawnattr_setsigdefault(attr, &defaults); + (void)posix_spawnattr_setflags(attr, (short)(flags | POSIX_SPAWN_SETSIGDEF)); + int err = scr_spawn_resolved(pid, file, fa, attr, argv, envp); + if (attr == &local) posix_spawnattr_destroy(&local); + return err; +} + /* env_pairs: NULL inherits the parent's environment; else [k,v,...] * replaces it, as Node's `env` option does, and the command is looked up * on the replacement's PATH (scr_spawn_command). */ diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index d28f058fa..0941db370 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -5,6 +5,9 @@ #include #include #include +#if !defined(_WIN32) && !defined(__wasi__) +#include +#endif #ifdef _WIN32 #include /* _O_BINARY */ #include /* _setmode, _fileno */ @@ -179,6 +182,15 @@ void scr_init(void) { * stream immediately. */ static char outbuf[1 << 16]; setvbuf(stdout, outbuf, _IOFBF, sizeof outbuf); +#if !defined(_WIN32) && !defined(__wasi__) + /* Node ignores SIGPIPE and SIGXFSZ: a write to a closed pipe or past + * RLIMIT_FSIZE fails with EPIPE or EFBIG, on any thread, instead of + * ending the process. A process.on listener replaces the disposition + * while it is registered (scr_events.c), and spawned children start with + * both at their default actions (scr_spawn_command). */ + signal(SIGPIPE, SIG_IGN); + signal(SIGXFSZ, SIG_IGN); +#endif #ifdef SCR_WORKERS } #endif diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 3bb9a0725..73ff388a9 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -4136,13 +4136,14 @@ static bool scr_fs_write_fd_valid(double fd) { /* write(2) raises SIGPIPE before returning EPIPE when a pipe/socket peer has * closed; write(2)/pwrite(2) likewise raise SIGXFSZ before returning EFBIG at - * RLIMIT_FSIZE. Node turns both into catchable fs errors. Do the same per - * calling thread rather than changing process dispositions (which spawned - * children would inherit). Consume only a signal freshly generated by this - * call, preserving any signal that was already pending. */ + * RLIMIT_FSIZE. Node ignores both signals, so they arrive as catchable fs + * errors. Executables ignore them too (scr_init) and write directly. A + * library does not own its host's dispositions: it blocks the signals in + * the calling thread instead, and consumes only a signal freshly generated + * by this call, preserving any signal that was already pending. */ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length, bool positioned, double position) { -#if defined(_WIN32) || defined(__wasi__) +#if defined(_WIN32) || defined(__wasi__) || !defined(SCR_LIB) return positioned ? scr_fs_pwrite(fd, data, length, position) : write(fd, data, length); diff --git a/tests/corpus/write-signals-ignored.ts b/tests/corpus/write-signals-ignored.ts new file mode 100644 index 000000000..565ffc061 --- /dev/null +++ b/tests/corpus/write-signals-ignored.ts @@ -0,0 +1,30 @@ +// Node ignores SIGPIPE and SIGXFSZ: the process keeps running when either +// arrives, and spawned children start with both at their default actions, +// through each spawn path. write-signals-listener.mjs covers a listener. +import { execFileSync, spawn, spawnSync } from "node:child_process"; + +process.kill(process.pid, "SIGPIPE"); +process.kill(process.pid, "SIGXFSZ"); +console.log("ignored"); + +for (const name of ["PIPE", "XFSZ"]) { + const child = spawnSync("/bin/sh", ["-c", `kill -${name} $$; echo survived`], { + encoding: "utf8", + }); + console.log("spawnSync", name, child.status, child.signal, JSON.stringify(child.stdout)); +} + +try { + execFileSync("/bin/sh", ["-c", "kill -PIPE $$; echo survived"], { encoding: "utf8" }); + console.log("execFileSync survived"); +} catch (error) { + console.log("execFileSync", (error as { signal?: string }).signal); +} + +const child = spawn("/bin/sh", ["-c", "kill -XFSZ $$; echo survived"]); +let output = ""; +child.stdout!.setEncoding("utf8"); +child.stdout!.on("data", (chunk: string) => (output += chunk)); +child.on("exit", (code, signal) => { + console.log("spawn", code, signal, JSON.stringify(output)); +}); diff --git a/tests/corpus/write-signals-listener.mjs b/tests/corpus/write-signals-listener.mjs new file mode 100644 index 000000000..da43dfa83 --- /dev/null +++ b/tests/corpus/write-signals-listener.mjs @@ -0,0 +1,12 @@ +// Node ignores SIGPIPE and SIGXFSZ until a listener is registered: the +// listener then receives each signal, and the process keeps running. +const keepAlive = setInterval(() => {}, 1000); +let received = 0; +for (const signal of ["SIGPIPE", "SIGXFSZ"]) { + process.on(signal, (name) => { + console.log("listener", name); + if (++received === 2) clearInterval(keepAlive); + }); +} +process.kill(process.pid, "SIGPIPE"); +setTimeout(() => process.kill(process.pid, "SIGXFSZ"), 20); diff --git a/tests/harness/corpus-costs/write-signals-ignored.ts.json b/tests/harness/corpus-costs/write-signals-ignored.ts.json new file mode 100644 index 000000000..4f7328ed7 --- /dev/null +++ b/tests/harness/corpus-costs/write-signals-ignored.ts.json @@ -0,0 +1 @@ +{ "plain": 4490, "san": 66079 } diff --git a/tests/harness/corpus-costs/write-signals-listener.mjs.json b/tests/harness/corpus-costs/write-signals-listener.mjs.json new file mode 100644 index 000000000..b73cebb1b --- /dev/null +++ b/tests/harness/corpus-costs/write-signals-listener.mjs.json @@ -0,0 +1 @@ +{ "plain": 3437, "san": 6636 } diff --git a/tests/harness/windows-differential.test.ts b/tests/harness/windows-differential.test.ts index 76293fc5b..752b505b3 100644 --- a/tests/harness/windows-differential.test.ts +++ b/tests/harness/windows-differential.test.ts @@ -101,6 +101,9 @@ const WINDOWS_SKIPS: Record = { // spawns succeed, exposed here where every one fails. "process-named-signals.cjs": "POSIX signal delivery: Windows Node cannot send SIGWINCH or register SIGSTOP", + "write-signals-ignored.ts": + "POSIX signals: Windows has no SIGPIPE or SIGXFSZ, and the children are /bin/sh", + "write-signals-listener.mjs": "POSIX signals: Windows has no SIGPIPE or SIGXFSZ", "1360-spawn-sync.ts": 'posix-shaped: every spawn is ENOENT on Windows Node too, exposing the documented spawn-failure ""-vs-null stdout stance (1644 covers spawnSync here)', "1361-spawn-events.ts":