Skip to content

Commit 8dc684e

Browse files
authored
feat: rework promise interface (#9)
Reworks the promise `.then` to be greatly simplified. As part of this, error handling has also been improved in a few ways: - Timeouts are now handled by an `AbortSignal` rather than the built-in `timeout` option, as there is currently no way to know if a proc was killed due to a timeout. - The last error is stored until we `await` the result, at which point, we will throw the stored error - We wait for the process to close before trying to read the output, in case it threw
1 parent ca8ba53 commit 8dc684e

File tree

2 files changed

+57
-22
lines changed

2 files changed

+57
-22
lines changed

src/main.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,26 @@ function normaliseCommandAndArgs(
7070
};
7171
}
7272

73+
function combineSignals(signals: Iterable<AbortSignal>): AbortSignal {
74+
const controller = new AbortController();
75+
76+
for (const signal of signals) {
77+
if (signal.aborted) {
78+
controller.abort();
79+
return signal;
80+
}
81+
82+
const onAbort = (): void => {
83+
controller.abort(signal.reason);
84+
};
85+
signal.addEventListener('abort', onAbort, {
86+
signal: controller.signal
87+
});
88+
}
89+
90+
return controller.signal;
91+
}
92+
7393
export class ExecProcess implements Result {
7494
protected _process?: ChildProcess;
7595
protected _aborted: boolean = false;
@@ -78,6 +98,7 @@ export class ExecProcess implements Result {
7898
protected _args: string[];
7999
protected _resolveClose?: () => void;
80100
protected _processClosed: Promise<void>;
101+
protected _thrownError?: Error;
81102

82103
public get process(): ChildProcess | undefined {
83104
return this._process;
@@ -139,6 +160,10 @@ export class ExecProcess implements Result {
139160
return;
140161
}
141162

163+
if (this._thrownError) {
164+
throw this._thrownError;
165+
}
166+
142167
const sources: Readable[] = [];
143168
if (proc.stderr) {
144169
sources.push(proc.stderr);
@@ -158,10 +183,7 @@ export class ExecProcess implements Result {
158183
await this._processClosed;
159184
}
160185

161-
public async then<TResult1 = Output, TResult2 = never>(
162-
onfulfilled?: ((value: Output) => TResult1 | PromiseLike<TResult1>) | null,
163-
_onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null
164-
): Promise<TResult1 | TResult2> {
186+
protected async _waitForOutput(): Promise<Output> {
165187
if (this._options?.stdin) {
166188
await this._options.stdin;
167189
}
@@ -172,22 +194,32 @@ export class ExecProcess implements Result {
172194
throw new Error('No process was started');
173195
}
174196

197+
await this._processClosed;
198+
199+
proc.removeAllListeners();
200+
201+
if (this._thrownError) {
202+
throw this._thrownError;
203+
}
204+
175205
const [stderr, stdout] = await Promise.all([
176206
proc.stderr && readStreamAsString(proc.stderr),
177-
proc.stdout && readStreamAsString(proc.stdout),
178-
this._processClosed
207+
proc.stdout && readStreamAsString(proc.stdout)
179208
]);
180209

181210
const result: Output = {
182211
stderr: stderr ?? '',
183212
stdout: stdout ?? ''
184213
};
185214

186-
if (onfulfilled) {
187-
return onfulfilled(result);
188-
} else {
189-
return result as TResult1;
190-
}
215+
return result;
216+
}
217+
218+
public then<TResult1 = Output, TResult2 = never>(
219+
onfulfilled?: ((value: Output) => TResult1 | PromiseLike<TResult1>) | null,
220+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null
221+
): Promise<TResult1 | TResult2> {
222+
return this._waitForOutput().then(onfulfilled, onrejected);
191223
}
192224

193225
public spawn(): void {
@@ -198,18 +230,24 @@ export class ExecProcess implements Result {
198230
...options.nodeOptions
199231
};
200232

233+
const signals: AbortSignal[] = [];
234+
201235
if (options.timeout !== undefined) {
202-
nodeOptions.timeout = options.timeout;
236+
signals.push(AbortSignal.timeout(options.timeout));
203237
}
204238

205239
if (options.signal !== undefined) {
206-
nodeOptions.signal = options.signal;
240+
signals.push(options.signal);
207241
}
208242

209243
if (options.persist === true) {
210244
nodeOptions.detached = true;
211245
}
212246

247+
if (signals.length > 0) {
248+
nodeOptions.signal = combineSignals(signals);
249+
}
250+
213251
nodeOptions.env = computeEnv(cwd, nodeOptions.env);
214252

215253
const {command: normalisedCommand, args: normalisedArgs} =
@@ -230,12 +268,14 @@ export class ExecProcess implements Result {
230268
}
231269

232270
protected _onError = (err: Error): void => {
233-
if (err.name === 'AbortError') {
271+
if (
272+
err.name === 'AbortError' &&
273+
(!(err.cause instanceof Error) || err.cause.name !== 'TimeoutError')
274+
) {
234275
this._aborted = true;
235276
return;
236277
}
237-
// TODO emit this somewhere
238-
throw err;
278+
this._thrownError = err;
239279
};
240280

241281
protected _onClose = (): void => {

src/test/stream_test.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,7 @@ test('readStreamAsString', async (t) => {
2121
this.destroy(streamError);
2222
}
2323
});
24-
try {
25-
await readStreamAsString(stream);
26-
assert.fail('expected to throw');
27-
} catch (err) {
28-
assert.equal(err, streamError);
29-
}
24+
await assert.rejects(readStreamAsString(stream), streamError);
3025
});
3126

3227
await t.test('resolves to concatenated data', async () => {

0 commit comments

Comments
 (0)