Repository navigation
spawn: kill and reap a child whose exit watch cannot be registered #37293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 7 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
d9edf67
spawn: fall back to the waiter thread when pidfd epoll registration f…
robobun c322e80
[autofix.ci] apply automated fixes
autofix-ci[bot] 6091828
Trim comments, ignore compiler stdout in test
robobun c02380c
ci: retrigger
robobun 8ba5fc3
test: match both pidfd readlink formats and assert the shim fired
robobun c5809ac
test: chain LD_PRELOAD instead of replacing it
robobun 583f30c
test: run both cases concurrently
robobun e8e0417
Merge remote-tracking branch 'origin/main' into farm/8df4c92d/fix-kil…
robobun ec00fdf
spawn: kill and reap a child whose exit watch cannot be registered
robobun 2800f2a
spawn: fail the spawn when the exit watch cannot be registered, and t…
robobun b98f152
Merge remote-tracking branch 'origin/main' into farm/8df4c92d/fix-kil…
robobun ac1a1fa
spawn: do not wait for a child that could not be signalled
robobun 0b90c6a
spawn: shorten the comments of the unwatchable-child arm
robobun 9ab9466
spawn: one copy of the watch body, and no callback when the stdin wri…
robobun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| // When registering the child's pidfd with epoll fails (ENOMEM under memory | ||
| // pressure, ENOSPC when /proc/sys/fs/epoll/max_user_watches is exhausted), | ||
| // the process must fall back to the waiter thread instead of fabricating an | ||
| // exit: previously `subprocess.kill()` became a silent no-op (no signal sent, | ||
| // `killed` stayed false) and `.exited` never settled, leaking the child. | ||
| // | ||
| // The failure is injected with an LD_PRELOAD shim that makes epoll_ctl | ||
| // ADD/MOD fail with ENOMEM whenever the target fd is a pidfd. Bun issues | ||
| // epoll_ctl both through the libc symbol and through the generic syscall() | ||
| // entry point, so the shim interposes both. The shim touches a marker file | ||
| // on the first injected failure, and the tests assert it exists: if a kernel | ||
| // ever renames the pidfd readlink target, the tests fail loudly instead of | ||
| // passing without injecting anything. | ||
| import { expect, test } from "bun:test"; | ||
| import { existsSync } from "fs"; | ||
| import { bunEnv, bunExe, isLinux, isMusl, tempDir } from "harness"; | ||
| import { join } from "path"; | ||
|
|
||
| const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); | ||
|
|
||
| // LD_PRELOAD requires the dynamically linked glibc build; the musl build is | ||
| // static. | ||
| const enabled = isLinux && !isMusl && !!cc; | ||
|
|
||
| const SHIM_SOURCE = ` | ||
| #define _GNU_SOURCE | ||
| #include <dlfcn.h> | ||
| #include <errno.h> | ||
| #include <fcntl.h> | ||
| #include <stdarg.h> | ||
| #include <stdio.h> | ||
| #include <stdlib.h> | ||
| #include <string.h> | ||
| #include <sys/epoll.h> | ||
| #include <sys/syscall.h> | ||
| #include <unistd.h> | ||
|
|
||
| static int is_pidfd(int fd) { | ||
| char path[64], buf[64]; | ||
| snprintf(path, sizeof(path), "/proc/self/fd/%d", fd); | ||
| ssize_t n = readlink(path, buf, sizeof(buf) - 1); | ||
| if (n <= 0) return 0; | ||
| buf[n] = 0; | ||
| /* "anon_inode:[pidfd]" classically; pidfs kernels may use "pidfd:[<ino>]". */ | ||
| return strstr(buf, "pidfd") != NULL; | ||
| } | ||
|
|
||
| static void mark_fired(void) { | ||
| const char *marker = getenv("BUN_EPOLL_FAIL_MARKER"); | ||
| if (!marker || !*marker) return; | ||
| int fd = open(marker, O_WRONLY | O_CREAT, 0644); | ||
| if (fd >= 0) close(fd); | ||
| } | ||
|
|
||
| static int should_fail(int op, int fd) { | ||
| if ((op == EPOLL_CTL_ADD || op == EPOLL_CTL_MOD) && is_pidfd(fd)) { | ||
| mark_fired(); | ||
| return 1; | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev) { | ||
| static int (*real)(int, int, int, struct epoll_event *); | ||
| if (!real) real = dlsym(RTLD_NEXT, "epoll_ctl"); | ||
| if (should_fail(op, fd)) { | ||
| errno = ENOMEM; | ||
| return -1; | ||
| } | ||
| return real(epfd, op, fd, ev); | ||
| } | ||
|
|
||
| long syscall(long number, ...) { | ||
| static long (*real)(long, long, long, long, long, long, long); | ||
| va_list ap; | ||
| long a[6]; | ||
| va_start(ap, number); | ||
| for (int i = 0; i < 6; i++) a[i] = va_arg(ap, long); | ||
| va_end(ap); | ||
| if (!real) real = dlsym(RTLD_NEXT, "syscall"); | ||
| if (number == SYS_epoll_ctl && should_fail((int)a[1], (int)a[2])) { | ||
| errno = ENOMEM; | ||
| return -1; | ||
| } | ||
| return real(number, a[0], a[1], a[2], a[3], a[4], a[5]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| `; | ||
|
|
||
| // Spawns a child, SIGKILLs it, and reports what the Subprocess API observed. | ||
| // The Bun.sleep() race is a deadline, not a wait: on a broken build `.exited` | ||
| // never settles, and the deadline turns that hang into a clean JSON mismatch. | ||
| const FIXTURE = ` | ||
| const p = Bun.spawn(["sleep", "100"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); | ||
| p.kill("SIGKILL"); | ||
| let timer; | ||
| const deadline = new Promise(resolve => { timer = setTimeout(() => resolve("never settled"), 3000); }); | ||
| const exited = await Promise.race([p.exited, deadline]); | ||
| clearTimeout(timer); | ||
| let alive; | ||
| try { process.kill(p.pid, 0); alive = true; } catch { alive = false; } | ||
| if (alive) try { process.kill(p.pid, 9); } catch {} | ||
| console.log(JSON.stringify({ exited, signalCode: p.signalCode, killed: p.killed, alive })); | ||
| `; | ||
|
|
||
| async function compileShim(dir: string): Promise<string> { | ||
| const source = join(dir, "epoll_fail.c"); | ||
| const shim = join(dir, "epoll_fail.so"); | ||
| await Bun.write(source, SHIM_SOURCE); | ||
| await using proc = Bun.spawn({ | ||
| cmd: [cc!, "-shared", "-fPIC", "-O2", "-o", shim, source, "-ldl"], | ||
| env: bunEnv, | ||
| stdout: "ignore", | ||
| stderr: "pipe", | ||
| }); | ||
| const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); | ||
| if (exitCode !== 0) throw new Error(`cc failed: ${stderr}`); | ||
| return shim; | ||
| } | ||
|
|
||
| // Chain ahead of any LD_PRELOAD the test runner itself was started with. | ||
| function preload(shim: string): string { | ||
| const existing = bunEnv.LD_PRELOAD; | ||
| return existing ? `${shim}:${existing}` : shim; | ||
| } | ||
|
|
||
| async function runFixture(extraEnv: Record<string, string>) { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "-e", FIXTURE], | ||
| env: { ...bunEnv, ...extraEnv }, | ||
| stderr: "pipe", | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| expect(stderr).toBe(""); | ||
| expect(exitCode).toBe(0); | ||
| return JSON.parse(stdout.trim()); | ||
| } | ||
|
|
||
| test.concurrent.skipIf(!enabled)( | ||
| "kill() and exited work when pidfd epoll registration fails", | ||
| async () => { | ||
| using dir = tempDir("epoll-fail", {}); | ||
| const shim = await compileShim(String(dir)); | ||
|
|
||
| // Control: the fixture passes without fault injection. | ||
| expect(await runFixture({})).toEqual({ | ||
| exited: 137, | ||
| signalCode: "SIGKILL", | ||
| killed: true, | ||
| alive: false, | ||
| }); | ||
|
|
||
| // With every pidfd epoll_ctl ADD/MOD failing, the signal must still be | ||
| // delivered and the exit must still be observed. | ||
| const marker = join(String(dir), "fired"); | ||
| expect(await runFixture({ LD_PRELOAD: preload(shim), BUN_EPOLL_FAIL_MARKER: marker })).toEqual({ | ||
| exited: 137, | ||
| signalCode: "SIGKILL", | ||
| killed: true, | ||
| alive: false, | ||
| }); | ||
| // The shim must actually have injected a failure, or the run above | ||
| // proved nothing. | ||
| expect(existsSync(marker)).toBe(true); | ||
| }, | ||
| 15_000, | ||
| ); | ||
|
|
||
| test.concurrent.skipIf(!enabled)( | ||
| "exit is observed without kill when pidfd epoll registration fails", | ||
| async () => { | ||
| using dir = tempDir("epoll-fail-exit", {}); | ||
| const shim = await compileShim(String(dir)); | ||
| const marker = join(String(dir), "fired"); | ||
|
|
||
| await using proc = Bun.spawn({ | ||
| cmd: [ | ||
| bunExe(), | ||
| "-e", | ||
| `const p = Bun.spawn(["sleep", "0.05"], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); | ||
| let timer; | ||
| const deadline = new Promise(resolve => { timer = setTimeout(() => resolve("never settled"), 3000); }); | ||
| const exited = await Promise.race([p.exited, deadline]); | ||
| clearTimeout(timer); | ||
| console.log(JSON.stringify({ exited, exitCode: p.exitCode }));`, | ||
| ], | ||
| env: { ...bunEnv, LD_PRELOAD: preload(shim), BUN_EPOLL_FAIL_MARKER: marker }, | ||
| stderr: "pipe", | ||
| }); | ||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
| expect(stderr).toBe(""); | ||
| expect(stdout.trim()).toBe(JSON.stringify({ exited: 0, exitCode: 0 })); | ||
| expect(exitCode).toBe(0); | ||
| // The shim must actually have injected a failure, or the run above | ||
| // proved nothing. | ||
| expect(existsSync(marker)).toBe(true); | ||
| }, | ||
| 15_000, | ||
| ); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.