Skip to content

Commit 8c226d5

Browse files
authored
Merge pull request #8613 from QwikDev/v2-fix-node-crash
fix: node crash after not handled aborted connections
2 parents fedd379 + 2c95113 commit 8c226d5

4 files changed

Lines changed: 329 additions & 16 deletions

File tree

.changeset/dry-rabbits-hang.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@qwik.dev/router': patch
3+
---
4+
5+
fix: handle aborted Node response streams without crashing and resolve the Node response stream contract

packages/qwik-router/src/middleware/node/http.ts

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,23 @@ export function getUrl(req: IncomingMessage | Http2ServerRequest, origin: string
3131
return normalizeUrl((req as any).originalUrl || req.url || '/', origin);
3232
}
3333

34-
// when the user refreshes or cancels the stream there will be an error
35-
function isIgnoredError(message = '') {
36-
const ignoredErrors = ['The stream has been destroyed', 'write after end'];
37-
return ignoredErrors.some((ignored) => message.includes(ignored));
34+
// Client disconnects are expected during streaming SSR: the socket is gone,
35+
// so the adapter should stop writing instead of surfacing them as app errors.
36+
function isClientAbortWriteError(error: unknown) {
37+
if (!(error instanceof Error)) {
38+
return false;
39+
}
40+
const code = (error as Error & { code?: string }).code;
41+
if (
42+
code === 'EPIPE' ||
43+
code === 'ECONNRESET' ||
44+
code === 'ERR_STREAM_DESTROYED' ||
45+
code === 'ERR_STREAM_WRITE_AFTER_END'
46+
) {
47+
return true;
48+
}
49+
50+
return false;
3851
}
3952

4053
// ensure no HTTP/2-specific headers are being set
@@ -46,20 +59,39 @@ export function normalizeUrl(url: string, base: string) {
4659

4760
function createNodeResponseSink(res: ServerResponse) {
4861
let closed = res.closed || res.destroyed;
62+
let responseError: unknown;
63+
let resolveClosed: () => void;
4964
const closedPromise = closed
5065
? Promise.resolve()
5166
: new Promise<void>((resolve) => {
67+
resolveClosed = resolve;
5268
res.once('close', () => {
5369
closed = true;
5470
resolve();
5571
});
5672
});
5773

74+
res.on('error', (error) => {
75+
if (responseError === undefined) {
76+
responseError = error;
77+
}
78+
if (!closed) {
79+
closed = true;
80+
resolveClosed();
81+
}
82+
});
83+
84+
const shouldPropagateResponseError = () =>
85+
responseError !== undefined && !isClientAbortWriteError(responseError);
86+
5887
const write = (chunk: Uint8Array) => {
5988
if (closed || res.closed || res.destroyed) {
6089
// If the response has already been closed or destroyed (for example the client has disconnected)
6190
// then writing into it will cause an error. So just stop writing since no one
6291
// is listening.
92+
if (shouldPropagateResponseError()) {
93+
return Promise.reject(responseError);
94+
}
6395
return;
6496
}
6597

@@ -76,21 +108,33 @@ function createNodeResponseSink(res: ServerResponse) {
76108
setImmediate(resolve);
77109
};
78110

111+
const finishClosed = () => {
112+
closed = true;
113+
finish();
114+
};
115+
79116
const fail = (error: unknown) => {
80117
if (settled) {
81118
return;
82119
}
83120
settled = true;
121+
closed = true;
84122
reject(error);
85123
};
86124

87-
closedPromise.then(finish);
125+
closedPromise.then(() => {
126+
if (shouldPropagateResponseError()) {
127+
fail(responseError);
128+
return;
129+
}
130+
finish();
131+
});
88132

89133
try {
90134
res.write(chunk, (error) => {
91135
if (error) {
92-
if (isIgnoredError(error.message)) {
93-
finish();
136+
if (isClientAbortWriteError(error)) {
137+
finishClosed();
94138
return;
95139
}
96140
fail(error);
@@ -99,8 +143,8 @@ function createNodeResponseSink(res: ServerResponse) {
99143
finish();
100144
});
101145
} catch (error) {
102-
if (error instanceof Error && isIgnoredError(error.message)) {
103-
finish();
146+
if (isClientAbortWriteError(error)) {
147+
finishClosed();
104148
return;
105149
}
106150
fail(error);
@@ -110,13 +154,52 @@ function createNodeResponseSink(res: ServerResponse) {
110154

111155
const close = () => {
112156
if (closed || res.closed || res.destroyed) {
157+
if (shouldPropagateResponseError()) {
158+
return Promise.reject(responseError);
159+
}
113160
return;
114161
}
115162

116-
return new Promise<void>((resolve) => {
117-
res.end(() => {
163+
return new Promise<void>((resolve, reject) => {
164+
let settled = false;
165+
166+
const finish = () => {
167+
if (settled) {
168+
return;
169+
}
170+
settled = true;
171+
closed = true;
118172
resolve();
173+
};
174+
175+
const fail = (error: unknown) => {
176+
if (settled) {
177+
return;
178+
}
179+
settled = true;
180+
closed = true;
181+
reject(error);
182+
};
183+
184+
closedPromise.then(() => {
185+
if (shouldPropagateResponseError()) {
186+
fail(responseError);
187+
return;
188+
}
189+
finish();
119190
});
191+
192+
try {
193+
res.end(() => {
194+
finish();
195+
});
196+
} catch (error) {
197+
if (isClientAbortWriteError(error)) {
198+
finish();
199+
return;
200+
}
201+
fail(error);
202+
}
120203
});
121204
};
122205

@@ -161,16 +244,20 @@ export async function fromNodeHttp(
161244

162245
const body = req.method === 'HEAD' || req.method === 'GET' ? undefined : getRequestBody();
163246
const controller = new AbortController();
247+
const abort = () => {
248+
controller.abort();
249+
};
164250
const options = {
165251
method: req.method,
166252
headers: requestHeaders,
167253
body: body as any,
168254
signal: controller.signal,
169255
duplex: 'half' as any,
170256
};
171-
res.on('close', () => {
172-
controller.abort();
173-
});
257+
req.on('aborted', abort);
258+
req.on('error', abort);
259+
res.on('close', abort);
260+
res.on('error', abort);
174261
const serverRequestEv: ServerRequestEvent<boolean> = {
175262
mode,
176263
url,
@@ -180,7 +267,7 @@ export async function fromNodeHttp(
180267
return process.env[key];
181268
},
182269
},
183-
getWritableStream: (status, headers, cookies) => {
270+
getWritableStream: (status, headers, cookies, resolve) => {
184271
res.statusCode = status;
185272
const sink = createNodeResponseSink(res);
186273

@@ -199,6 +286,8 @@ export async function fromNodeHttp(
199286
console.error(err);
200287
}
201288

289+
resolve(true);
290+
202291
return new WritableStream<Uint8Array>({
203292
write(chunk) {
204293
return sink.write(chunk);

0 commit comments

Comments
 (0)