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.
This commit is contained in:
Chris Tate
2026-10-29 16:00:39 -05:00
parent 4e67c5ce2c
commit e3a42c52a8
11 changed files with 110 additions and 12 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/write-signals-ignored.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/write-signals-listener.mjs"
],
"diags": []
}
+5 -3
View File
@@ -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;
}
+28 -4
View File
@@ -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). */
+12
View File
@@ -5,6 +5,9 @@
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#if !defined(_WIN32) && !defined(__wasi__)
#include <signal.h>
#endif
#ifdef _WIN32
#include <fcntl.h> /* _O_BINARY */
#include <io.h> /* _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
+6 -5
View File
@@ -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);
+30
View File
@@ -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));
});
+12
View File
@@ -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);
@@ -0,0 +1 @@
{ "plain": 4490, "san": 66079 }
@@ -0,0 +1 @@
{ "plain": 3437, "san": 6636 }
@@ -101,6 +101,9 @@ const WINDOWS_SKIPS: Record<string, string> = {
// 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":