diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-client-response-aborted-unlistened.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-client-response-aborted-unlistened.ts.json new file mode 100644 index 0000000000..c21202def8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/http-client-response-aborted-unlistened.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/http-client-response-aborted-unlistened.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index b006d9c369..79d3c62f41 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -2376,7 +2376,9 @@ static void scr_http_proto_sweep(void) { } case SCR_HTTP_EMIT_REQ_ABORTED: { ScrHttpReq *req = (ScrHttpReq *)e.h; - if (!req->close_emitted) { + /* Node's IncomingMessage reports its destroy error only to 'error' + * listeners: without one, 'aborted' and 'close' alone report it. */ + if (!req->close_emitted && req->err_ls.n != 0) { ScrStr *msg = scr_str_new("aborted", 7); scr_net_fire_err_this(&req->err_ls, msg, req, SCR_DYNH_HTTP_REQ); scr_str_release(msg); diff --git a/tests/corpus/http-client-response-aborted-unlistened.ts b/tests/corpus/http-client-response-aborted-unlistened.ts new file mode 100644 index 0000000000..dff3abf8c9 --- /dev/null +++ b/tests/corpus/http-client-response-aborted-unlistened.ts @@ -0,0 +1,53 @@ +// A client response cut off mid-body reports 'aborted' and 'close'; its +// 'error' reaches 'error' listeners only, so a response without one does not +// end the process. fetch relies on this when it leaves a redirect whose +// chunked body has not ended. +import * as http from "node:http"; +import * as net from "node:net"; + +const server = net.createServer((socket) => { + let buffered = ""; + socket.on("data", (chunk) => { + buffered += chunk.toString("latin1"); + while (buffered.includes("\r\n\r\n")) { + const end = buffered.indexOf("\r\n\r\n"); + const path = buffered.slice(0, end).split(" ")[1]; + buffered = buffered.slice(end + 4); + if (path === "/final") { + socket.write("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nfinal"); + continue; + } + socket.write("HTTP/1.1 303 See Other\r\nLocation: /final\r\nTransfer-Encoding: chunked\r\n\r\n"); + // The terminating chunk arrives in a later read. + setTimeout(() => { + if (!socket.destroyed) socket.write("0\r\n\r\n"); + }, 50); + } + }); + socket.on("error", () => {}); +}); + +function destroyMidBody(port: number, listen: boolean): Promise { + return new Promise((resolve) => { + const req = http.get({ host: "127.0.0.1", port, path: "/start" }, (res) => { + console.log("status", res.statusCode); + res.on("aborted", () => console.log("aborted")); + if (listen) res.on("error", (error: NodeJS.ErrnoException) => console.log("res error", error.message, error.code)); + res.on("close", () => { + console.log("res close"); + resolve(); + }); + req.destroy(); + }); + req.on("close", () => console.log("req close")); + }); +} + +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as net.AddressInfo).port; + const response = await fetch(`http://127.0.0.1:${port}/start`); + console.log(response.status, response.redirected, response.url.endsWith("/final"), await response.text()); + await destroyMidBody(port, false); + await destroyMidBody(port, true); + server.close(); +}); diff --git a/tests/harness/corpus-costs/http-client-response-aborted-unlistened.ts.json b/tests/harness/corpus-costs/http-client-response-aborted-unlistened.ts.json new file mode 100644 index 0000000000..346d8bdac9 --- /dev/null +++ b/tests/harness/corpus-costs/http-client-response-aborted-unlistened.ts.json @@ -0,0 +1 @@ +{ "plain": 15300, "san": 69300 }