Skip to content

Commit f5222e6

Browse files
committed
child_process: watch child_process stdin pipe peer close event
Watch the child end of the stdin pipe for peer-close (EOF) events, only supported on non-Windows platforms. When the child closes its end of the pipe, subprocess.stdin is destroyed so that its 'close' event is emitted, instead of only being reported as an EPIPE error on the next write. Fixes: #25131 Assisted-by: Claude Code Signed-off-by: TseIan <tseianxwc@gmail.com>
1 parent e8c1160 commit f5222e6

6 files changed

Lines changed: 203 additions & 4 deletions

File tree

‎doc/api/child_process.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2205,6 +2205,11 @@ until this stream has been closed via `end()`.
22052205
If the child process was spawned with `stdio[0]` set to anything other than `'pipe'`,
22062206
then this will be `null`.
22072207
2208+
On non-Windows platforms, when `stdio[0]` is `'pipe'`, Node.js watches for the
2209+
child process closing its end of the stdin pipe and destroys `subprocess.stdin`
2210+
when that happens. This helps surface pipe peer-close semantics consistently for
2211+
the writable side of the stream.
2212+
22082213
`subprocess.stdin` is an alias for `subprocess.stdio[0]`. Both properties will
22092214
refer to the same value.
22102215

‎lib/internal/child_process.js‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,15 @@ function flushStdio(subprocess) {
362362
}
363363

364364

