Skip to content

Create sockets with WSA_FLAG_NO_HANDLE_INHERIT on Windows - #36938

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/54146df1/windows-socket-inherit
Aug 5, 2026
Merged

Create sockets with WSA_FLAG_NO_HANDLE_INHERIT on Windows#36938
Jarred-Sumner merged 1 commit into
mainfrom
farm/54146df1/windows-socket-inherit

Conversation

@robobun

@robobun robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #36936

Problem

On Windows, a detached child spawned while an HTTP server is listening inherits the server's listening socket. If the child outlives the parent, the port stays in LISTENING state (attributed to the dead parent's PID) even after server.close() completes and the parent exits normally:

LocalAddress LocalPort State  OwningProcess
------------ --------- -----  -------------
127.0.0.1        43123 Listen         23068   <- dead parent PID

Cause

uSockets creates sockets with plain socket(). On Windows that returns an inheritable handle (the SOCK_CLOEXEC branch in bsd_create_socket is POSIX-only). When node:child_process.spawn creates the child via CreateProcess with bInheritHandles=TRUE (needed for stdio), every live socket handle is duplicated into the child, so the kernel keeps listen sockets open for as long as the child lives. accept() likewise returns inheritable handles.

Fix

In packages/bun-usockets/src/bsd.c, matching what libuv does:

  • bsd_create_socket: on Windows, create sockets with WSASocketW(..., WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT) instead of socket().
  • bsd_accept_socket: clear HANDLE_FLAG_INHERIT on accepted sockets via SetHandleInformation.

All TCP, UDP, and connect sockets funnel through bsd_create_socket, so this covers Bun.serve, node:http, node:net, and dgram.

Verification (on Windows)

The new test (test/js/node/child_process/child-process-socket-inherit.test.ts) spawns a parent that listens on port 0, spawns a detached child, closes the server, and exits; the test then asserts connecting to the port yields ECONNREFUSED while the detached child is still alive.

  • Unfixed canary (USE_SYSTEM_BUN=1): fails, the connection succeeds because the inherited listen socket still accepts.
  • With this fix (bun bd test): passes, and the issue's original repro now matches Node: parent exited, child alive, Get-NetTCPConnection -LocalPort 43123 -State Listen returns nothing.
  • Sanity: test/js/bun/net/tcp-server.test.ts, test/js/node/net/node-net.test.ts, test/js/bun/udp/udp_socket.test.ts all pass on Windows with the fix (272 pass, 0 fail).

Note: the bug is Windows-only (POSIX uses SOCK_CLOEXEC), so the test is test.skipIf(!isWindows) and only exercises the fix on Windows lanes.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/child_process/child-process-socket-inherit.test.ts

On Windows, plain socket() and accept() return inheritable handles, so a
child spawned with bInheritHandles=TRUE (any node:child_process spawn with
stdio) duplicated every live socket, including listen sockets. A detached
child that outlived the parent kept the parent's port in LISTENING state
even after server.close() and parent exit.

Create sockets with WSASocketW(..., WSA_FLAG_OVERLAPPED |
WSA_FLAG_NO_HANDLE_INHERIT) and clear HANDLE_FLAG_INHERIT on accepted
sockets, matching libuv.

Fixes #36936
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The Windows socket implementation now creates non-inheritable sockets and clears inheritance on accepted sockets. A Windows-only regression test verifies that a detached child does not keep the parent HTTP server’s port open.

Windows socket inheritance

Layer / File(s) Summary
Disable inheritance and verify listener cleanup
packages/bun-usockets/src/bsd.c, test/js/node/child_process/child-process-socket-inherit.test.ts
Windows sockets use WSASocketW with overlapped and non-inheritable flags. Accepted sockets clear the inherit flag. The regression test verifies ECONNREFUSED after the parent closes its server and exits.

