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
21 changes: 21 additions & 0 deletions packages/bun-usockets/src/bsd.c
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,21 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_socket(int domain, int type, int protocol, in
}

return apple_no_sigpipe(created_fd);
#elif defined(_WIN32)
/* Plain socket() returns an inheritable handle, so any child spawned with
* bInheritHandles=TRUE (e.g. node:child_process stdio) would duplicate it
* and keep listen sockets alive after the parent exits. */
created_fd = WSASocketW(domain, type, protocol, NULL, 0,
WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT);

if (UNLIKELY(created_fd == INVALID_SOCKET)) {
if (err != NULL) {
*err = WSAGetLastError();
}
return LIBUS_SOCKET_ERROR;
}

return bsd_set_nonblocking(created_fd);
#else
do {
created_fd = socket(domain, type, protocol);
Expand Down Expand Up @@ -872,6 +887,12 @@ LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket(LIBUS_SOCKET_DESCRIPTOR fd, struct bsd
break;
}

#ifdef _WIN32
/* accept() returns an inheritable handle regardless of the listening
* socket's flags; keep it out of spawned children. */
SetHandleInformation((HANDLE) accepted_fd, HANDLE_FLAG_INHERIT, 0);
#endif
Comment on lines +890 to +894

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target function context ---'
sed -n '820,925p' packages/bun-usockets/src/bsd.c
printf '%s\n' '--- accepted_fd and LIBUS_SOCKET_ERROR references ---'
rg -n -C 3 'accepted_fd|LIBUS_SOCKET_ERROR|SetHandleInformation|accept\(' packages/bun-usockets/src/bsd.c packages/bun-usockets -g '*.{c,h,cc,cpp,hpp}'
printf '%s\n' '--- repository guidance ---'
find .. -name REVIEW.md -o -name '*landing*guidance*' 2>/dev/null | head -50

Repository: oven-sh/bun

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- socket close and Windows error helpers ---'
rg -n -C 8 'bsd_close_socket|`#define`.*LIBUS_SOCKET_ERROR|typedef.*LIBUS_SOCKET_DESCRIPTOR|LIBUS_SOCKET_DESCRIPTOR' packages/bun-usockets/src/bsd.c packages/bun-usockets/src/libusockets.h | head -240
printf '%s\n' '--- callers of bsd_accept_socket ---'
rg -n -C 12 'bsd_accept_socket\s*\(' packages/bun-usockets/src -g '*.{c,h}'
printf '%s\n' '--- relevant Windows includes and error handling ---'
sed -n '1,120p' packages/bun-usockets/src/bsd.c
sed -n '320,390p' packages/bun-usockets/src/bsd.c
sed -n '700,770p' packages/bun-usockets/src/bsd.c

Repository: oven-sh/bun

Length of output: 35768


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- close implementation and platform error conventions ---'
sed -n '430,475p' packages/bun-usockets/src/bsd.c
printf '%s\n' '--- accept-loop ownership and error handling ---'
sed -n '480,585p' packages/bun-usockets/src/loop.c
printf '%s\n' '--- Windows-specific socket creation and inheritance references ---'
rg -n -C 5 'NO_HANDLE_INHERIT|HANDLE_INHERIT|SetHandleInformation|GetLastError|WSASetLastError|bsd_close_socket\(' packages/bun-usockets/src -g '*.{c,h}'
printf '%s\n' '--- git metadata for the target lines ---'
git blame -L 885,905 -- packages/bun-usockets/src/bsd.c

Repository: oven-sh/bun

Length of output: 50367


🌐 Web query:

Microsoft documentation SetHandleInformation HANDLE_FLAG_INHERIT accepted socket accept Windows handle inheritance closesocket GetLastError

💡 Result:

While Windows documentation lists sockets among the objects supported by SetHandleInformation, attempting to manage socket handle inheritance via SetHandleInformation with the HANDLE_FLAG_INHERIT flag is often unreliable and frequently ineffective due to the architecture of Windows Sockets (Winsock) [1][2][3][4]. The primary issue is the involvement of Layered Service Providers (LSPs), which sit between your application and the underlying Winsock stack [2][5][6]. Even if you successfully mark a socket handle as non-inheritable using SetHandleInformation, the underlying handle used by the LSP may still be marked inheritable, causing child processes to inadvertently receive access to the socket [2]. Furthermore, even when inheritance is technically avoided, inherited socket handles often cannot be used in a child process because the LSP was never informed of the handle duplication, leading to errors such as WSAENOTSOCK (10038) or invalid handle errors [5]. To reliably prevent socket handle inheritance, consider the following approaches: 1. Use WSA_FLAG_NO_HANDLE_INHERIT: When creating a socket, use the WSASocket function with the WSA_FLAG_NO_HANDLE_INHERIT flag (available on Windows Vista/Server 2008 and later). This is the most reliable way to ensure a socket is not inherited [2]. 2. Use Explicit Handle Lists: Instead of setting bInheritHandles to TRUE in CreateProcess (which exposes all inheritable handles), you can use the PROC_THREAD_ATTRIBUTE_HANDLE_LIST attribute with UpdateProcThreadAttribute. This allows you to provide an explicit list of specific handles to be inherited, ensuring only the intended handles are passed to the child process [7][8]. 3. Avoid Relying on Inheritance for IPC: Because of the complexities surrounding socket inheritance, it is generally recommended to avoid using socket handle inheritance for interprocess communication. Instead, use explicit IPC mechanisms such as Named Pipes or sockets created with explicit non-inheritance flags [5]. Regarding closesocket and GetLastError: If you encounter an error with a socket handle—potentially because an inherited handle has become invalid—calling closesocket will return SOCKET_ERROR, and you can retrieve the specific error code using WSAGetLastError (or GetLastError, though WSAGetLastError is preferred for Winsock-specific errors) [5][6]. However, if the handle has been closed or invalidated by the system due to the inheritance issues described above, the error returned will typically indicate that the handle is no longer valid [5].

Citations:


🌐 Web query:

site:learn.microsoft.com Winsock accept accepted socket handle inheritance WSA_FLAG_NO_HANDLE_INHERIT accept

💡 Result:

In Winsock, socket handles are inheritable by default when created via the socket or WSASocket functions [1][2]. The WSA_FLAG_NO_HANDLE_INHERIT flag (0x80) can be passed to the WSASocket function to create a socket that is explicitly non-inheritable [1][2]. Regarding connection acceptance: 1. The standard accept function creates and returns a new socket handle for an incoming connection [3][4]. This newly created handle generally inherits the inheritance property of the listening socket or follows the default behavior of being inheritable [1][2]. 2. The WSAAccept function, which allows for conditional acceptance, also returns a new socket handle [5][6]. 3. If you require a non-inheritable socket handle for a new connection accepted via these functions, you cannot directly pass a "non-inheritance" flag to accept or WSAAccept. Instead, you must use the SetHandleInformation function from the Windows API after the socket has been accepted to modify the handle's inheritance attribute (specifically, by clearing the HANDLE_FLAG_INHERIT flag). 4. For high-performance scenarios using AcceptEx, the socket passed into the sAcceptSocket parameter must be created beforehand using WSASocket [7]. You can (and should) use the WSA_FLAG_NO_HANDLE_INHERIT flag during the creation of this sAcceptSocket to ensure it is non-inheritable before it is even used in the AcceptEx operation [1][7][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
source = Path("packages/bun-usockets/src/bsd.c").read_text()
start = source.index("LIBUS_SOCKET_DESCRIPTOR bsd_accept_socket")
end = source.index("\nssize_t bsd_recv", start)
function = source[start:end]

checks = {
    "SetHandleInformation is unchecked": "SetHandleInformation((HANDLE) accepted_fd, HANDLE_FLAG_INHERIT, 0);" in function
        and "!SetHandleInformation" not in function,
    "accepted socket is returned after the unchecked call": "return accepted_fd;" in function[function.index("SetHandleInformation"):],
    "Windows cleanup uses closesocket": "void bsd_close_socket" in source
        and "closesocket(fd);" in source[source.index("void bsd_close_socket"):source.index("void bsd_close_socket") + 180],
    "accepted socket is closed before retry only for the unrelated Apple path": "bsd_close_socket(accepted_fd);" in function
        and function.count("bsd_close_socket(accepted_fd);") == 1,
}
for name, passed in checks.items():
    print(f"{'PASS' if passed else 'FAIL'}: {name}")

print("CALLER_SENTINEL_ONLY:")
loop = Path("packages/bun-usockets/src/loop.c").read_text()
call_start = loop.index("LIBUS_SOCKET_DESCRIPTOR client_fd = bsd_accept_socket")
call_end = loop.index("case POLL_TYPE_SOCKET_SHUT_DOWN", call_start)
print(loop[call_start:call_end].splitlines()[0])
print(next(line.strip() for line in loop[call_start:call_end].splitlines()
           if "if (client_fd == LIBUS_SOCKET_ERROR)" in line))
print("No bsd_accept_socket error out-parameter is present in its declaration.")
PY

Repository: oven-sh/bun

Length of output: 576


Fail closed when SetHandleInformation fails. Save GetLastError(), close accepted_fd, restore the error after closesocket, and return LIBUS_SOCKET_ERROR; do not pass an accepted socket with inheritance still enabled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bun-usockets/src/bsd.c` around lines 890 - 894, Update the _WIN32
handling after accept() to check the result of SetHandleInformation; on failure,
save GetLastError(), close accepted_fd, restore the saved error with
SetLastError(), and return LIBUS_SOCKET_ERROR so an inheritable socket is never
passed onward.

Source: Coding guidelines


internal_finalize_bsd_addr(addr);

#if defined(SOCK_CLOEXEC) && defined(SOCK_NONBLOCK)
Expand Down
63 changes: 63 additions & 0 deletions test/js/node/child_process/child-process-socket-inherit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { connect } from "node:net";

// https://github.com/oven-sh/bun/issues/36936
// On Windows, sockets created with plain socket() are inheritable, so a
// detached child spawned while a server is listening would duplicate the
// listen handle and keep the port open after the parent exits.
test.skipIf(!isWindows)("detached child does not inherit the parent's listening socket", async () => {
using dir = tempDir("socket-inherit", {
"parent.mjs": `
import { spawn } from "node:child_process";
import { createServer } from "node:http";

const server = createServer((_request, response) => response.end("ok"));
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const port = server.address().port;

// Spawn a detached child while the listen socket is open, then exit.
const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 100000)"], {
detached: true,
stdio: "ignore",
windowsHide: true,
});
child.unref();

