Ignore SIGPIPE and SIGXFSZ in executables, as Node does (#2578)

This commit is contained in:
Chris Tate
2026-10-29 16:05:14 -05:00
committed by GitHub
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":