Skip to content

Commit c295062

Browse files
authoredAug 8, 2021
Stop reading from stdin after programmatic API finishes (#253)
1 parent 07a7de1 commit c295062

22 files changed

+186
-83
lines changed
 

‎README.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -228,15 +228,19 @@ For more details, visit https://github.com/open-cli-tools/concurrently
228228
concurrently can be used programmatically by using the API documented below:
229229

230230
### `concurrently(commands[, options])`
231+
231232
- `commands`: an array of either strings (containing the commands to run) or objects
232233
with the shape `{ command, name, prefixColor, env, cwd }`.
234+
233235
- `options` (optional): an object containing any of the below:
234236
- `cwd`: the working directory to be used by all commands. Can be overriden per command.
235237
Default: `process.cwd()`.
236238
- `defaultInputTarget`: the default input target when reading from `inputStream`.
237239
Default: `0`.
240+
- `handleInput`: when `true`, reads input from `process.stdin`.
238241
- `inputStream`: a [`Readable` stream](https://nodejs.org/dist/latest-v10.x/docs/api/stream.html#stream_readable_streams)
239-
to read the input from, eg `process.stdin`.
242+
to read the input from. Should only be used in the rare instance you would like to stream anything other than `process.stdin`. Overrides `handleInput`.
243+
- `pauseInputStreamOnFinish`: by default, pauses the input stream (`process.stdin` when `handleInput` is enabled, or `inputStream` if provided) when all of the processes have finished. If you need to read from the input stream after `concurrently` has finished, set this to `false`. ([#252](https://github.com/kimmobrunfeldt/concurrently/issues/252)).
240244
- `killOthers`: an array of exitting conditions that will cause a process to kill others.
241245
Can contain any of `success` or `failure`.
242246
- `maxProcesses`: how many processes should run at once.

‎bin/concurrently.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ concurrently(args._.map((command, index) => {
157157
name: names[index]
158158
};
159159
}), {
160-
inputStream: args.handleInput && process.stdin,
160+
handleInput: args.handleInput,
161161
defaultInputTarget: args.defaultInputTarget,
162162
killOthers: args.killOthers
163163
? ['success', 'failure']

‎bin/concurrently.spec.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ describe('--names', () => {
193193
});
194194

195195
describe('--prefix', () => {
196-
it('is alised to -p', done => {
196+
it('is aliased to -p', done => {
197197
const child = run('-p command "echo foo" "echo bar"');
198198
child.log.pipe(buffer(child.close)).subscribe(lines => {
199199
expect(lines).toContainEqual(expect.stringContaining('[echo foo] foo'));
@@ -213,7 +213,7 @@ describe('--prefix', () => {
213213
});
214214

215215
describe('--prefix-length', () => {
216-
it('is alised to -l', done => {
216+
it('is aliased to -l', done => {
217217
const child = run('-p command -l 5 "echo foo" "echo bar"');
218218
child.log.pipe(buffer(child.close)).subscribe(lines => {
219219
expect(lines).toContainEqual(expect.stringContaining('[ec..o] foo'));
@@ -247,7 +247,7 @@ describe('--restart-tries', () => {
247247
});
248248

249249
describe('--kill-others', () => {
250-
it('is alised to -k', done => {
250+
it('is aliased to -k', done => {
251251
const child = run('-k "sleep 10" "exit 0"');
252252
child.log.pipe(buffer(child.close)).subscribe(lines => {
253253
expect(lines).toContainEqual(expect.stringContaining('[1] exit 0 exited with code 0'));

‎index.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ module.exports = exports = (commands, options = {}) => {
3030
new InputHandler({
3131
logger,
3232
defaultInputTarget: options.defaultInputTarget,
33-
inputStream: options.inputStream,
33+
inputStream: options.inputStream || (options.handleInput && process.stdin),
34+
pauseInputStreamOnFinish: options.pauseInputStreamOnFinish,
3435
}),
3536
new KillOnSignal({ process }),
3637
new RestartProcess({

‎src/concurrently.js

+15-4
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,29 @@ module.exports = (commands, options) => {
4949
))
5050
.value();
5151

52-
commands = options.controllers.reduce(
53-
(prevCommands, controller) => controller.handle(prevCommands),
54-
commands
52+
const handleResult = options.controllers.reduce(
53+
({ commands: prevCommands, onFinishCallbacks }, controller) => {
54+
const { commands, onFinish } = controller.handle(prevCommands);
55+
return {
56+
commands,
57+
onFinishCallbacks: _.concat(onFinishCallbacks, onFinish ? [onFinish] : [])
58+
}
59+
},
60+
{ commands, onFinishCallbacks: [] }
5561
);
62+
commands = handleResult.commands
5663

5764
const commandsLeft = commands.slice();
5865
const maxProcesses = Math.max(1, Number(options.maxProcesses) || commandsLeft.length);
5966
for (let i = 0; i < maxProcesses; i++) {
6067
maybeRunMore(commandsLeft);
6168
}
6269

63-
return new CompletionListener({ successCondition: options.successCondition }).listen(commands);
70+
return new CompletionListener({ successCondition: options.successCondition })
71+
.listen(commands)
72+
.finally(() => {
73+
handleResult.onFinishCallbacks.forEach((onFinish) => onFinish());
74+
});
6475
};
6576

6677
function mapToCommandInfo(command) {

‎src/concurrently.spec.js

+21-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const EventEmitter = require('events');
22

33
const createFakeCommand = require('./flow-control/fixtures/fake-command');
4+
const FakeHandler = require('./flow-control/fixtures/fake-handler');
45
const concurrently = require('./concurrently');
56

67
let spawn, kill, controllers, processes = [];
@@ -18,7 +19,7 @@ beforeEach(() => {
1819
return process;
1920
});
2021
kill = jest.fn();
21-
controllers = [{ handle: jest.fn(arg => arg) }, { handle: jest.fn(arg => arg) }];
22+
controllers = [new FakeHandler(), new FakeHandler()];
2223
});
2324

2425
it('fails if commands is not an array', () => {
@@ -85,7 +86,7 @@ it('runs commands with a name or prefix color', () => {
8586

8687
it('passes commands wrapped from a controller to the next one', () => {
8788
const fakeCommand = createFakeCommand('banana', 'banana');
88-
controllers[0].handle.mockReturnValue([fakeCommand]);
89+
controllers[0].handle.mockReturnValue({ commands: [fakeCommand] });
8990

9091
create(['echo']);
9192

@@ -165,3 +166,21 @@ it('uses overridden cwd option for each command if specified', () => {
165166
cwd: 'foobar',
166167
}));
167168
});
169+
170+
it('runs onFinish hook after all commands run', async () => {
171+
const promise = create(['foo', 'bar'], { maxProcesses: 1 });
172+
expect(spawn).toHaveBeenCalledTimes(1);
173+
expect(controllers[0].onFinish).not.toHaveBeenCalled();
174+
expect(controllers[1].onFinish).not.toHaveBeenCalled();
175+
176+
processes[0].emit('close', 0, null);
177+
expect(spawn).toHaveBeenCalledTimes(2);
178+
expect(controllers[0].onFinish).not.toHaveBeenCalled();
179+
expect(controllers[1].onFinish).not.toHaveBeenCalled();
180+
181+
processes[1].emit('close', 0, null);
182+
await promise;
183+
184+
expect(controllers[0].onFinish).toHaveBeenCalled();
185+
expect(controllers[1].onFinish).toHaveBeenCalled();
186+
})

‎src/flow-control/base-handler.js

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module.exports = class BaseHandler {
2+
constructor(options = {}) {
3+
const { logger } = options;
4+
5+
this.logger = logger;
6+
}
7+
8+
handle(commands) {
9+
return {
10+
commands,
11+
// an optional callback to call when all commands have finished
12+
// (either successful or not)
13+
onFinish: null,
14+
};
15+
}
16+
};
+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const BaseHandler = require('../base-handler')
2+
3+
module.exports = class FakeHandler extends BaseHandler {
4+
constructor() {
5+
super();
6+
7+
this.handle = jest.fn(commands => ({
8+
commands,
9+
onFinish: this.onFinish,
10+
}));
11+
this.onFinish = jest.fn();
12+
}
13+
};

‎src/flow-control/input-handler.js

+16-5
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ const Rx = require('rxjs');
22
const { map } = require('rxjs/operators');
33

44
const defaults = require('../defaults');
5+
const BaseHandler = require('./base-handler');
6+
7+
module.exports = class InputHandler extends BaseHandler {
8+
constructor({ defaultInputTarget, inputStream, pauseInputStreamOnFinish, logger }) {
9+
super({ logger });
510

6-
module.exports = class InputHandler {
7-
constructor({ defaultInputTarget, inputStream, logger }) {
811
this.defaultInputTarget = defaultInputTarget || defaults.defaultInputTarget;
912
this.inputStream = inputStream;
10-
this.logger = logger;
13+
this.pauseInputStreamOnFinish = pauseInputStreamOnFinish !== false;
1114
}
1215

1316
handle(commands) {
1417
if (!this.inputStream) {
15-
return commands;
18+
return { commands };
1619
}
1720

1821
Rx.fromEvent(this.inputStream, 'data')
@@ -34,6 +37,14 @@ module.exports = class InputHandler {
3437
}
3538
});
3639

37-
return commands;
40+
return {
41+
commands,
42+
onFinish: () => {
43+
if (this.pauseInputStreamOnFinish) {
44+
// https://github.com/kimmobrunfeldt/concurrently/issues/252
45+
this.inputStream.pause();
46+
}
47+
},
48+
};
3849
}
3950
};

‎src/flow-control/input-handler.spec.js

+31-9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const EventEmitter = require('events');
1+
const stream = require('stream');
22
const { createMockInstance } = require('jest-create-mock-instance');
33

44
const Logger = require('../logger');
@@ -12,7 +12,7 @@ beforeEach(() => {
1212
createFakeCommand('foo', 'echo foo', 0),
1313
createFakeCommand('bar', 'echo bar', 1),
1414
];
15-
inputStream = new EventEmitter();
15+
inputStream = new stream.PassThrough();
1616
logger = createMockInstance(Logger);
1717
controller = new InputHandler({
1818
defaultInputTarget: 0,
@@ -22,16 +22,16 @@ beforeEach(() => {
2222
});
2323

2424
it('returns same commands', () => {
25-
expect(controller.handle(commands)).toBe(commands);
25+
expect(controller.handle(commands)).toMatchObject({ commands });
2626

2727
controller = new InputHandler({ logger });
28-
expect(controller.handle(commands)).toBe(commands);
28+
expect(controller.handle(commands)).toMatchObject({ commands });
2929
});
3030

3131
it('forwards input stream to default target ID', () => {
3232
controller.handle(commands);
3333

34-
inputStream.emit('data', Buffer.from('something'));
34+
inputStream.write('something');
3535

3636
expect(commands[0].stdin.write).toHaveBeenCalledTimes(1);
3737
expect(commands[0].stdin.write).toHaveBeenCalledWith('something');
@@ -41,7 +41,7 @@ it('forwards input stream to default target ID', () => {
4141
it('forwards input stream to target index specified in input', () => {
4242
controller.handle(commands);
4343

44-
inputStream.emit('data', Buffer.from('1:something'));
44+
inputStream.write('1:something');
4545

4646
expect(commands[0].stdin.write).not.toHaveBeenCalled();
4747
expect(commands[1].stdin.write).toHaveBeenCalledTimes(1);
@@ -63,7 +63,7 @@ it('forwards input stream to target index specified in input when input contains
6363
it('forwards input stream to target name specified in input', () => {
6464
controller.handle(commands);
6565

66-
inputStream.emit('data', Buffer.from('bar:something'));
66+
inputStream.write('bar:something');
6767

6868
expect(commands[0].stdin.write).not.toHaveBeenCalled();
6969
expect(commands[1].stdin.write).toHaveBeenCalledTimes(1);
@@ -74,7 +74,7 @@ it('logs error if command has no stdin open', () => {
7474
commands[0].stdin = null;
7575
controller.handle(commands);
7676

77-
inputStream.emit('data', Buffer.from('something'));
77+
inputStream.write('something');
7878

7979
expect(commands[1].stdin.write).not.toHaveBeenCalled();
8080
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Unable to find command 0, or it has no stdin open\n');
@@ -83,9 +83,31 @@ it('logs error if command has no stdin open', () => {
8383
it('logs error if command is not found', () => {
8484
controller.handle(commands);
8585

86-
inputStream.emit('data', Buffer.from('foobar:something'));
86+
inputStream.write('foobar:something');
8787

8888
expect(commands[0].stdin.write).not.toHaveBeenCalled();
8989
expect(commands[1].stdin.write).not.toHaveBeenCalled();
9090
expect(logger.logGlobalEvent).toHaveBeenCalledWith('Unable to find command foobar, or it has no stdin open\n');
9191
});
92+
93+
it('pauses input stream when finished', () => {
94+
expect(inputStream.readableFlowing).toBeNull();
95+
96+
const { onFinish } = controller.handle(commands);
97+
expect(inputStream.readableFlowing).toBe(true);
98+
99+
onFinish();
100+
expect(inputStream.readableFlowing).toBe(false);
101+
});
102+
103+
it('does not pause input stream when pauseInputStreamOnFinish is set to false', () => {
104+
controller = new InputHandler({ inputStream, pauseInputStreamOnFinish: false });
105+
106+
expect(inputStream.readableFlowing).toBeNull();
107+
108+
const { onFinish } = controller.handle(commands);
109+
expect(inputStream.readableFlowing).toBe(true);
110+
111+
onFinish();
112+
expect(inputStream.readableFlowing).toBe(true);
113+
});

‎src/flow-control/kill-on-signal.js

+17-12
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
const { map } = require('rxjs/operators');
22

3+
const BaseHandler = require('./base-handler');
34

4-
module.exports = class KillOnSignal {
5+
module.exports = class KillOnSignal extends BaseHandler {
56
constructor({ process }) {
7+
super();
8+
69
this.process = process;
710
}
811

@@ -15,16 +18,18 @@ module.exports = class KillOnSignal {
1518
});
1619
});
1720

18-
return commands.map(command => {
19-
const closeStream = command.close.pipe(map(exitInfo => {
20-
const exitCode = caughtSignal === 'SIGINT' ? 0 : exitInfo.exitCode;
21-
return Object.assign({}, exitInfo, { exitCode });
22-
}));
23-
return new Proxy(command, {
24-
get(target, prop) {
25-
return prop === 'close' ? closeStream : target[prop];
26-
}
27-
});
28-
});
21+
return {
22+
commands: commands.map(command => {
23+
const closeStream = command.close.pipe(map(exitInfo => {
24+
const exitCode = caughtSignal === 'SIGINT' ? 0 : exitInfo.exitCode;
25+
return Object.assign({}, exitInfo, { exitCode });
26+
}));
27+
return new Proxy(command, {
28+
get(target, prop) {
29+
return prop === 'close' ? closeStream : target[prop];
30+
}
31+
});
32+
})
33+
};
2934
}
3035
};

‎src/flow-control/kill-on-signal.spec.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ beforeEach(() => {
1414
});
1515

1616
it('returns commands that keep non-close streams from original commands', () => {
17-
const newCommands = controller.handle(commands);
17+
const { commands: newCommands } = controller.handle(commands);
1818
newCommands.forEach((newCommand, i) => {
1919
expect(newCommand.close).not.toBe(commands[i].close);
2020
expect(newCommand.error).toBe(commands[i].error);
@@ -24,7 +24,7 @@ it('returns commands that keep non-close streams from original commands', () =>
2424
});
2525

2626
it('returns commands that map SIGINT to exit code 0', () => {
27-
const newCommands = controller.handle(commands);
27+
const { commands: newCommands } = controller.handle(commands);
2828
expect(newCommands).not.toBe(commands);
2929
expect(newCommands).toHaveLength(commands.length);
3030

@@ -40,7 +40,7 @@ it('returns commands that map SIGINT to exit code 0', () => {
4040
});
4141

4242
it('returns commands that keep non-SIGINT exit codes', () => {
43-
const newCommands = controller.handle(commands);
43+
const { commands: newCommands } = controller.handle(commands);
4444
expect(newCommands).not.toBe(commands);
4545
expect(newCommands).toHaveLength(commands.length);
4646

0 commit comments

Comments
 (0)
Please sign in to comment.