Suggested reviewers: cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the Windows socket creation change that fixes handle inheritance.
Description check ✅ Passed The description explains the problem, cause, fix, and verification, covering the template requirements despite different section headings.
Linked Issues check ✅ Passed The changes prevent Windows child processes from inheriting sockets and add a regression test for issue #36936.
Out of Scope Changes check ✅ Passed The source changes and Windows regression test directly support the linked issue and stated pull request objectives.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/bun-usockets/src/bsd.c`:
- Around line 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.

In `@test/js/node/child_process/child-process-socket-inherit.test.ts`:
- Around line 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.
- Around line 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.
- Around line 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.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 80354e4f-88cd-4bdf-a0e7-e49af47a0924

📥 Commits

Reviewing files that changed from the base of the PR and between d601782 and 27ffb64.

📒 Files selected for processing (2)
  • packages/bun-usockets/src/bsd.c
  • test/js/node/child_process/child-process-socket-inherit.test.ts

Comment on lines +890 to +894
#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

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

Comment on lines +22 to +30
// 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())));

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

Comment on lines +41 to +44
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());

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

Comment on lines +59 to +61
try {
process.kill(childPid);
} catch {}

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

Comment on lines +41 to +62
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());

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 {}
}

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).

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Rewrite the uSockets core in Rust #34037 - Rewrites bsd_create_socket/bsd_accept_socket in Rust and already marks both created and accepted Windows sockets non-inheritable via SetHandleInformation(HANDLE_FLAG_INHERIT, 0) for the same "keep sockets out of spawned children" reason, while deleting packages/bun-usockets/src/bsd.c entirely.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate in practice: #34037 is the full uSockets rewrite in Rust and handles inheritance as part of replacing bsd.c wholesale. This PR is a minimal C-side fix for #36936 that can ship independently; if the rewrite lands, it supersedes this change.

@Jarred-Sumner
Jarred-Sumner merged commit 1c4cfba into main Aug 5, 2026
54 of 55 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/54146df1/windows-socket-inherit branch August 5, 2026 07:30
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

The test added here fails its temp dir cleanup with EBUSY on the Windows 11 aarch64 lane (the detached child inherits the temp dir as cwd and outlives the rm). Fix in #36971.

dylan-conway pushed a commit that referenced this pull request Aug 5, 2026
…est (#36971)

Fixes the Windows CI failure in
`test/js/node/child_process/child-process-socket-inherit.test.ts`, which
has been failing on the Windows 11 aarch64 lane since the test landed in
#36938 (for example in build 89207):

```
EBUSY: resource busy or locked, rm 'C:\Windows\Temp\buntmp-Fbshlf\socket-inherit_Ev5bLB'
    at [Symbol.dispose] (C:\buildkite-agent\build\test\harness.ts:462:8)
```

### Cause

The fixture spawns a detached child (`setTimeout(() => {}, 100000)`)
that inherits the parent's cwd, which is the test's temp dir. The test
kills the child in its `finally`, but on Windows `process.kill` only
initiates `TerminateProcess`: the process object, including its working
directory handle, is released asynchronously. `using dir` disposal runs
`fs.rmSync` immediately after, so cleanup races the child's teardown and
fails with EBUSY whenever the child has not finished dying. The socket
assertions themselves pass (all 3 expect calls run); only cleanup fails.
On the slower aarch64 runners the rm loses the race most of the time.

It is worse on the failure paths: if the parent fixture exits non-zero,
`childPid` is never parsed, the kill never runs, and the child holds the
temp dir for the full 100 seconds.

### Fix

Spawn the detached child with an explicit `cwd` outside the temp dir
(`os.tmpdir()`). The child then never holds the directory that disposal
removes, in every path, with no timing dependence. `cwd` is independent
of handle inheritance (`lpCurrentDirectory` vs `bInheritHandles` in
`CreateProcess`), so the code path exercised by the test is unchanged:
the child is still spawned with inheritable-handle semantics while the
listen socket is open.

I considered retrying EBUSY in the harness `tempDir` disposal instead,
but that would mask genuine process and handle leaks in every other
Windows test.

### Verification (Windows 11 aarch64, the failing lane)

- Unmodified test, release build with the #36938 fix (same setup as CI):
EBUSY in 3 of 5 runs.
- Fixed test, same build: 20 of 20 runs pass.
- Fixed test, debug build with `packages/bun-usockets/src/bsd.c`
reverted to its pre-#36938 state: fails with `Expected: "ECONNREFUSED",
Received: "connected"`, so the test still detects the original
inherited-socket bug (#36936).
- Fixed test, debug build with current `bsd.c`: passes.

The test is `skipIf(!isWindows)`, so Linux lanes are unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows: detached child inherits listening socket and keeps port open after parent exits

2 participants