Skip to content

Commit 5f80cde

Browse files
committed
Fix IPC edge cases
1 parent d3ec10d commit 5f80cde

21 files changed

Lines changed: 264 additions & 14 deletions

‎docs/ipc.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ import {execaNode} from 'execa';
184184
await execaNode({serialization: 'json'})`child.js`;
185185
```
186186

187+
Objects with a `type` property starting with `execa:ipc:` are reserved for Execa's internal messages, and should not be sent.
188+
187189
## Messages order
188190

189191
The messages are always received in the same order they were sent. Even when sent all at once.

‎lib/ipc/forward.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const getIpcEmitter = (anyProcess, channel, isSubprocess) => {
1313
// Use an `EventEmitter`, like the `process` that is being proxied
1414
// eslint-disable-next-line unicorn/prefer-event-target
1515
const ipcEmitter = new EventEmitter();
16+
// Each pending `getOneMessage()` or `getEachMessage()` call adds listeners, which are removed once it ends. Users cannot reach this emitter to increase its maximum number of listeners.
17+
ipcEmitter.setMaxListeners(0);
1618
ipcEmitter.connected = true;
1719
IPC_EMITTERS.set(anyProcess, ipcEmitter);
1820
forwardEvents({

‎lib/ipc/get-each.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const loopOnMessages = ({anyProcess, waitProcess = anyProcess, channel, i
5656
controller,
5757
state,
5858
reference,
59+
signal,
5960
});
6061
};
6162

@@ -90,7 +91,7 @@ const abortOnStrictError = async ({ipcEmitter, isSubprocess, controller, state})
9091
} catch {}
9192
};
9293

93-
const iterateOnMessages = async function * ({anyProcess, waitProcess, channel, ipcEmitter, isSubprocess, shouldAwait, controller, state, reference}) {
94+
const iterateOnMessages = async function * ({anyProcess, waitProcess, channel, ipcEmitter, isSubprocess, shouldAwait, controller, state, reference, signal}) {
9495
try {
9596
for await (const [message] of on(ipcEmitter, 'message', {signal: controller.signal})) {
9697
throwIfStrictError(state);
@@ -102,7 +103,8 @@ const iterateOnMessages = async function * ({anyProcess, waitProcess, channel, i
102103
controller.abort();
103104
removeReference(channel, reference);
104105

105-
if (!isSubprocess) {
106+
// When the internal `signal` stops the iteration, e.g. when a `.pipe()` is canceled, the subprocess keeps running and can still use IPC
107+
if (!isSubprocess && !signal?.aborted) {
106108
disconnect(anyProcess);
107109
}
108110

‎lib/ipc/get-one.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ const getOneMessageAsync = async ({anyProcess, channel, isSubprocess, filter, re
4747
throwOnStrictError(ipcEmitter, isSubprocess, controller),
4848
]);
4949
} catch (error) {
50-
disconnect(anyProcess);
50+
// When the internal `signal` stops the read, e.g. when a `.pipe()` is canceled, the subprocess keeps running and can still use IPC
51+
if (!signal?.aborted) {
52+
disconnect(anyProcess);
53+
}
54+
5155
throw error;
5256
} finally {
5357
controller.abort();

‎lib/ipc/graceful.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ const startIpc = async ({anyProcess, channel, isSubprocess, ipc}) => {
4141

4242
isCancelListening = true;
4343

44-
if (channel === null) {
44+
// `channel` is retrieved when Execa is imported, so the current process might have disconnected since then
45+
if (!anyProcess.connected) {
4546
abortOnDisconnect();
4647
return;
4748
}

‎lib/ipc/send.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const sendMessageAsync = async ({anyProcess, channel, methodName, isSubprocess,
4949
isSubprocess,
5050
wrappedMessage,
5151
message,
52+
strict,
5253
});
5354
} catch (error) {
5455
disconnect(anyProcess);
@@ -59,12 +60,13 @@ const sendMessageAsync = async ({anyProcess, channel, methodName, isSubprocess,
5960
};
6061

6162
// Used internally by `cancelSignal`
62-
export const sendOneMessage = async ({anyProcess, methodName, isSubprocess, wrappedMessage, message}) => {
63+
export const sendOneMessage = async ({anyProcess, methodName, isSubprocess, wrappedMessage, message, strict = false}) => {
6364
const sendMethod = getSendMethod(anyProcess);
6465

6566
try {
6667
await Promise.all([
67-
waitForStrictResponse(wrappedMessage, anyProcess, isSubprocess),
68+
// Only messages sent with the `strict` option wait for an acknowledgment response
69+
strict ? waitForStrictResponse(wrappedMessage, anyProcess, isSubprocess) : undefined,
6870
sendMethod(wrappedMessage),
6971
]);
7072
} catch (error) {

‎lib/ipc/strict.js‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ let count = 0;
2828
// Handles when both processes are calling `sendMessage()` with `strict` at the same time.
2929
// If neither process is listening, this would create a deadlock. We detect it and throw.
3030
export const validateStrictDeadlock = (outgoingMessages, wrappedMessage) => {
31-
if (wrappedMessage?.type !== REQUEST_TYPE || wrappedMessage.hasListeners) {
31+
if (!isStrictRequest(wrappedMessage) || wrappedMessage.hasListeners) {
3232
return;
3333
}
3434

@@ -41,11 +41,16 @@ export const validateStrictDeadlock = (outgoingMessages, wrappedMessage) => {
4141

4242
// The other process then sends the acknowledgment back as a response
4343
export const handleStrictRequest = async ({wrappedMessage, anyProcess, channel, isSubprocess, ipcEmitter}) => {
44-
if (wrappedMessage?.type !== REQUEST_TYPE || !anyProcess.connected) {
44+
if (!isStrictRequest(wrappedMessage)) {
4545
return wrappedMessage;
4646
}
4747

4848
const {id, message} = wrappedMessage;
49+
// The acknowledgment cannot be sent anymore, but the message is still delivered
50+
if (!anyProcess.connected) {
51+
return message;
52+
}
53+
4954
const response = {id, type: RESPONSE_TYPE, message: hasMessageListeners(anyProcess, ipcEmitter)};
5055

5156
try {
@@ -56,7 +61,10 @@ export const handleStrictRequest = async ({wrappedMessage, anyProcess, channel,
5661
ipc: true,
5762
}, response);
5863
} catch (error) {
59-
ipcEmitter.emit('strict:error', error);
64+
// When the other process disconnects right after sending the message, it cannot receive the acknowledgment anymore. Like above, the message is still delivered.
65+
if (error.cause?.code !== 'EPIPE') {
66+
ipcEmitter.emit('strict:error', error);
67+
}
6068
}
6169

6270
return message;
@@ -80,10 +88,6 @@ export const handleStrictResponse = (wrappedMessage, anyProcess) => {
8088

8189
// Wait for the other process to receive the message from `sendMessage()`
8290
export const waitForStrictResponse = async (wrappedMessage, anyProcess, isSubprocess) => {
83-
if (wrappedMessage?.type !== REQUEST_TYPE) {
84-
return;
85-
}
86-
8791
const deferred = createDeferred();
8892
STRICT_RESPONSES[wrappedMessage.id] = {deferred, anyProcess};
8993
const controller = new AbortController();
@@ -117,3 +121,9 @@ const throwOnDisconnect = async (anyProcess, isSubprocess, {signal}) => {
117121

118122
const REQUEST_TYPE = 'execa:ipc:request';
119123
const RESPONSE_TYPE = 'execa:ipc:response';
124+
125+
// `id` and `hasListeners` are always defined in messages sent by `sendMessage()` with the `strict` option.
126+
// Messages with the same `type` but without them are user messages, which must be delivered as is.
127+
const isStrictRequest = wrappedMessage => wrappedMessage?.type === REQUEST_TYPE
128+
&& Number.isInteger(wrappedMessage.id)
129+
&& typeof wrappedMessage.hasListeners === 'boolean';

‎lib/ipc/validation.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import {inspect} from 'node:util';
2+
13
// Validate the IPC channel is connected before receiving/sending messages
24
export const validateIpcMethod = ({methodName, isSubprocess, ipc, isConnected}) => {
35
validateIpcOption(methodName, isSubprocess, ipc);
@@ -68,7 +70,16 @@ export const handleEpipeError = ({error, methodName, isSubprocess}) => {
6870
// Works with both `serialization: 'advanced'` and `serialization: 'json'`.
6971
export const handleSerializationError = ({error, methodName, isSubprocess, message}) => {
7072
if (isSerializationError(error)) {
71-
throw new Error(`${getMethodName(methodName, isSubprocess)}'s argument type is invalid: the message cannot be serialized: ${String(message)}.`, {cause: error});
73+
throw new Error(`${getMethodName(methodName, isSubprocess)}'s argument type is invalid: the message cannot be serialized: ${serializeInvalidMessage(message)}.`, {cause: error});
74+
}
75+
};
76+
77+
// `String()` throws with objects that have a `null` prototype or a throwing `toString()` method
78+
const serializeInvalidMessage = message => {
79+
try {
80+
return String(message);
81+
} catch {
82+
return inspect(message);
7283
}
7384
};
7485

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env node
2+
import process from 'node:process';
3+
import {once} from 'node:events';
4+
import {onAbortedSignal} from '../helpers/graceful.js';
5+
import {getCancelSignal} from 'execa';
6+
7+
// Unlike `graceful-print.js`, Execa is imported before the parent process disconnects
8+
process.send('.');
9+
await once(process, 'disconnect');
10+
const cancelSignal = await getCancelSignal();
11+
await onAbortedSignal(cancelSignal);
12+
console.log(cancelSignal.reason);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env node
2+
import {argv} from 'node:process';
3+
import {getOneMessage} from '../../index.js';
4+
5+
const messages = await Promise.all(Array.from({length: Number(argv[2])}, () => getOneMessage()));
6+
console.log(messages.length);

0 commit comments

Comments
 (0)