365-
function createSocket(pipe, readable) {
366-
return net.Socket({ handle: pipe, readable });
365+
function createSocket(pipe, readable, watchPeerClose) {
366+
const sock = net.Socket({ handle: pipe, readable });
367+
if (watchPeerClose &&
368+
process.platform !== 'win32' &&
369+
typeof pipe?.watchPeerClose === 'function') {
370+
pipe.watchPeerClose(true, () => sock.destroy());
371+
sock.once('close', () => pipe.watchPeerClose(false));
372+
}
373+
return sock;
367374
}
368375

369376

@@ -519,7 +526,7 @@ ChildProcess.prototype.spawn = function spawn(options) {
519526

520527
if (stream.handle) {
521528
stream.socket = createSocket(this.pid !== 0 ?
522-
stream.handle : null, i > 0);
529+
stream.handle : null, i > 0, i === 0);
523530

524531
if (i > 0 && this.pid !== 0) {
525532
this._closesNeeded++;

‎src/pipe_wrap.cc‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ void PipeWrap::Initialize(Local<Object> target,
8282
SetProtoMethod(isolate, t, "listen", Listen);
8383
SetProtoMethod(isolate, t, "connect", Connect);
8484
SetProtoMethod(isolate, t, "open", Open);
85+
SetProtoMethod(isolate, t, "watchPeerClose", WatchPeerClose);
8586

8687
#ifdef _WIN32
8788
SetProtoMethod(isolate, t, "setPendingInstances", SetPendingInstances);
@@ -114,6 +115,7 @@ void PipeWrap::RegisterExternalReferences(ExternalReferenceRegistry* registry) {
114115
registry->Register(Listen);
115116
registry->Register(Connect);
116117
registry->Register(Open);
118+
registry->Register(WatchPeerClose);
117119
#ifdef _WIN32
118120
registry->Register(SetPendingInstances);
119121
#endif
@@ -231,6 +233,103 @@ void PipeWrap::Open(const FunctionCallbackInfo<Value>& args) {
231233
args.GetReturnValue().Set(err);
232234
}
233235

236+
void PipeWrap::WatchPeerClose(const FunctionCallbackInfo<Value>& args) {
237+
PipeWrap* wrap;
238+
ASSIGN_OR_RETURN_UNWRAP(&wrap, args.This());
239+
240+
CHECK_GT(args.Length(), 0);
241+
CHECK(args[0]->IsBoolean());
242+
const bool enable = args[0].As<v8::Boolean>()->Value();
243+
Environment* env = wrap->env();
244+
Isolate* isolate = env->isolate();
245+
v8::HandleScope handle_scope(isolate);
246+
v8::Context::Scope context_scope(env->context());
247+
Local<Object> obj = wrap->object();
248+
249+
// UnwatchPeerClose
250+
if (!enable) {
251+
if (obj->GetInternalField(kPeerCloseCallbackField)
252+
.As<Value>()
253+
->IsUndefined()) {
254+
return;
255+
}
256+
257+
obj->SetInternalField(kPeerCloseCallbackField, v8::Undefined(isolate));
258+
uv_read_stop(wrap->stream());
259+
return;
260+
}
261+
262+
if (!wrap->IsAlive()) {
263+
return;
264+
}
265+
if (!obj->GetInternalField(kPeerCloseCallbackField)
266+
.As<Value>()
267+
->IsUndefined()) {
268+
return;
269+
}
270+
271+
CHECK_GT(args.Length(), 1);
272+
CHECK(args[1]->IsFunction());
273+
274+
// Store the JS callback in an internal field.
275+
obj->SetInternalField(kPeerCloseCallbackField, args[1]);
276+
277+
// Start reading to detect EOF/ECONNRESET from the peer.
278+
// We use our custom allocator and reader, ignoring actual data.
279+
int err = uv_read_start(wrap->stream(), PeerCloseAlloc, PeerCloseRead);
280+
if (err != 0) {
281+
obj->SetInternalField(kPeerCloseCallbackField, v8::Undefined(isolate));
282+
}
283+
}
284+
285+
void PipeWrap::PeerCloseAlloc(uv_handle_t* handle,
286+
size_t suggested_size,
287+
uv_buf_t* buf) {
288+
// We only care about EOF, not the actual data.
289+
// Using a static 1-byte buffer avoids dynamic memory allocation overhead.
290+
static char scratch;
291+
*buf = uv_buf_init(&scratch, 1);
292+
}
293+
294+
void PipeWrap::PeerCloseRead(uv_stream_t* stream,
295+
ssize_t nread,
296+
const uv_buf_t* buf) {
297+
PipeWrap* wrap = static_cast<PipeWrap*>(stream->data);
298+
if (wrap == nullptr) return;
299+
300+
// Ignore actual data reads or EAGAIN (0). We only watch for disconnects.
301+
if (nread >= 0) return;
302+
303+
// Wait specifically for EOF or connection reset (peer closed).
304+
if (nread != UV_EOF && nread != UV_ECONNRESET) return;
305+
306+
// Peer has closed the connection. Stop reading immediately.
307+
uv_read_stop(stream);
308+
309+
Environment* env = wrap->env();
310+
Isolate* isolate = env->isolate();
311+
312+
// Set up V8 context and handles to safely execute the JS callback.
313+
v8::HandleScope handle_scope(isolate);
314+
v8::Context::Scope context_scope(env->context());
315+
Local<Object> obj = wrap->object();
316+
317+
// Check if callback is set
318+
if (obj->GetInternalField(kPeerCloseCallbackField)
319+
.As<Value>()
320+
->IsUndefined()) {
321+
return;
322+
}
323+
Local<Value> cb_value =
324+
obj->GetInternalField(kPeerCloseCallbackField).As<Value>();
325+
Local<Function> cb = cb_value.As<Function>();
326+
// Reset before calling to prevent re-entrancy issues
327+
obj->SetInternalField(kPeerCloseCallbackField, v8::Undefined(isolate));
328+
329+
// MakeCallback properly tracks AsyncHooks context and flushes microtasks.
330+
wrap->MakeCallback(cb, 0, nullptr);
331+
}
332+
234333
void PipeWrap::Connect(const FunctionCallbackInfo<Value>& args) {
235334
Environment* env = Environment::GetCurrent(args);
236335

‎src/pipe_wrap.h‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ class PipeWrap : public ConnectionWrap<PipeWrap, uv_pipe_t> {
4040
IPC
4141
};
4242

43+
enum InternalFields {
44+
kPeerCloseCallbackField = LibuvStreamWrap::kInternalFieldCount,
45+
kInternalFieldCount
46+
};
47+
4348
static v8::MaybeLocal<v8::Object> Instantiate(Environment* env,
4449
AsyncWrap* parent,
4550
SocketType type);
@@ -64,6 +69,13 @@ class PipeWrap : public ConnectionWrap<PipeWrap, uv_pipe_t> {
6469
static void Listen(const v8::FunctionCallbackInfo<v8::Value>& args);
6570
static void Connect(const v8::FunctionCallbackInfo<v8::Value>& args);
6671
static void Open(const v8::FunctionCallbackInfo<v8::Value>& args);
72+
static void WatchPeerClose(const v8::FunctionCallbackInfo<v8::Value>& args);
73+
static void PeerCloseAlloc(uv_handle_t* handle,
74+
size_t suggested_size,
75+
uv_buf_t* buf);
76+
static void PeerCloseRead(uv_stream_t* stream,
77+
ssize_t nread,
78+
const uv_buf_t* buf);
6779

6880
#ifdef _WIN32
6981
static void SetPendingInstances(

‎test/async-hooks/test-pipewrap.js‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const processwrap = processes[0];
3535
const pipe1 = pipes[0];
3636
const pipe2 = pipes[1];
3737
const pipe3 = pipes[2];
38+
const pipe1ExpectedInvocations = process.platform === 'win32' ? 1 : 2;
3839

3940
assert.strictEqual(processwrap.type, 'PROCESSWRAP');
4041
assert.strictEqual(processwrap.triggerAsyncId, 1);
@@ -83,7 +84,11 @@ function onexit() {
8384
// Usually it is just one event, but it can be more.
8485
assert.ok(ioEvents >= 3, `at least 3 stdout io events, got ${ioEvents}`);
8586

86-
checkInvocations(pipe1, { init: 1, before: 1, after: 1 },
87+
checkInvocations(pipe1, {
88+
init: 1,
89+
before: pipe1ExpectedInvocations,
90+
after: pipe1ExpectedInvocations,
91+
},
8792
'pipe wrap when sleep.spawn was called');
8893
checkInvocations(pipe2, { init: 1, before: ioEvents, after: ioEvents },
8994
'pipe wrap when sleep.spawn was called');
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const { spawn } = require('child_process');
6+
7+
if (common.isWindows) {
8+
common.skip('Not applicable on Windows');
9+
}
10+
11+
function spawnChild(script) {
12+
return spawn(process.execPath, ['-e', script], {
13+
stdio: ['pipe', 'ignore', 'ignore'],
14+
});
15+
}
16+
17+
function runTest({ script, onSpawn }, done) {
18+
const child = spawnChild(script);
19+
20+
const timeout = setTimeout(() => {
21+
assert.fail('stdin close event was not emitted');
22+
}, 2000);
23+
24+
let closed = false;
25+
let exited = false;
26+
function maybeDone() {
27+
if (!closed || !exited) return;
28+
clearTimeout(timeout);
29+
done();
30+
}
31+
32+
child.stdin.once('close', common.mustCall(() => {
33+
closed = true;
34+
child.kill();
35+
maybeDone();
36+
}));
37+
38+
child.once('exit', common.mustCall(() => {
39+
exited = true;
40+
maybeDone();
41+
}));
42+
43+
onSpawn?.(child);
44+
}
45+
46+
runTest({
47+
script: 'setTimeout(() => require("fs").closeSync(0), 50); setTimeout(() => {}, 2000)',
48+
}, common.mustCall(() => {
49+
runTest({
50+
script: 'setTimeout(() => require("fs").closeSync(0), 200); setTimeout(() => {}, 2000)',
51+
onSpawn: common.mustCall((child) => {
52+
const handle = child.stdin?._handle;
53+
assert.strictEqual(typeof handle?.watchPeerClose, 'function');
54+
handle.watchPeerClose(true, common.mustNotCall());
55+
}),
56+
}, common.mustCall(() => {
57+
runTest({
58+
script: 'setTimeout(() => {}, 2000)',
59+
onSpawn: common.mustCall((child) => {
60+
const handle = child.stdin?._handle;
61+
assert.strictEqual(typeof handle?.watchPeerClose, 'function');
62+
63+
child.stdin.once('close', common.mustCall(() => {
64+
// Calling watchPeerClose again after close must not throw.
65+
handle.watchPeerClose(true, common.mustNotCall());
66+
}));
67+
child.stdin.destroy();
68+
}),
69+
}, common.mustCall());
70+
}));
71+
}));

0 commit comments

Comments
 (0)