Skip to content

Commit c31c94c

Browse files
authored
Expose the destination subprocess methods on .pipe() (#1252)
1 parent 4cafc3f commit c31c94c

11 files changed

Lines changed: 829 additions & 59 deletions

File tree

docs/api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ _Returns_: [`Promise<Result>`](#result)
253253

254254
This follows the same syntax as [`execa(file, arguments?, options?)`](#execafile-arguments-options) except both [regular options](#options-1) and [pipe-specific options](#pipeoptions) can be specified.
255255

256+
Like a subprocess, the return value can be [iterated](lines.md#progressive-splitting), [converted to a stream](streams.md#converting-a-subprocess-to-a-stream), or used for [IPC](ipc.md) with the destination subprocess.
257+
256258
[More info.](pipe.md#array-syntax)
257259

258260
### subprocess.pipe\`command\`

docs/pipe.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ const sourceResult = destinationResult.pipedFrom[0];
6666
console.log(sourceResult.stdout); // Full output of `npm run build`
6767
```
6868

69+
## Iterate, stream and IPC
70+
71+
Just like a regular subprocess, the value returned by `subprocess.pipe()` can be [iterated](lines.md#progressive-splitting) over its output lines, [converted to a readable stream](streams.md#converting-a-subprocess-to-a-stream), or used to exchange [IPC messages](ipc.md) with the destination subprocess.
72+
73+
```js
74+
for await (const line of execa`npm run build`.pipe`sort`) {
75+
console.log(line);
76+
}
77+
```
78+
6979
## Errors
7080

7181
When either subprocess fails, `subprocess.pipe()` is rejected with that subprocess' error. If the destination subprocess fails, [`error.pipedFrom`](api.md#resultpipedfrom) includes the source subprocess' result, which is useful for debugging.

lib/ipc/get-each.js

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@ import {validateIpcMethod, disconnect, getStrictResponseError} from './validatio
33
import {getIpcEmitter, isConnected} from './forward.js';
44
import {addReference, removeReference} from './reference.js';
55

6+
export const internalGetEachMessageOptions = Symbol('internalGetEachMessageOptions');
7+
68
// Like `[sub]process.on('message')` but promise-based
7-
export const getEachMessage = ({anyProcess, channel, isSubprocess, ipc}, {reference = true} = {}) => loopOnMessages({
8-
anyProcess,
9-
channel,
10-
isSubprocess,
11-
ipc,
12-
shouldAwait: !isSubprocess,
13-
reference,
14-
});
9+
export const getEachMessage = (subprocessInfo, options = {}) => {
10+
const {reference = true} = options;
11+
// Internal callers can stop the IPC listener without exposing cancellation or pipe-awaiting options as public API.
12+
const {signal, shouldAwait = !subprocessInfo.isSubprocess} = options[internalGetEachMessageOptions] ?? {};
13+
14+
return loopOnMessages({
15+
...subprocessInfo,
16+
shouldAwait,
17+
reference,
18+
signal,
19+
});
20+
};
1521

1622
// Same but used internally
17-
export const loopOnMessages = ({anyProcess, channel, isSubprocess, ipc, shouldAwait, reference}) => {
23+
export const loopOnMessages = ({anyProcess, channel, isSubprocess, ipc, shouldAwait, reference, signal}) => {
1824
validateIpcMethod({
1925
methodName: 'getEachMessage',
2026
isSubprocess,
@@ -26,6 +32,7 @@ export const loopOnMessages = ({anyProcess, channel, isSubprocess, ipc, shouldAw
2632
const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess);
2733
const controller = new AbortController();
2834
const state = {};
35+
stopOnAbort(signal, controller);
2936
stopOnDisconnect(anyProcess, ipcEmitter, controller);
3037
abortOnStrictError({
3138
ipcEmitter,
@@ -45,6 +52,22 @@ export const loopOnMessages = ({anyProcess, channel, isSubprocess, ipc, shouldAw
4552
});
4653
};
4754

55+
const stopOnAbort = (signal, controller) => {
56+
if (signal === undefined) {
57+
return;
58+
}
59+
60+
if (signal.aborted) {
61+
controller.abort();
62+
return;
63+
}
64+
65+
// Reuse the iterator controller for listener cleanup when iteration ends before the internal signal aborts.
66+
signal.addEventListener('abort', () => {
67+
controller.abort();
68+
}, {once: true, signal: controller.signal});
69+
};
70+
4871
const stopOnDisconnect = async (anyProcess, ipcEmitter, controller) => {
4972
try {
5073
await once(ipcEmitter, 'disconnect', {signal: controller.signal});

lib/ipc/get-one.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ import {
88
import {getIpcEmitter, isConnected} from './forward.js';
99
import {addReference, removeReference} from './reference.js';
1010

11+
export const internalGetOneMessageOptions = Symbol('internalGetOneMessageOptions');
12+
1113
// Like `[sub]process.once('message')` but promise-based
12-
export const getOneMessage = ({anyProcess, channel, isSubprocess, ipc}, {reference = true, filter} = {}) => {
14+
export const getOneMessage = ({anyProcess, channel, isSubprocess, ipc}, options = {}) => {
15+
const {reference = true, filter} = options;
16+
const {signal} = options[internalGetOneMessageOptions] ?? {};
17+
1318
validateIpcMethod({
1419
methodName: 'getOneMessage',
1520
isSubprocess,
@@ -23,13 +28,16 @@ export const getOneMessage = ({anyProcess, channel, isSubprocess, ipc}, {referen
2328
isSubprocess,
2429
filter,
2530
reference,
31+
signal,
2632
});
2733
};
2834

29-
const getOneMessageAsync = async ({anyProcess, channel, isSubprocess, filter, reference}) => {
35+
const getOneMessageAsync = async ({anyProcess, channel, isSubprocess, filter, reference, signal}) => {
3036
addReference(channel, reference);
3137
const ipcEmitter = getIpcEmitter(anyProcess, channel, isSubprocess);
3238
const controller = new AbortController();
39+
stopOnAbort(signal, controller);
40+
3341
try {
3442
return await Promise.race([
3543
getMessage(ipcEmitter, filter, controller),
@@ -45,6 +53,21 @@ const getOneMessageAsync = async ({anyProcess, channel, isSubprocess, filter, re
4553
}
4654
};
4755

56+
const stopOnAbort = (signal, controller) => {
57+
if (signal === undefined) {
58+
return;
59+
}
60+
61+
if (signal.aborted) {
62+
controller.abort();
63+
return;
64+
}
65+
66+
signal.addEventListener('abort', () => {
67+
controller.abort();
68+
}, {once: true, signal: controller.signal});
69+
};
70+
4871
const getMessage = async (ipcEmitter, filter, {signal}) => {
4972
if (filter === undefined) {
5073
const [message] = await once(ipcEmitter, 'message', {signal});

lib/pipe/setup.js

Lines changed: 167 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import isPlainObject from 'is-plain-obj';
2+
import {SUBPROCESS_OPTIONS} from '../arguments/fd-options.js';
3+
import {initializeConcurrentStreams} from '../convert/concurrent.js';
4+
import {createIterable} from '../convert/iterable.js';
5+
import {createReadable} from '../convert/readable.js';
6+
import {internalGetOneMessageOptions} from '../ipc/get-one.js';
7+
import {internalGetEachMessageOptions} from '../ipc/get-each.js';
28
import {normalizePipeArguments} from './pipe-arguments.js';
39
import {handlePipeArgumentsError} from './throw.js';
410
import {waitForBothSubprocesses} from './sequence.js';
@@ -15,16 +21,162 @@ export const pipeToSubprocess = (sourceInfo, ...pipeArguments) => {
1521
}
1622

1723
const {destination, ...normalizedInfo} = normalizePipeArguments(sourceInfo, ...pipeArguments);
18-
const promise = handlePipePromise({...normalizedInfo, destination});
24+
const pipeFailureController = new AbortController();
25+
const promise = handlePipePromise({...normalizedInfo, destination, pipeFailureController});
1926
promise.pipe = pipeToSubprocess.bind(undefined, {
2027
...sourceInfo,
2128
source: destination,
2229
sourcePromise: promise,
2330
boundOptions: {},
2431
});
32+
forwardDestinationMethods(promise, destination, pipeFailureController.signal);
2533
return promise;
2634
};
2735

36+
/*
37+
The return value of `.pipe()` exposes the destination subprocess' output, but its iteration and stream conversion methods must await the whole pipe so source failures are propagated too. The destination is `undefined` when `.pipe()` was passed invalid arguments, in which case the promise rejects and there is nothing to forward.
38+
*/
39+
const forwardDestinationMethods = (promise, destination, pipeFailureSignal) => {
40+
if (destination === undefined) {
41+
return;
42+
}
43+
44+
forwardReadableMethods(promise, destination);
45+
forwardIpcMethods(promise, destination, pipeFailureSignal);
46+
};
47+
48+
const forwardReadableMethods = (promise, destination) => {
49+
const subprocessOptions = SUBPROCESS_OPTIONS.get(destination);
50+
SUBPROCESS_OPTIONS.set(promise, subprocessOptions);
51+
promise.stdio = destination.stdio;
52+
promise.all = destination.all;
53+
54+
const {options: {encoding}} = subprocessOptions;
55+
const concurrentStreams = initializeConcurrentStreams();
56+
promise[Symbol.asyncIterator] = createIterable.bind(undefined, promise, encoding, {});
57+
promise.iterable = createIterable.bind(undefined, promise, encoding);
58+
promise.readable = createPipeReadable.bind(undefined, promise, {
59+
subprocess: promise,
60+
concurrentStreams,
61+
encoding,
62+
});
63+
forwardAll(promise, destination);
64+
};
65+
66+
const forwardAll = (promise, destination) => {
67+
if (destination.all === undefined) {
68+
promise.all = undefined;
69+
return;
70+
}
71+
72+
Object.defineProperty(promise, 'all', {
73+
get() {
74+
setAllProperty(promise, destination.all);
75+
const all = promise.readable({from: 'all'});
76+
setAllProperty(promise, all);
77+
return all;
78+
},
79+
enumerable: true,
80+
configurable: true,
81+
});
82+
};
83+
84+
const setAllProperty = (promise, value) => {
85+
Object.defineProperty(promise, 'all', {
86+
value,
87+
writable: true,
88+
enumerable: true,
89+
configurable: true,
90+
});
91+
};
92+
93+
const createPipeReadable = (promise, readableOptions, ...arguments_) => {
94+
const readable = createReadable(readableOptions, ...arguments_);
95+
destroyOnPipeFailure(promise, readable);
96+
return readable;
97+
};
98+
99+
const destroyOnPipeFailure = async (promise, readable) => {
100+
try {
101+
await promise;
102+
} catch (error) {
103+
readable.destroy(error);
104+
}
105+
};
106+
107+
const forwardIpcMethods = (promise, destination, pipeFailureSignal) => {
108+
promise.sendMessage = destination.sendMessage;
109+
promise.getOneMessage = getOnePipeMessage.bind(undefined, destination, pipeFailureSignal);
110+
promise.getEachMessage = getEachPipeMessage.bind(undefined, promise, destination, pipeFailureSignal);
111+
};
112+
113+
const getOnePipeMessage = (destination, pipeFailureSignal, ...arguments_) => {
114+
const controller = new AbortController();
115+
const messagePromise = destination.getOneMessage(...addPipeOptions(arguments_, controller.signal, internalGetOneMessageOptions));
116+
return waitForOnePipeMessage(pipeFailureSignal, messagePromise, controller);
117+
};
118+
119+
const waitForOnePipeMessage = async (pipeFailureSignal, messagePromise, controller) => {
120+
try {
121+
return await Promise.race([messagePromise, getSignalRejection(pipeFailureSignal, controller.signal)]);
122+
} finally {
123+
controller.abort();
124+
}
125+
};
126+
127+
const getSignalRejection = (signal, listenerSignal) => new Promise((_, reject) => {
128+
if (signal.aborted) {
129+
reject(signal.reason);
130+
return;
131+
}
132+
133+
signal.addEventListener('abort', () => {
134+
reject(signal.reason);
135+
}, {once: true, signal: listenerSignal});
136+
});
137+
138+
const getEachPipeMessage = (promise, destination, pipeFailureSignal, ...arguments_) => {
139+
const controller = new AbortController();
140+
// Create the destination iterator before awaiting the pipe so option validation stays synchronous.
141+
const iterator = destination.getEachMessage(...addPipeOptions(arguments_, controller.signal, internalGetEachMessageOptions));
142+
abortOnSignal(pipeFailureSignal, controller);
143+
return iterateOnPipeMessages(promise, iterator, controller);
144+
};
145+
146+
const iterateOnPipeMessages = async function * (promise, iterator, controller) {
147+
try {
148+
yield * iterator;
149+
} finally {
150+
controller.abort();
151+
await promise;
152+
}
153+
};
154+
155+
const addPipeOptions = (arguments_, signal, internalOptionsSymbol) => {
156+
if (arguments_[0] === null) {
157+
// Preserve the public validation error from `getEachMessage(null)` instead of masking it with a pipe failure.
158+
return arguments_;
159+
}
160+
161+
const [options] = arguments_;
162+
// The returned pipe promise is awaited by the forwarded IPC iterator, so the destination IPC iterator must not also await the destination subprocess on close.
163+
return [{...options, [internalOptionsSymbol]: {signal, shouldAwait: false}}];
164+
};
165+
166+
const abortOnSignal = (signal, controller) => {
167+
if (signal.aborted) {
168+
controller.abort();
169+
return;
170+
}
171+
172+
// Interrupt pending IPC reads when the pipe rejects, including `unpipeSignal` cancellation while the destination keeps running.
173+
signal.addEventListener('abort', () => {
174+
controller.abort();
175+
}, {once: true, signal: controller.signal});
176+
};
177+
178+
// `writable()` and `duplex()` are intentionally not forwarded: they write to the destination's `stdin`, which is already being piped from the source.
179+
28180
// Asynchronous logic when piping subprocesses
29181
const handlePipePromise = async ({
30182
sourcePromise,
@@ -37,19 +189,20 @@ const handlePipePromise = async ({
37189
unpipeSignal,
38190
fileDescriptors,
39191
startTime,
192+
pipeFailureController,
40193
}) => {
41-
const subprocessPromises = getSubprocessPromises(sourcePromise, destination);
42-
handlePipeArgumentsError({
43-
sourceStream,
44-
sourceError,
45-
destinationStream,
46-
destinationError,
47-
fileDescriptors,
48-
sourceOptions,
49-
startTime,
50-
});
51194
const maxListenersController = new AbortController();
52195
try {
196+
const subprocessPromises = getSubprocessPromises(sourcePromise, destination);
197+
handlePipeArgumentsError({
198+
sourceStream,
199+
sourceError,
200+
destinationStream,
201+
destinationError,
202+
fileDescriptors,
203+
sourceOptions,
204+
startTime,
205+
});
53206
const mergedStream = pipeSubprocessStream(sourceStream, destinationStream, maxListenersController);
54207
return await Promise.race([
55208
waitForBothSubprocesses(subprocessPromises),
@@ -61,6 +214,9 @@ const handlePipePromise = async ({
61214
startTime,
62215
}),
63216
]);
217+
} catch (error) {
218+
pipeFailureController.abort(error);
219+
throw error;
64220
} finally {
65221
maxListenersController.abort();
66222
}

0 commit comments

Comments
 (0)