Skip to content

Commit 014f0ec

Browse files
committed
Enforce that rules must end or destroy before they resolve successfully
The one step breaking this invariant was the streaming step, which resolved immediately. This meant it ignored any possible errors. With this fix, stream errors will now be exposed in the response stream as expected. This also changes logic so that in general any handler throwing an AbortError will have the underlying response automatically killed if it hasn't destroyed it itself already. This shouldn't affect any externally visible behaviour.
1 parent c4c271a commit 014f0ec

File tree

3 files changed

+27
-7
lines changed

3 files changed

+27
-7
lines changed

src/rules/requests/request-step-impls.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,17 +285,33 @@ export class CallbackStepImpl extends CallbackStep {
285285

286286
export class StreamStepImpl extends StreamStep {
287287

288-
async handle(_request: OngoingRequest, response: OngoingResponse) {
288+
async handle(request: OngoingRequest, response: OngoingResponse) {
289289
if (!this.stream.done) {
290290
if (this.headers) dropDefaultHeaders(response);
291291

292292
writeHead(response, this.status, undefined, this.headers);
293293
response.flushHeaders();
294294

295-
this.stream.pipe(response);
295+
if (this.stream.readableEnded || this.stream.destroyed) {
296+
response.end();
297+
} else {
298+
this.stream.pipe(response);
299+
}
296300
this.stream.done = true;
297301

298-
this.stream.on('error', (e) => response.destroy(e));
302+
return new Promise<void>((resolve, reject) => {
303+
if (this.stream.readableEnded || this.stream.destroyed) {
304+
resolve();
305+
} else {
306+
this.stream.on('end', () => resolve());
307+
this.stream.on('error', (e: ErrorLike) => {
308+
reject(new AbortError(
309+
`Stream rule error: ${e.message}`,
310+
e.code ?? 'STREAM_RULE_ERROR'
311+
));
312+
});
313+
}
314+
});
299315
} else {
300316
throw new Error(stripIndent`
301317
Stream request step called more than once - this is not supported.

src/server/mockttp-server.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,10 +745,16 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
745745
} else {
746746
await this.sendUnmatchedRequestError(request, response);
747747
}
748-
result = result || 'responded';
748+
749+
if (!response.writableEnded && !response.destroyed) {
750+
throw new Error("Request handler finished successfully without ending the response");
751+
}
752+
753+
result ||= 'responded';
749754
} catch (e) {
750755
if (e instanceof AbortError) {
751756
abort(e);
757+
response.destroy(e);
752758

753759
if (this.debug) {
754760
console.error("Failed to handle request due to abort:", e);
@@ -770,7 +776,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
770776

771777
try {
772778
response.end((isErrorLike(e) && e.toString()) || e);
773-
result = result || 'responded';
779+
result ||= 'responded';
774780
} catch (e) {
775781
abort(e as Error);
776782
}

test/integration/subscriptions/response-events.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,8 @@ describe("Response initiated subscriptions", () => {
5959

6060
const timingEvents = seenResponse.timingEvents;
6161
expect(timingEvents.startTimestamp).to.be.a('number');
62-
expect(timingEvents.bodyReceivedTimestamp).to.be.a('number');
6362
expect(timingEvents.headersSentTimestamp).to.be.a('number');
6463

65-
expect(timingEvents.bodyReceivedTimestamp).to.be.greaterThan(timingEvents.startTimestamp);
6664
expect(timingEvents.headersSentTimestamp).to.be.greaterThan(timingEvents.startTimestamp);
6765

6866
expect(timingEvents.responseSentTimestamp).to.equal(undefined);

0 commit comments

Comments
 (0)