await new Promise((resolve, reject) => server.close(error => (error ? reject(error) : resolve())));
Comment on lines +22 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate test files ---'
git ls-files 'test/js/node/child_process/child-process-socket-inherit.test.ts' 'packages/bun-usockets/src/bsd.c' 'REVIEW.md' '*landing*' | sed -n '1,80p'

printf '%s\n' '--- test outline ---'
ast-grep outline test/js/node/child_process/child-process-socket-inherit.test.ts --view expanded

printf '%s\n' '--- test source ---'
cat -n test/js/node/child_process/child-process-socket-inherit.test.ts

printf '%s\n' '--- bsd.c target area ---'
sed -n '850,930p' packages/bun-usockets/src/bsd.c

printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 4 'bsd_accept_socket|accept_socket|child-process-socket-inherit|socket.inherit|inherit' \
  test/js/node/child_process packages/bun-usockets/src packages/bun-usockets 2>/dev/null | sed -n '1,240p'

printf '%s\n' '--- repository guidance ---'
if [ -f REVIEW.md ]; then cat REVIEW.md; fi

Repository: oven-sh/bun

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- relevant history and diff metadata ---'
git status --short
git diff --stat
git diff -- test/js/node/child_process/child-process-socket-inherit.test.ts packages/bun-usockets/src/bsd.c | sed -n '1,260p'

