Skip to content

Commit 5ed7523

Browse files
author
Mohamed Sayed
committed
child_process: emit 'close' after explicit disconnect()
An explicit subprocess.disconnect() closed the IPC channel without counting it towards the subprocess 'close' event, so 'close' was never emitted once the child exited. Route every channel teardown through the same finish() so the accounting happens where the channel is closed, and drop the handle queue on EOF so the disconnect is not postponed forever. Fixes: #19433 Refs: #19566 Signed-off-by: Mohamed Sayed <k@3zrv.com>
1 parent e7a6370 commit 5ed7523

3 files changed

Lines changed: 170 additions & 13 deletions

File tree

‎lib/internal/child_process.js‎

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,28 @@ function closePendingHandle(target) {
351351
target._pendingMessage = null;
352352
}
353353

354+
// Drops the messages queued behind a handle that is still waiting for an
355+
// acknowledgement. Used when the channel is gone for good, so that they get
356+
// the same error as a message sent over a closed channel.
357+
function clearHandleQueue(target) {
358+
const queue = target._handleQueue;
359+
target._handleQueue = null;
360+
361+
for (let i = 0; i < queue.length; i++) {
362+
const { callback, options } = queue[i];
363+
364+
if (options.swallowErrors)
365+
continue;
366+
367+
const ex = new ERR_IPC_CHANNEL_CLOSED();
368+
if (typeof callback === 'function') {
369+
process.nextTick(callback, ex);
370+
} else {
371+
process.nextTick(() => target.emit('error', ex));
372+
}
373+
}
374+
}
375+
354376

355377
ChildProcess.prototype.spawn = function spawn(options) {
356378
let i = 0;
@@ -662,14 +684,40 @@ function setupChannel(target, channel, serializationMode) {
662684
}
663685
} else {
664686
this.buffering = false;
665-
target.disconnect();
666687
channel.onread = nop;
667-
channel.close();
668-
target.channel = null;
669-
maybeClose(target);
688+
689+
// The other side has closed the channel: nothing can be sent or
690+
// received anymore, so do not wait for the acknowledgement of a
691+
// pending handle before disconnecting.
692+
if (target._handleQueue)
693+
clearHandleQueue(target);
694+
695+
if (target.connected) {
696+
target.disconnect();
697+
} else if (target.channel) {
698+
// disconnect() was called but was postponed by the handle queue.
699+
target._disconnect();
700+
} else {
701+
// disconnect() was called but was postponed until the message being
702+
// read completes, which is not going to happen anymore.
703+
finish();
704+
}
670705
}
671706
};
672707

708+
// Closes the channel for good and does the bookkeeping that depends on it:
709+
// emit 'disconnect' and count the channel towards the 'close' event of the
710+
// subprocess. Every way of tearing down the channel ends up here.
711+
let finished = false;
712+
function finish() {
713+
if (finished) return;
714+
finished = true;
715+
716+
channel.close();
717+
target.emit('disconnect');
718+
maybeClose(target);
719+
}
720+
673721
// Object where socket lists will live
674722
channel.sockets = { got: {}, send: {} };
675723

