Skip to content

Commit 6de6266

Browse files
authored
feat: use streams rather than a buffer (#10)
Reworks the main output method to use streams directly rather than accumulating chunks through events. This should mean we don't hold any output until the user awaits the result.
1 parent 8dc684e commit 6de6266

File tree

4 files changed

+153
-70
lines changed

4 files changed

+153
-70
lines changed

src/main.ts

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {type Readable} from 'node:stream';
33
import {normalize as normalizePath} from 'node:path';
44
import {cwd as getCwd} from 'node:process';
55
import {computeEnv} from './env.js';
6-
import {readStreamAsString, combineStreams} from './stream.js';
6+
import {combineStreams} from './stream.js';
77
import readline from 'node:readline';
88

99
export interface Output {
@@ -156,31 +156,37 @@ export class ExecProcess implements Result {
156156

157157
async *[Symbol.asyncIterator](): AsyncIterator<string> {
158158
const proc = this._process;
159+
159160
if (!proc) {
160161
return;
161162
}
162163

163-
if (this._thrownError) {
164-
throw this._thrownError;
165-
}
164+
const streams: Readable[] = [];
166165

167-
const sources: Readable[] = [];
168-
if (proc.stderr) {
169-
sources.push(proc.stderr);
166+
if (this._streamErr) {
167+
streams.push(this._streamErr);
170168
}
171-
if (proc.stdout) {
172-
sources.push(proc.stdout);
169+
if (this._streamOut) {
170+
streams.push(this._streamOut);
173171
}
174-
const combined = combineStreams(sources);
172+
173+
const streamCombined = combineStreams(streams);
174+
175175
const rl = readline.createInterface({
176-
input: combined
176+
input: streamCombined
177177
});
178178

179179
for await (const chunk of rl) {
180180
yield chunk.toString();
181181
}
182182

183183
await this._processClosed;
184+
185+
proc.removeAllListeners();
186+
187+
if (this._thrownError) {
188+
throw this._thrownError;
189+
}
184190
}
185191

186192
protected async _waitForOutput(): Promise<Output> {
@@ -194,6 +200,21 @@ export class ExecProcess implements Result {
194200
throw new Error('No process was started');
195201
}
196202

203+
let stderr = '';
204+
let stdout = '';
205+
206+
if (this._streamErr) {
207+
for await (const chunk of this._streamErr) {
208+
stderr += chunk.toString();
209+
}
210+
}
211+
212+
if (this._streamOut) {
213+
for await (const chunk of this._streamOut) {
214+
stdout += chunk.toString();
215+
}
216+
}
217+
197218
await this._processClosed;
198219

199220
proc.removeAllListeners();
@@ -202,14 +223,9 @@ export class ExecProcess implements Result {
202223
throw this._thrownError;
203224
}
204225

205-
const [stderr, stdout] = await Promise.all([
206-
proc.stderr && readStreamAsString(proc.stderr),
207-
proc.stdout && readStreamAsString(proc.stdout)
208-
]);
209-
210226
const result: Output = {
211-
stderr: stderr ?? '',
212-
stdout: stdout ?? ''
227+
stderr,
228+
stdout
213229
};
214230

215231
return result;
@@ -222,16 +238,20 @@ export class ExecProcess implements Result {
222238
return this._waitForOutput().then(onfulfilled, onrejected);
223239
}
224240

241+
protected _streamOut?: Readable;
242+
protected _streamErr?: Readable;
243+
225244
public spawn(): void {
226245
const cwd = getCwd();
227246
const options = this._options;
228247
const nodeOptions = {
229248
...defaultNodeOptions,
230249
...options.nodeOptions
231250
};
232-
233251
const signals: AbortSignal[] = [];
234252

253+
this._resetState();
254+
235255
if (options.timeout !== undefined) {
236256
signals.push(AbortSignal.timeout(options.timeout));
237257
}
@@ -255,6 +275,13 @@ export class ExecProcess implements Result {
255275

256276
const handle = spawn(normalisedCommand, normalisedArgs, nodeOptions);
257277

278+
if (handle.stderr) {
279+
this._streamErr = handle.stderr;
280+
}
281+
if (handle.stdout) {
282+
this._streamOut = handle.stdout;
283+
}
284+
258285
this._process = handle;
259286
handle.once('error', this._onError);
260287
handle.once('close', this._onClose);
@@ -267,6 +294,14 @@ export class ExecProcess implements Result {
267294
}
268295
}
269296

297+
protected _resetState(): void {
298+
this._aborted = false;
299+
this._processClosed = new Promise<void>((resolve) => {
300+
this._resolveClose = resolve;
301+
});
302+
this._thrownError = undefined;
303+
}
304+
270305
protected _onError = (err: Error): void => {
271306
if (
272307
err.name === 'AbortError' &&

src/stream.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,6 @@
11
import {type EventEmitter} from 'node:events';
22
import {type Readable, PassThrough} from 'node:stream';
33

4-
export const readStreamAsString = (stream: Readable): Promise<string> => {
5-
return new Promise<string>((resolve, reject) => {
6-
let result = '';
7-
const onDataReceived = (chunk: Buffer | string): void => {
8-
result += chunk.toString();
9-
};
10-
11-
stream.once('error', (err) => {
12-
reject(err);
13-
});
14-
15-
stream.on('data', onDataReceived);
16-
17-
stream.once('end', () => {
18-
stream.removeListener('data', onDataReceived);
19-
resolve(result);
20-
});
21-
});
22-
};
23-
244
export const waitForEvent = (
255
emitter: EventEmitter,
266
name: string

src/test/main_test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {x} from '../main.js';
2+
import * as assert from 'node:assert/strict';
3+
import {test} from 'node:test';
4+
5+
test('exec', async (t) => {
6+
await t.test('resolves to stdout', async () => {
7+
const result = await x('echo', ['foo']);
8+
assert.equal(result.stdout, 'foo\n');
9+
assert.equal(result.stderr, '');
10+
});
11+
12+
await t.test('times out after defined timeout (ms)', async () => {
13+
const proc = x('sleep', ['0.2s'], {timeout: 100});
14+
await assert.rejects(async () => {
15+
await proc;
16+
});
17+
assert.equal(proc.killed, true);
18+
assert.equal(proc.process!.signalCode, 'SIGTERM');
19+
});
20+
21+
await t.test('throws spawn errors', async () => {
22+
const proc = x('definitelyNonExistent');
23+
await assert.rejects(async () => {
24+
await proc;
25+
}, 'spawn definitelyNonExistent NOENT');
26+
});
27+
28+
await t.test('captures stderr', async () => {
29+
const result = await x('cat', ['nonexistentforsure']);
30+
assert.equal(
31+
result.stderr,
32+
'cat: nonexistentforsure: No such file or directory\n'
33+
);
34+
assert.equal(result.stdout, '');
35+
});
36+
37+
await t.test('pid is number', async () => {
38+
const proc = x('echo', ['foo']);
39+
await proc;
40+
assert.ok(typeof proc.pid === 'number');
41+
});
42+
43+
await t.test('exitCode is set correctly', async () => {
44+
const proc = x('echo', ['foo']);
45+
assert.equal(proc.exitCode, undefined);
46+
await proc;
47+
assert.equal(proc.exitCode, 0);
48+
});
49+
50+
await t.test('kill terminates the process', async () => {
51+
const proc = x('sleep', ['5s']);
52+
const result = proc.kill();
53+
assert.ok(result);
54+
assert.ok(proc.killed);
55+
assert.equal(proc.aborted, false);
56+
});
57+
58+
await t.test('pipe correctly pipes output', async () => {
59+
const echoProc = x('echo', ['foo\nbar']);
60+
const grepProc = echoProc.pipe('grep', ['foo']);
61+
const result = await grepProc;
62+
63+
assert.equal(result.stderr, '');
64+
assert.equal(result.stdout, 'foo\n');
65+
assert.equal(echoProc.exitCode, 0);
66+
assert.equal(grepProc.exitCode, 0);
67+
});
68+
69+
await t.test('async iterator gets correct output', async () => {
70+
const proc = x('echo', ['foo\nbar']);
71+
const lines = [];
72+
for await (const line of proc) {
73+
lines.push(line);
74+
}
75+
76+
assert.deepEqual(lines, ['foo', 'bar']);
77+
});
78+
79+
await t.test('async iterator receives errors', async () => {
80+
const proc = x('nonexistentforsure');
81+
await assert.rejects(async () => {
82+
for await (const line of proc) {
83+
line;
84+
}
85+
});
86+
});
87+
88+
await t.test('signal can be used to abort execution', async () => {
89+
const controller = new AbortController();
90+
const proc = x('sleep', ['4s'], {signal: controller.signal});
91+
controller.abort();
92+
const result = await proc;
93+
assert.ok(proc.aborted);
94+
assert.ok(proc.killed);
95+
assert.equal(result.stderr, '');
96+
assert.equal(result.stdout, '');
97+
});
98+
});

src/test/stream_test.ts

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {combineStreams, waitForEvent, readStreamAsString} from '../stream.js';
1+
import {combineStreams, waitForEvent} from '../stream.js';
22
import * as assert from 'node:assert/strict';
33
import {test} from 'node:test';
44
import {EventEmitter} from 'node:events';
@@ -13,36 +13,6 @@ test('waitForEvent', async (t) => {
1313
});
1414
});
1515

16-
test('readStreamAsString', async (t) => {
17-
await t.test('rejects on error', async () => {
18-
const streamError = new Error('fudge');
19-
const stream = new Readable({
20-
read() {
21-
this.destroy(streamError);
22-
}
23-
});
24-
await assert.rejects(readStreamAsString(stream), streamError);
25-
});
26-
27-
await t.test('resolves to concatenated data', async () => {
28-
const stream = Readable.from(['foo', 'bar']);
29-
const result = await readStreamAsString(stream);
30-
assert.equal(result, 'foobar');
31-
});
32-
33-
await t.test('handles buffer data', async () => {
34-
const stream = new Readable({
35-
read() {
36-
this.push(Buffer.from('foo'));
37-
this.push(Buffer.from('bar'));
38-
this.push(null);
39-
}
40-
});
41-
const result = await readStreamAsString(stream);
42-
assert.equal(result, 'foobar');
43-
});
44-
});
45-
4616
test('combineStreams', async (t) => {
4717
await t.test('works with a single stream', async () => {
4818
const stream = Readable.from(['foo', 'bar']);

0 commit comments

Comments
 (0)