printf '%s\n' '--- adjacent child-process socket tests ---'
rg -n -C 8 'server\.listen|server\.close|spawn\(|ECONNREFUSED|accept|connection' \
  test/js/node/child_process -g '*.test.ts' | sed -n '1,320p'

Repository: oven-sh/bun

Length of output: 27761


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

test = Path("test/js/node/child_process/child-process-socket-inherit.test.ts").read_text()
bsd = Path("packages/bun-usockets/src/bsd.c").read_text()

parent = test.split('"parent.mjs": `', 1)[1].split('`,', 1)[0]
spawn_at = parent.index("spawn(process.execPath")
close_at = parent.index("server.close")
parent_connects = bool(re.search(r"\b(?:connect|fetch|http\.request|https\.request)\s*\(", parent[:spawn_at]))
test_connect_at = test.index("const socket = connect")
parent_exit_marker = test.index("const { port, childPid }")

print({
    "parent_connects_before_spawn": parent_connects,
    "parent_closes_server_after_spawn": close_at > spawn_at,
    "external_connect_occurs_after_parent_output_is_read": test_connect_at > parent_exit_marker,
    "windows_accepted_handle_guard_present": bool(
        re.search(
            r"`#ifdef` _WIN32\s*/\* accept\(\).*?SetHandleInformation"
            r"\(\(HANDLE\) accepted_fd,\s*HANDLE_FLAG_INHERIT,\s*0\);",
            bsd,
            re.S,
        )
    ),
    "cleanup_swallows_process_kill_errors": bool(
        re.search(r"try\s*\{\s*process\.kill\(childPid\);\s*\}\s*catch\s*\{\s*\}", test, re.S)
    ),
})
PY