@@ -948,15 +996,6 @@ function setupChannel(target, channel, serializationMode) {
948996
if (this._pendingMessage)
949997
closePendingHandle(this);
950998

951-
let fired = false;
952-
function finish() {
953-
if (fired) return;
954-
fired = true;
955-
956-
channel.close();
957-
target.emit('disconnect');
958-
}
959-
960999
// If a message is being read, then wait for it to complete.
9611000
if (channel.buffering) {
9621001
this.once('message', finish);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use strict';
2+
// Tests that when the IPC channel is closed by the other side while messages
3+
// are still queued behind a handle waiting for its acknowledgement, the queue
4+
// is dropped (callbacks are called with ERR_IPC_CHANNEL_CLOSED, or 'error' is
5+
// emitted for messages without a callback) and 'close' is still emitted.
6+
const common = require('../common');
7+
const assert = require('assert');
8+
const net = require('net');
9+
const { fork } = require('child_process');
10+
const fixtures = require('../common/fixtures');
11+
12+
const server = net.createServer().listen(0, common.mustCall(() => {
13+
// The child exits without ever reading from the channel, so the handle is
14+
// never acknowledged and everything sent after it stays queued.
15+
const child = fork(fixtures.path('exit.js'), ['7']);
16+
17+
let gotExit = false;
18+
let gotClose = false;
19+
20+
// The handle itself is written right away.
21+
child.send('handle', server, common.mustCall((err) => {
22+
assert.strictEqual(err, null);
23+
}));
24+
25+
// Queued behind the handle: with a callback...
26+
assert.strictEqual(
27+
child.send('queued', common.mustCall((err) => {
28+
assert.strictEqual(err.code, 'ERR_IPC_CHANNEL_CLOSED');
29+
assert.strictEqual(gotClose, false);
30+
})),
31+
true,
32+
);
33+
// ... without a callback ...
34+
assert.strictEqual(child.send('queued'), false);
35+
// ... and with errors swallowed.
36+
assert.strictEqual(child.send('queued', undefined, { swallowErrors: true }), false);
37+
38+
child.on('error', common.mustCall((err) => {
39+
assert.strictEqual(err.code, 'ERR_IPC_CHANNEL_CLOSED');
40+
assert.strictEqual(gotClose, false);
41+
}));
42+
43+
child.on('disconnect', common.mustCall(() => {
44+
assert.strictEqual(child._handleQueue, null);
45+
assert.strictEqual(child._pendingMessage, null);
46+
}));
47+
48+
child.on('exit', common.mustCall((code, signal) => {
49+
gotExit = true;
50+
assert.strictEqual(code, 7);
51+
assert.strictEqual(signal, null);
52+
}));
53+
54+
child.on('close', common.mustCall((code, signal) => {
55+
gotClose = true;
56+
assert.strictEqual(gotExit, true);
57+
assert.strictEqual(code, 7);
58+
assert.strictEqual(signal, null);
59+
assert.strictEqual(child.connected, false);
60+
assert.strictEqual(child.channel, null);
61+
server.close();
62+
}));
63+
}));
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use strict';
2+
// Regression test for https://github.com/nodejs/node/issues/19433:
3+
// after the parent explicitly calls subprocess.disconnect(), the subprocess
4+
// must still emit 'close' (exactly once) when it exits and its stdio closes,
5+
// regardless of whether it exits on its own or is killed.
6+
const common = require('../common');
7+
const assert = require('assert');
8+
const { fork } = require('child_process');
9+
10+
if (process.argv[2] === 'child') {
11+
const mode = process.argv[3];
12+
// Keep the event loop alive until the parent disconnects (or kills us).
13+
const timer = setInterval(() => {}, 1000);
14+
process.on('disconnect', () => {
15+
clearInterval(timer);
16+
if (mode === 'self-exit')
17+
process.exit(42);
18+
});
19+
process.send('ready');
20+
return;
21+
}
22+
23+
function test(mode, expectedCode, expectedSignal) {
24+
const child = fork(__filename, ['child', mode]);
25+
const events = [];
26+
27+
child.on('disconnect', common.mustCall(() => {
28+
events.push('disconnect');
29+
assert.strictEqual(child.connected, false);
30+
assert.strictEqual(child.channel, null);
31+
}));
32+
33+
child.on('exit', common.mustCall((code, signal) => {
34+
events.push('exit');
35+
assert.strictEqual(code, expectedCode);
36+
assert.strictEqual(signal, expectedSignal);
37+
}));
38+
39+
child.on('close', common.mustCall((code, signal) => {
40+
events.push('close');
41+
assert.strictEqual(code, expectedCode);
42+
assert.strictEqual(signal, expectedSignal);
43+
assert.deepStrictEqual(events, ['disconnect', 'exit', 'close']);
44+
}));
45+
46+
child.once('message', common.mustCall((message) => {
47+
assert.strictEqual(message, 'ready');
48+
child.disconnect();
49+
if (mode === 'kill')
50+
child.kill('SIGKILL');
51+
}));
52+
}
53+
54+
test('self-exit', 42, null);
55+
test('kill', null, 'SIGKILL');

0 commit comments

Comments
 (0)