Skip to content

Commit 9dfdac6

Browse files
util: Fix Windows SpawnProcess hang when child exits before connecting to named pipe
SpawnProcess on Windows creates a named pipe with PIPE_WAIT and then calls ConnectNamedPipe synchronously. If the child exits or crashes before connecting, ConnectNamedPipe blocks forever with no recovery path. Fix by opening the pipe with FILE_FLAG_OVERLAPPED and using WaitForMultipleObjects on both the connect event and the child process handle. If the process handle signals first, the child died without connecting and SpawnProcess throws instead of hanging. Since the pipe is now in overlapped mode, WriteFile also requires an OVERLAPPED structure; use GetOverlappedResult with bWait=TRUE to handle both synchronous and asynchronous completion. Add a Windows-only test that spawns a child which exits immediately without opening the named pipe and asserts SpawnProcess does not block. (bitcoin-core#231 (comment)) Co-Authored-By: ViniciusCestarii <viniciuscestari01@gmail.com> This change was written with Claude Sonnet 4.6.
1 parent 3a2e695 commit 9dfdac6

2 files changed

Lines changed: 77 additions & 6 deletions

File tree

‎src/mp/util.cpp‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,9 +406,12 @@ std::tuple<ProcessId, SocketId> SpawnProcess(const std::function<std::vector<std
406406
return {pid, fds[1]};
407407
#else
408408
// Create windows pipe to send socket over to child process.
409+
// FILE_FLAG_OVERLAPPED lets ConnectNamedPipe be used with
410+
// WaitForMultipleObjects so the parent detects child death instead of
411+
// blocking forever if the child exits without opening the pipe.
409412
static std::atomic<int> counter{1};
410413
std::string pipe_path{R"(\\.\pipe\mp-)" + std::to_string(GetCurrentProcessId()) + "-" + std::to_string(counter.fetch_add(1))};
411-
HANDLE pipe{CreateNamedPipeA(pipe_path.c_str(), PIPE_ACCESS_OUTBOUND, PIPE_TYPE_MESSAGE | PIPE_WAIT, /*nMaxInstances=*/1, /*nOutBufferSize=*/0, /*nInBufferSize=*/0, /*nDefaultTimeOut=*/0, /*lpSecurityAttributes=*/nullptr)};
414+
HANDLE pipe{CreateNamedPipeA(pipe_path.c_str(), PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED, PIPE_TYPE_MESSAGE | PIPE_WAIT, /*nMaxInstances=*/1, /*nOutBufferSize=*/0, /*nInBufferSize=*/0, /*nDefaultTimeOut=*/0, /*lpSecurityAttributes=*/nullptr)};
412415
KJ_WIN32(pipe != INVALID_HANDLE_VALUE, "CreateNamedPipe failed");
413416

414417
// TODO: Would be good to add more exception safety here. Resources (pipe,
@@ -423,8 +426,37 @@ std::tuple<ProcessId, SocketId> SpawnProcess(const std::function<std::vector<std
423426
KJ_WIN32(CreateProcessA(/*lpApplicationName=*/nullptr, const_cast<char*>(cmd.c_str()), /*lpProcessAttributes=*/nullptr, /*lpThreadAttributes=*/nullptr, /*bInheritHandles=*/FALSE, /*dwCreationFlags=*/0, /*lpEnvironment=*/nullptr, /*lpCurrentDirectory=*/nullptr, &si, &pi), "CreateProcess failed");
424427
KJ_WIN32(CloseHandle(pi.hThread), "CloseHandle(hThread)");
425428

426-
// Send socket to the child via the pipe
427-
KJ_WIN32(ConnectNamedPipe(pipe, nullptr) || GetLastError() == ERROR_PIPE_CONNECTED, "ConnectNamedPipe failed");
429+
// Wait for child to connect to the pipe. WaitForMultipleObjects on both
430+
// the connect event and the child process handle lets us fail cleanly if
431+
// the child exits without opening the pipe.
432+
HANDLE event{CreateEvent(/*lpEventAttributes=*/nullptr, /*bManualReset=*/TRUE, /*bInitialState=*/FALSE, /*lpName=*/nullptr)};
433+
KJ_WIN32(event != nullptr, "CreateEvent failed");
434+
OVERLAPPED ov{};
435+
ov.hEvent = event;
436+
if (!ConnectNamedPipe(pipe, &ov)) {
437+
DWORD err{GetLastError()};
438+
if (err == ERROR_IO_PENDING) {
439+
HANDLE objects[2]{event, pi.hProcess};
440+
DWORD result{WaitForMultipleObjects(2, objects, /*bWaitAll=*/FALSE, /*dwMilliseconds=*/INFINITE)};
441+
KJ_WIN32(result != WAIT_FAILED, "WaitForMultipleObjects failed");
442+
if (result != WAIT_OBJECT_0) {
443+
CloseHandle(event);
444+
CloseHandle(pipe);
445+
KJ_FAIL_REQUIRE("child process exited before connecting to named pipe");
446+
}
447+
DWORD unused;
448+
KJ_WIN32(GetOverlappedResult(pipe, &ov, &unused, /*bWait=*/FALSE), "ConnectNamedPipe failed");
449+
} else if (err != ERROR_PIPE_CONNECTED) {
450+
CloseHandle(event);
451+
KJ_FAIL_WIN32("ConnectNamedPipe", err);
452+
}
453+
}
454+
KJ_WIN32(CloseHandle(event), "CloseHandle(event)");
455+
456+
// Send socket to child. Use overlapped I/O since pipe is FILE_FLAG_OVERLAPPED.
457+
OVERLAPPED write_ov{};
458+
write_ov.hEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr);
459+
KJ_WIN32(write_ov.hEvent != nullptr, "CreateEvent failed");
428460
// Duplicate socket for the child using its PID.
429461
WSAPROTOCOL_INFO info{};
430462
KJ_WINSOCK(WSADuplicateSocket(fds[0], pi.dwProcessId, &info), "WSADuplicateSocket failed");
@@ -433,8 +465,14 @@ std::tuple<ProcessId, SocketId> SpawnProcess(const std::function<std::vector<std
433465
// sees a disconnection when the child exits, resulting in hangs reading or
434466
// writing to fds[1].
435467
CloseSocket(fds[0]);
468+
if (!WriteFile(pipe, &info, sizeof(info), nullptr, &write_ov)) {
469+
DWORD write_err{GetLastError()};
470+
if (write_err != ERROR_IO_PENDING) KJ_FAIL_WIN32("WriteFile(pipe)", write_err);
471+
}
436472
DWORD wr;
437-
KJ_WIN32(WriteFile(pipe, &info, sizeof(info), &wr, nullptr) && wr == sizeof(info), "WriteFile(pipe) failed");
473+
KJ_WIN32(GetOverlappedResult(pipe, &write_ov, &wr, /*bWait=*/TRUE), "WriteFile(pipe) failed");
474+
KJ_REQUIRE(wr == sizeof(info), "WriteFile(pipe): short write");
475+
KJ_WIN32(CloseHandle(write_ov.hEvent), "CloseHandle(write_event)");
438476
KJ_WIN32(CloseHandle(pipe), "CloseHandle(pipe)");
439477

440478
return {pi.hProcess, fds[1]};

‎test/mp/test/spawn_tests.cpp‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
#include <utility>
2424
#include <vector>
2525

26-
#ifndef WIN32
26+
#ifdef WIN32
27+
#include <atomic>
28+
#else
2729
#include <csignal>
2830
#include <sys/wait.h>
2931
#include <unistd.h>
@@ -35,7 +37,38 @@ namespace {
3537

3638
constexpr auto FAILURE_TIMEOUT = std::chrono::seconds{30};
3739

38-
#ifndef WIN32
40+
#ifdef WIN32
41+
KJ_TEST("SpawnProcess does not hang if child never connects to named pipe")
42+
{
43+
// Without FILE_FLAG_OVERLAPPED on the named pipe, ConnectNamedPipe blocks
44+
// forever if the child exits without opening the pipe. Verify SpawnProcess
45+
// detects child exit and throws instead of hanging.
46+
//
47+
// Run in a detached thread so the test suite times out and reports a failure
48+
// instead of hanging indefinitely if the bug is reintroduced.
49+
std::atomic<bool> done{false};
50+
std::thread t([&done] {
51+
try {
52+
auto [process, socket]{SpawnProcess([](std::string) -> std::vector<std::string> {
53+
// A child that exits immediately without opening the named pipe.
54+
return {"cmd.exe", "/c", "exit 0"};
55+
})};
56+
CloseHandle(process);
57+
CloseSocket(socket);
58+
} catch (...) {
59+
// Throwing is the expected outcome; blocking forever is not.
60+
}
61+
done.store(true, std::memory_order_relaxed);
62+
});
63+
t.detach();
64+
65+
const auto deadline{std::chrono::steady_clock::now() + FAILURE_TIMEOUT};
66+
while (!done.load(std::memory_order_relaxed) && std::chrono::steady_clock::now() < deadline) {
67+
std::this_thread::sleep_for(std::chrono::milliseconds{10});
68+
}
69+
KJ_EXPECT(done.load(std::memory_order_relaxed), "SpawnProcess hung waiting for child that never connected");
70+
}
71+
#else
3972

4073
// Poll for child process exit using waitpid(..., WNOHANG) until the child exits
4174
// or timeout expires. Returns true if the child exited and status_out was set.

0 commit comments

Comments
 (0)