printf '%s\n' '--- cross-platform landing guidance ---'
rg -n -A 45 -B 5 'Cross-platform|platform-sensitive|Windows' .claude/docs/landing-prs.md | sed -n '1,180p'

Repository: oven-sh/bun

Length of output: 24602


🌐 Web query:

site:github.com/oven-sh/bun/issues/36936 socket inherit Windows accepted socket

💡 Result:

GitHub Issue #36936: Windows: detached child inherits listening socket and keeps port open after parent exits.

  • Status: Open; no assignee, labels, or linked PRs yet.
  • Reported: August 5, 2026.
  • Affected: Bun 1.4.0-canary.1+81dac417b on Windows.
  • Problem: A detached child process inherits the parent’s HTTP listening socket, so the port remains in LISTENING state after the parent calls server.close() and exits.
  • Likely cause: The Windows socket/handle is incorrectly marked inheritable.
  • Comparison: The same reproduction reportedly works correctly under Node.js 25.8.1. [1]

[1] (github.com)

Citations:


Add coverage for accepted socket inheritance.

The current test connects only after the parent exits, so it does not execute bsd_accept_socket. Add a Windows case that accepts a connection before spawning the detached child and asserts that the child does not retain the accepted socket after the parent exits.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/child_process/child-process-socket-inherit.test.ts` around lines
22 - 30, Extend the socket inheritance test around the server connection flow to
add a Windows-specific case that accepts a client connection before spawning the
detached child. After the parent exits, verify the detached child does not
retain or keep the accepted socket usable, while preserving the existing
listener-inheritance coverage.

Source: Coding guidelines

console.log(JSON.stringify({ port, childPid: child.pid }));
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "parent.mjs"],
env: bunEnv,
cwd: String(dir),
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
const { port, childPid } = JSON.parse(stdout.trim());
Comment on lines +41 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate stdout before the exit code.

Line 43 asserts the exit code before Line 44 validates the fixture output. Parse and validate stdout after the stderr assertion and before the exit-code assertion.

Based on learnings, subprocess tests must assert stdout and stderr before the exit code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/child_process/child-process-socket-inherit.test.ts` around lines
41 - 44, Reorder the assertions in the subprocess test so the JSON parsing and
validation of stdout via JSON.parse occur after the stderr assertion but before
expect(exitCode).toBe(0). Keep the existing stdout fixture validation and
exit-code expectation unchanged apart from their ordering.

Sources: Coding guidelines, Learnings


try {
// The parent has exited and closed its server, so nothing may be listening
// on the port even though the detached child is still alive.
const result = await new Promise<string>(resolve => {
const socket = connect({ port, host: "127.0.0.1" });
socket.on("connect", () => {
socket.destroy();
resolve("connected");
});
socket.on("error", error => resolve((error as NodeJS.ErrnoException).code ?? "error"));
});
expect(result).toBe("ECONNREFUSED");
} finally {
try {
process.kill(childPid);
} catch {}
Comment on lines +59 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
target='test/js/node/child_process/child-process-socket-inherit.test.ts'
printf '%s\n' '--- target outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline "$target" --view expanded || true
fi
printf '%s\n' '--- target context ---'
cat -n "$target" | sed -n '1,130p'
printf '%s\n' '--- nearby files ---'
fd -i 'child.*process.*\.test\.(ts|tsx|js|jsx)$' test/js/node/child_process 2>/dev/null | head -40 || true
printf '%s\n' '--- kill cleanup patterns ---'
rg -n -C 5 'process\.kill|catch\s*\{\s*\}' test/js/node/child_process test 2>/dev/null | head -240 || true
printf '%s\n' '--- review guidance ---'
for f in REVIEW.md test/REVIEW.md; do
  if [ -f "$f" ]; then
    printf '%s\n' "--- $f ---"
    sed -n '1,220p' "$f"
  fi
done

Repository: oven-sh/bun

Length of output: 44502


🏁 Script executed:

#!/bin/bash
set -eu
target='test/js/node/child_process/child-process-socket-inherit.test.ts'
cat -n "$target" | sed -n '1,130p'
rg -n -C 5 'process\.kill|childPid|detached|unref' test/js/node/child_process test 2>/dev/null | head -260

Repository: oven-sh/bun

Length of output: 21105


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- process.kill implementations and bindings ---'
rg -n -C 8 'process\.kill|kill\(.*pid|ERRNO.*ESRCH|ESRCH|EPERM' src packages test/js | head -400
printf '%s\n' '--- platform-specific kill helpers ---'
rg -n -C 6 'killProcess|terminateProcess|TerminateProcess|kill.*process|process.*kill' src | head -400
printf '%s\n' '--- process.kill type/docs references ---'
rg -n -C 5 'kill\s*\(' packages/bun-types src/js test/js/node/child_process | head -300

Repository: oven-sh/bun

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -eu
rg -n -C 8 'process\.kill|kill\(.*pid|ERRNO.*ESRCH|ESRCH|EPERM' src packages test/js | head -400
rg -n -C 6 'killProcess|terminateProcess|TerminateProcess|kill.*process|process.*kill' src | head -400

Repository: oven-sh/bun

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- child-process kill implementation ---'
sed -n '1535,1605p' src/js/node/child_process.ts
printf '%s\n' '--- global process.kill references ---'
rg -n -C 12 'kill\s*:\s*|kill\(pid|fn.*kill|process_kill|uv_kill' src/js src | head -260
printf '%s\n' '--- direct uv_kill implementation references ---'
rg -n -C 10 'uv_kill|pub fn kill|fn kill' src/spawn src/js src/bun.js src | head -260

Repository: oven-sh/bun

Length of output: 35116


🏁 Script executed:

#!/bin/bash
set -eu
sed -n '1535,1605p' src/js/node/child_process.ts
rg -n -C 12 'kill\s*:\s*|kill\(pid|process_kill|uv_kill' src/js src | head -260
rg -n -C 10 'uv_kill|pub fn kill|fn kill' src/spawn src/js src | head -260

Repository: oven-sh/bun

Length of output: 32407


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- process module files ---'
fd -i 'process' src/js src | head -120
printf '%s\n' '--- kill declarations in likely process modules ---'
for f in $(fd -i 'process' src/js src | head -80); do
  if rg -q 'kill|Process' "$f" 2>/dev/null; then
    printf '%s\n' "--- $f ---"
    rg -n -C 5 'kill|Process' "$f" | head -100
  fi
done
printf '%s\n' '--- read-only policy verifier ---'
python3 - <<'PY'
from pathlib import Path

kill_impl = Path("src/spawn/process.rs").read_text()
target = Path("test/js/node/child_process/child-process-socket-inherit.test.ts").read_text()
errno_map = Path("src/errno/windows_errno.rs").read_text()

assert 'if errno_ != bun_sys::E::ESRCH' in kill_impl
assert 'if err.errno != bun_sys::E::ESRCH as u16' in kill_impl
assert 'process.kill(childPid)' in target
assert 'catch {}' in target
assert 'EPERM = 1' in errno_map
print("target uses broad cleanup suppression: yes")
print("implementation treats only ESRCH as benign in POSIX and Windows child kill paths: yes")
print("Windows errno table defines EPERM: yes")
PY

Repository: oven-sh/bun

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -eu
fd -i 'process' src/js src | head -120
for f in $(fd -i 'process' src/js src | head -80); do
  if rg -q 'kill|Process' "$f" 2>/dev/null; then
    printf '%s\n' "--- $f ---"
    rg -n -C 5 'kill|Process' "$f" | head -100
  fi
done
python3 - <<'PY'
from pathlib import Path
kill_impl = Path("src/spawn/process.rs").read_text()
target = Path("test/js/node/child_process/child-process-socket-inherit.test.ts").read_text()
errno_map = Path("src/errno/windows_errno.rs").read_text()
assert 'if errno_ != bun_sys::E::ESRCH' in kill_impl
assert 'if err.errno != bun_sys::E::ESRCH as u16' in kill_impl
assert 'process.kill(childPid)' in target
assert 'catch {}' in target
assert 'EPERM = 1' in errno_map
print("cleanup policy evidence is consistent")
PY

Repository: oven-sh/bun

Length of output: 50367


Report unexpected child cleanup failures.

If process.kill(childPid) fails with an error other than ESRCH, rethrow it so failed cleanup cannot leave the detached child alive.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/child_process/child-process-socket-inherit.test.ts` around lines
59 - 61, Update the child cleanup try/catch around process.kill(childPid) to
ignore only ESRCH, and rethrow any other error so unexpected cleanup failures
are reported.

Source: Coding guidelines

}

Check warning on line 62 in test/js/node/child_process/child-process-socket-inherit.test.ts

View check run for this annotation

Claude / Claude Code Review

Detached grandchild leaks if assertions before try/finally fail

The `expect(stderr)`, `expect(exitCode)`, and `JSON.parse` calls at lines 42-44 run *before* the `try/finally` that kills the detached grandchild, so if the parent emits any stderr, exits non-zero, or prints malformed stdout after spawning, the assertion throws and the detached child is orphaned on the persistent Windows runner for 100s. Consider parsing `childPid` best-effort first and wrapping all assertions inside the `try`, and/or shortening the grandchild's `setTimeout(() => {}, 100000)` to
Comment on lines +41 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The expect(stderr), expect(exitCode), and JSON.parse calls at lines 42-44 run before the try/finally that kills the detached grandchild, so if the parent emits any stderr, exits non-zero, or prints malformed stdout after spawning, the assertion throws and the detached child is orphaned on the persistent Windows runner for 100s. Consider parsing childPid best-effort first and wrapping all assertions inside the try, and/or shortening the grandchild's setTimeout(() => {}, 100000) to ~5-10s so a leak self-heals quickly.

Extended reasoning...

What the bug is

The test spawns a parent process that in turn spawns a detached grandchild running setTimeout(() => {}, 100000) — a process that will live for 100 seconds unless explicitly killed. Cleanup for this grandchild is process.kill(childPid) inside a finally block at lines 58-62. However, three fallible statements execute before that try block is entered:

expect(stderr).toBe("");            // line 42
expect(exitCode).toBe(0);            // line 43
const { port, childPid } = JSON.parse(stdout.trim());  // line 44
try { ... } finally { process.kill(childPid); }        // lines 46-62

If any of lines 42-44 throw, control never reaches the try, childPid is never extracted, and the detached grandchild survives on the CI runner for the full 100 seconds.

Concrete trigger path (step-by-step)

  1. parent.mjs starts, listens on port 0, and spawns the detached grandchild (child.pid = 12345), which begins its 100-second sleep.
  2. parent.mjs calls server.close() and prints {"port":54321,"childPid":12345} to stdout — but also emits something to stderr (a deprecation warning, a debug-build log line that leaked past bunEnv, or any future runtime notice).
  3. The parent exits with code 0.
  4. Back in the test, stderr is "[some warning]\n", so expect(stderr).toBe("") at line 42 throws.
  5. Line 44 (JSON.parse) never runs → childPid is never bound → the try/finally is never entered → process.kill(childPid) never fires.
  6. Process 12345 remains alive for 100 seconds on the persistent Windows CI runner. If the test is retried, another orphan accumulates.

The same applies if the parent crashes after spawn() but before console.log(...) (e.g., server.close() errors): exitCode !== 0 or stdout is not valid JSON, one of lines 42-44 throws, and the grandchild leaks.

Why existing code doesn't prevent it

The try/finally is correctly structured for the connection-check assertion inside it, but the three preceding lines are outside its guard. The await using proc on line 35 only cleans up the direct subprocess (the parent), which has already exited; it does nothing for the detached, unref'd grandchild. The only backstop is the grandchild's own setTimeout(..., 100000) — which is 100 seconds, long enough for retries to stack multiple orphans on a persistent runner.

Why this matters (REVIEW.md)

REVIEW.md's test-hygiene section states:

Release every resource via using/await using or try/finally registered BEFORE the assertions (cleanup after expectations leaks on the first failure and poisons later tests on persistent CI runners)

This is a direct instance of that rule: cleanup is registered after three assertions.

Impact

Test-hygiene only. With this PR's fix applied, the leaked grandchild holds no inherited socket, port, or stdio pipe — it's just an idle process that self-terminates after 100s. Nothing user-facing breaks; the concern is orphan accumulation on persistent Windows CI runners across retries when the test fails.

Fix

Either or both of:

  1. Restructure: parse childPid best-effort first (e.g., let childPid; try { ({ port, childPid } = JSON.parse(stdout.trim())); } catch {}), then wrap all assertions — including expect(stderr) / expect(exitCode) — inside the try { ... } finally { if (childPid) try { process.kill(childPid); } catch {} }.
  2. Shorten the self-timeout: change setTimeout(() => {}, 100000) to ~5000-10000. The test only needs the grandchild alive long enough to attempt one connect(), so a few seconds is plenty and any leak self-heals before it can accumulate. This also covers the case where the parent dies before printing childPid (where restructuring alone can't help).

});