From f43cc0f6e95f85cb24ec7a891f26200fca4d8428 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 07:50:28 +0900 Subject: [PATCH 1/4] test(transports): cover HEAD preservation, interim answers and the response timeout Three coverage gaps the acceptance audit found in the transports work. No production change: each case exercises a branch that is already there and was not asserted. HEAD (#5109). A HEAD answer carries the headers the GET would have carried, including the length of a body it will never send. The SOCKS transport now has a case proving it answers with a null body, keeps the advertised length in the headers where a caller is entitled to it, and releases a tunnel whose peer asked to stay alive. Interim answers (#5110). A peer may answer 100 and 103 before the request body is finished, and those are not the answer. The new case has the peer send both mid-upload and asserts the caller receives the final 200, with upload continuity witnessed by the peer receiving the rest of the body including its terminating chunk. The response timeout (#5110). The branch that matters runs while the upload is parked on a chunk the caller will never produce. Waiting out the real budget would make this a three-minute test, so the timer is observed as the transport arms it and its own callback is invoked once the peer has witnessed the stall. The budget asserted is the transport's, mirrored in the test rather than exported from the module. Settlement, reader release and socket teardown are each witnessed. local checks: NOT RUN --- tests/lib/socks5-upload-lifecycle.test.ts | 115 +++++++++++++++++++++- tests/lib/transport-null-body.test.ts | 33 +++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/tests/lib/socks5-upload-lifecycle.test.ts b/tests/lib/socks5-upload-lifecycle.test.ts index 13b9dcaa11a..a7f13e99e70 100644 --- a/tests/lib/socks5-upload-lifecycle.test.ts +++ b/tests/lib/socks5-upload-lifecycle.test.ts @@ -1,7 +1,17 @@ import { describe, expect, test } from "bun:test"; -import { createConnection, createServer as createTcpServer, type AddressInfo, type Server as TcpServer, type Socket } from "node:net"; +import { Socket, createConnection, createServer as createTcpServer, type AddressInfo, type Server as TcpServer } from "node:net"; import { socks5Fetch } from "../../src/lib/socks5-fetch"; +/** + * The budget the transport arms for a response, mirrored from SOCKS5_RESPONSE_TIMEOUT_MS in + * src/lib/socks5-fetch.ts, which keeps it module-private. + * + * Mirrored rather than exported: the value is what identifies the transport's own timer among the + * several this exchange arms, and a test is not a reason to widen that module's surface. If the + * budget changes there, the assertion below stops finding it and says so. + */ +const RESPONSE_TIMEOUT_MS = 200_000; + const openSockets = new WeakMap>(); async function listen(server: TcpServer): Promise { @@ -276,4 +286,107 @@ describe("SOCKS5 upload lifecycle", () => { await Promise.all([close(proxy), close(target)]); } }); + test("interim informational answers are consumed and the upload runs to completion", async () => { + /* + * A peer may answer 100 and 103 before the request body is finished, and those are not the + * answer. The transport consumes them and keeps uploading; one that treated the first head it + * saw as final would hand the caller an empty 100 and stop writing mid-body. + */ + let received = ""; + let informed = false; + const target = createTcpServer(socket => { + socket.once("error", () => { /* teardown may reset this peer */ }); + socket.on("data", chunk => { + received += chunk.toString("latin1"); + if (!informed && received.includes("\r\n\r\n")) { + informed = true; + socket.write("HTTP/1.1 100 Continue\r\n\r\n"); + socket.write("HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n"); + } + if (!received.includes("0\r\n\r\n")) return; + socket.write("HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nposted"); + }); + }); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("hello ")); + controller.enqueue(encoder.encode("socks")); + controller.close(); + }, + }); + try { + const response = await post(targetPort, proxyPort, stream); + + // The final answer is the one the caller gets, with the interim heads consumed rather than + // surfaced or left in the buffer ahead of the body. + expect(informed).toBe(true); + expect(response.status).toBe(200); + expect(await response.text()).toBe("posted"); + // Upload continuity: everything after the interim answers still reached the peer, including + // the terminating chunk that ends the request. + expect(received).toContain("hello "); + expect(received).toContain("socks"); + expect(received).toContain("0\r\n\r\n"); + } finally { + await Promise.all([close(proxy), close(target)]); + } + }); + + test("the response timeout fires while a body read is pending and cleans up after itself", async () => { + /* + * The branch that matters here runs while the upload is parked on a chunk the caller will + * never produce. Waiting out the real budget would make this a three-minute test, so the timer + * is observed as the transport arms it and then fired deliberately once the peer has witnessed + * the stall. What runs is the transport's own callback, not a substitute for it. + */ + const { server: target, uploading } = uploadObserver(); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + const body = stallingBody(new TextEncoder().encode("first chunk")); + const armed: number[] = []; + let responseTimeoutSocket: Socket | undefined; + let fireResponseTimeout: (() => void) | undefined; + const originalSetTimeout = Socket.prototype.setTimeout; + try { + Socket.prototype.setTimeout = function patched(this: Socket, ms: number, callback?: () => void) { + armed.push(ms); + if (ms === RESPONSE_TIMEOUT_MS) { + responseTimeoutSocket = this; + fireResponseTimeout = callback; + } + return originalSetTimeout.call(this, ms, callback as never); + } as typeof Socket.prototype.setTimeout; + + const pending = post(targetPort, proxyPort, body.stream); + const outcome = pending.then(() => "resolved" as const, (error: unknown) => error); + + // Parked on a read the caller's stream will never fulfil, which is the state this timeout + // exists for. + expect(await settlesWithin(uploading)).toBe(true); + expect(armed).toContain(RESPONSE_TIMEOUT_MS); + expect(responseTimeoutSocket).toBeDefined(); + expect(fireResponseTimeout).toBeDefined(); + + // The transport's own callback, invoked where its timer would have invoked it. Nothing here + // substitutes for what it does. + fireResponseTimeout?.(); + + // Settlement: the caller is answered rather than left pending. + expect(await settlesWithin(outcome)).toBe(true); + const error = await outcome; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("timed out"); + // Reader cleanup: the caller's stream is released rather than held open by a dead exchange. + expect(await settlesWithin(body.cancelled)).toBe(true); + // Socket cleanup: the tunnel this exchange owned is gone. + expect(responseTimeoutSocket?.destroyed).toBe(true); + } finally { + Socket.prototype.setTimeout = originalSetTimeout; + await Promise.all([close(proxy), close(target)]); + } + }); + }); diff --git a/tests/lib/transport-null-body.test.ts b/tests/lib/transport-null-body.test.ts index b770d383585..7f1f4f00c00 100644 --- a/tests/lib/transport-null-body.test.ts +++ b/tests/lib/transport-null-body.test.ts @@ -174,4 +174,37 @@ describe("null-body statuses on the raw outbound transports", () => { await Promise.all([close(proxy), close(target)]); } }); + test("the SOCKS transport answers HEAD with no body even when the peer advertises one", async () => { + let targetConnection: Socket | undefined; + /* + * A HEAD answer carries the headers the GET would have carried, including the length of a + * body it will never send. Reading that many bytes would park the transport on a body that is + * not coming, with the answer already in hand. + */ + const target = replyingTarget( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 128\r\nConnection: keep-alive\r\n\r\n", + socket => { targetConnection = socket; }, + ); + const proxy = socksProxy(); + const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + try { + const response = await socks5Fetch( + "http://provider.invalid:" + targetPort + "/head", + { method: "HEAD" }, + "socks5://127.0.0.1:" + proxyPort, + ); + expect(response.status).toBe(200); + expect(response.body).toBeNull(); + // The advertised length survives: it describes the representation, and a caller reading + // these headers is entitled to it. + expect(response.headers.get("content-length")).toBe("128"); + // The peer asked to keep the connection alive, so an observed close is the transport + // releasing it rather than the fixture tearing it down. + expect(await awaitDestroyed(targetConnection)).toBe(true); + } finally { + targetConnection?.destroy(); + await Promise.all([close(proxy), close(target)]); + } + }); + }); From 497b7df9e9376cab448297d6c5d2ef02704d09df Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 08:13:18 +0900 Subject: [PATCH 2/4] test(transports): make the interim-answer case prove its own ordering The case enqueued the whole request body up front, so the upload could finish before the peer ever sent 100 and 103 and the claim that the rest of the body went out after them was not causal. The peer now answers the request head before any body byte, and the caller's stream withholds its last chunk and the end of the body until that has happened. Upload continuity is measured against the moment the interim answers were sent rather than against the whole exchange, so the terminator arriving afterwards says what it appears to say. The transport-side receipt is the final answer itself: reading 200 with its body is only possible for something that consumed both interim heads and went on reading, where a transport that stopped at the first would have handed the caller an empty 100. The exchange is also required to end on its own, before the fixture tears any peer down. No production change, and every existing assertion is kept. local checks: NOT RUN --- tests/lib/socks5-upload-lifecycle.test.ts | 51 +++++++++++++++++------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/tests/lib/socks5-upload-lifecycle.test.ts b/tests/lib/socks5-upload-lifecycle.test.ts index a7f13e99e70..c4064a67c88 100644 --- a/tests/lib/socks5-upload-lifecycle.test.ts +++ b/tests/lib/socks5-upload-lifecycle.test.ts @@ -289,19 +289,31 @@ describe("SOCKS5 upload lifecycle", () => { test("interim informational answers are consumed and the upload runs to completion", async () => { /* * A peer may answer 100 and 103 before the request body is finished, and those are not the - * answer. The transport consumes them and keeps uploading; one that treated the first head it - * saw as final would hand the caller an empty 100 and stop writing mid-body. + * answer. The transport consumes them and reads on for the real one, while the upload keeps + * going; one that treated the first head it saw as final would hand the caller an empty 100 + * and stop writing mid-body. + * + * The ordering is arranged rather than hoped for. The peer answers the request head before any + * body byte, and the caller's stream withholds its last chunk and the end of the body until + * that has happened, so everything asserted below about the rest of the upload genuinely + * happened after the interim answers were sent. */ let received = ""; - let informed = false; + let receivedWhenInformed: number | undefined; + let markInformed: () => void = () => { /* replaced below */ }; + const informed = new Promise(resolve => { markInformed = resolve; }); + let markPeerClosed: () => void = () => { /* replaced below */ }; + const peerClosed = new Promise(resolve => { markPeerClosed = resolve; }); const target = createTcpServer(socket => { socket.once("error", () => { /* teardown may reset this peer */ }); + socket.once("close", () => markPeerClosed()); socket.on("data", chunk => { received += chunk.toString("latin1"); - if (!informed && received.includes("\r\n\r\n")) { - informed = true; + if (receivedWhenInformed === undefined && received.includes("\r\n\r\n")) { socket.write("HTTP/1.1 100 Continue\r\n\r\n"); socket.write("HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n"); + receivedWhenInformed = received.length; + markInformed(); } if (!received.includes("0\r\n\r\n")) return; socket.write("HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nposted"); @@ -310,9 +322,16 @@ describe("SOCKS5 upload lifecycle", () => { const proxy = socksProxy(); const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); const encoder = new TextEncoder(); + let released = false; const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode("hello ")); + }, + async pull(controller) { + if (released) return; + released = true; + // Nothing more of this body exists until the peer has answered with both interim heads. + await informed; controller.enqueue(encoder.encode("socks")); controller.close(); }, @@ -320,16 +339,24 @@ describe("SOCKS5 upload lifecycle", () => { try { const response = await post(targetPort, proxyPort, stream); - // The final answer is the one the caller gets, with the interim heads consumed rather than - // surfaced or left in the buffer ahead of the body. - expect(informed).toBe(true); + /* + * The final answer reaching the caller is the transport-side receipt: 200 with its body can + * only be read by something that consumed both interim heads and went on reading. A + * transport that stopped at the first head would answer 100 with nothing in it. + */ expect(response.status).toBe(200); expect(await response.text()).toBe("posted"); - // Upload continuity: everything after the interim answers still reached the peer, including - // the terminating chunk that ends the request. + + // Upload continuity, measured against the moment the interim answers were sent rather than + // against the whole exchange: the rest of the body and its terminator arrived afterwards. + expect(receivedWhenInformed).toBeDefined(); + const afterInterim = received.slice(receivedWhenInformed ?? 0); expect(received).toContain("hello "); - expect(received).toContain("socks"); - expect(received).toContain("0\r\n\r\n"); + expect(afterInterim).toContain("socks"); + expect(afterInterim).toContain("0\r\n\r\n"); + + // The exchange ends by itself, before anything here tears a peer down. + expect(await settlesWithin(peerClosed)).toBe(true); } finally { await Promise.all([close(proxy), close(target)]); } From 899c57ca2a3afc36e752a9b5f33d5adeaf3027af Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 08:26:17 +0900 Subject: [PATCH 3/4] test(transports): witness the interim answers arriving on this side of the tunnel The previous version released the rest of the body once the peer had written 100 and 103, which says the bytes were sent, not that the transport had them. The release now waits until both complete interim heads have arrived on the client side of the tunnel and the reader has had a native scheduling turn to consume them, and it records that the fetch is still pending and the body uncancelled at that moment, which is what says neither head was taken for the answer. The socket is observed through the timer the transport already arms, with a passive data listener; the reader is in flowing mode, so it observes the same chunks and consumes none. No production seam and no sleep. Upload continuity is still measured from the moment the interim answers were sent, the final answer and its body are still the receipt that both were read past, and the exchange must still end on its own before the fixture tears any peer down. local checks: NOT RUN --- tests/lib/socks5-upload-lifecycle.test.ts | 65 +++++++++++++++++------ 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/tests/lib/socks5-upload-lifecycle.test.ts b/tests/lib/socks5-upload-lifecycle.test.ts index c4064a67c88..f1de8d29426 100644 --- a/tests/lib/socks5-upload-lifecycle.test.ts +++ b/tests/lib/socks5-upload-lifecycle.test.ts @@ -289,19 +289,18 @@ describe("SOCKS5 upload lifecycle", () => { test("interim informational answers are consumed and the upload runs to completion", async () => { /* * A peer may answer 100 and 103 before the request body is finished, and those are not the - * answer. The transport consumes them and reads on for the real one, while the upload keeps + * answer. The transport consumes them and reads on for the real one while the upload keeps * going; one that treated the first head it saw as final would hand the caller an empty 100 * and stop writing mid-body. * - * The ordering is arranged rather than hoped for. The peer answers the request head before any - * body byte, and the caller's stream withholds its last chunk and the end of the body until - * that has happened, so everything asserted below about the rest of the upload genuinely - * happened after the interim answers were sent. + * The ordering is arranged rather than hoped for. The rest of the body is withheld until both + * interim heads have arrived at this side of the tunnel and the reader has had a scheduling + * turn to consume them, and the fetch is required to be unsettled at that moment. So + * everything asserted afterwards about the remainder of the upload genuinely happened after + * the transport had those answers in hand and had not mistaken either for the final one. */ let received = ""; let receivedWhenInformed: number | undefined; - let markInformed: () => void = () => { /* replaced below */ }; - const informed = new Promise(resolve => { markInformed = resolve; }); let markPeerClosed: () => void = () => { /* replaced below */ }; const peerClosed = new Promise(resolve => { markPeerClosed = resolve; }); const target = createTcpServer(socket => { @@ -313,7 +312,6 @@ describe("SOCKS5 upload lifecycle", () => { socket.write("HTTP/1.1 100 Continue\r\n\r\n"); socket.write("HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n"); receivedWhenInformed = received.length; - markInformed(); } if (!received.includes("0\r\n\r\n")) return; socket.write("HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\nposted"); @@ -321,8 +319,21 @@ describe("SOCKS5 upload lifecycle", () => { }); const proxy = socksProxy(); const [targetPort, proxyPort] = await Promise.all([listen(target), listen(proxy)]); + + // Both interim heads, seen arriving on this side of the tunnel rather than merely written by + // the peer. The reader is already in flowing mode, so this listener observes the same chunks + // it does and consumes nothing. + let clientSeen = ""; + let markInterimAtClient: () => void = () => { /* replaced below */ }; + const interimAtClient = new Promise(resolve => { markInterimAtClient = resolve; }); + const originalSetTimeout = Socket.prototype.setTimeout; + const encoder = new TextEncoder(); let released = false; + let settledAtRelease: boolean | undefined; + let cancelledAtRelease: boolean | undefined; + let settled = false; + let cancelled = false; const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode("hello ")); @@ -330,25 +341,48 @@ describe("SOCKS5 upload lifecycle", () => { async pull(controller) { if (released) return; released = true; - // Nothing more of this body exists until the peer has answered with both interim heads. - await informed; + await interimAtClient; + // One native checkpoint, which the reader's buffered-header continuations run ahead of. + await new Promise(resolve => { setImmediate(resolve); }); + settledAtRelease = settled; + cancelledAtRelease = cancelled; controller.enqueue(encoder.encode("socks")); controller.close(); }, + cancel() { cancelled = true; }, }); + try { - const response = await post(targetPort, proxyPort, stream); + Socket.prototype.setTimeout = function patched(this: Socket, ms: number, callback?: () => void) { + if (ms === RESPONSE_TIMEOUT_MS) { + this.on("data", chunk => { + clientSeen += chunk.toString("latin1"); + if (clientSeen.includes("HTTP/1.1 100 Continue\r\n\r\n") + && clientSeen.includes("Link: ; rel=preload\r\n\r\n")) markInterimAtClient(); + }); + } + return originalSetTimeout.call(this, ms, callback as never); + } as typeof Socket.prototype.setTimeout; + + const pending = post(targetPort, proxyPort, stream); + void pending.then(() => { settled = true; }, () => { settled = true; }); + const response = await pending; + + // Still reading when the remainder was released: the transport had both interim heads and + // had treated neither as the answer. + expect(settledAtRelease).toBe(false); + expect(cancelledAtRelease).toBe(false); /* - * The final answer reaching the caller is the transport-side receipt: 200 with its body can - * only be read by something that consumed both interim heads and went on reading. A - * transport that stopped at the first head would answer 100 with nothing in it. + * The final answer reaching the caller is the receipt that those heads were consumed rather + * than merely delivered: 200 with its body can only be read by something that read past + * both of them. */ expect(response.status).toBe(200); expect(await response.text()).toBe("posted"); // Upload continuity, measured against the moment the interim answers were sent rather than - // against the whole exchange: the rest of the body and its terminator arrived afterwards. + // against the whole exchange. expect(receivedWhenInformed).toBeDefined(); const afterInterim = received.slice(receivedWhenInformed ?? 0); expect(received).toContain("hello "); @@ -358,6 +392,7 @@ describe("SOCKS5 upload lifecycle", () => { // The exchange ends by itself, before anything here tears a peer down. expect(await settlesWithin(peerClosed)).toBe(true); } finally { + Socket.prototype.setTimeout = originalSetTimeout; await Promise.all([close(proxy), close(target)]); } }); From 9e3ee90f930d6f7ebaf94f3a2dd2425a6f7ce332 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 08:48:33 +0900 Subject: [PATCH 4/4] test(transports): make the interim-answer fixture fail safely The held tail lived in a stream pull that awaited a receipt which, if it never arrived, would park the fetch until the runner's own deadline. The finally block restoring the global Socket prototype would not have run by then, and the data observer was anonymous, so nothing could remove it either. The tail is now held by the test body with bounded waits on the receipt, the outcome and the response, the observer is named and removed, and the fetch is owned by an AbortController the fixture aborts on the way out. The prototype is restored first, before anything else in teardown. Every causal assertion is kept and two more are added: at the moment the remainder is released, the peer has not yet seen it or the terminator, which makes the ordering the assertions rely on visible rather than assumed. The timeout constant and the response and closure checks are unchanged. local checks: NOT RUN --- tests/lib/socks5-upload-lifecycle.test.ts | 69 +++++++++++++---------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/tests/lib/socks5-upload-lifecycle.test.ts b/tests/lib/socks5-upload-lifecycle.test.ts index f1de8d29426..18b1b4975b8 100644 --- a/tests/lib/socks5-upload-lifecycle.test.ts +++ b/tests/lib/socks5-upload-lifecycle.test.ts @@ -327,51 +327,54 @@ describe("SOCKS5 upload lifecycle", () => { let markInterimAtClient: () => void = () => { /* replaced below */ }; const interimAtClient = new Promise(resolve => { markInterimAtClient = resolve; }); const originalSetTimeout = Socket.prototype.setTimeout; + let transportSocket: Socket | undefined; + const observeInterim = (chunk: Buffer | string): void => { + clientSeen += typeof chunk === "string" ? chunk : chunk.toString("latin1"); + if (clientSeen.includes("HTTP/1.1 100 Continue\r\n\r\n") + && clientSeen.includes("HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n")) { + markInterimAtClient(); + } + }; const encoder = new TextEncoder(); - let released = false; - let settledAtRelease: boolean | undefined; - let cancelledAtRelease: boolean | undefined; + let bodyController!: ReadableStreamDefaultController; let settled = false; let cancelled = false; const stream = new ReadableStream({ start(controller) { + bodyController = controller; controller.enqueue(encoder.encode("hello ")); }, - async pull(controller) { - if (released) return; - released = true; - await interimAtClient; - // One native checkpoint, which the reader's buffered-header continuations run ahead of. - await new Promise(resolve => { setImmediate(resolve); }); - settledAtRelease = settled; - cancelledAtRelease = cancelled; - controller.enqueue(encoder.encode("socks")); - controller.close(); - }, cancel() { cancelled = true; }, }); + const abort = new AbortController(); + let outcome: Promise | undefined; try { Socket.prototype.setTimeout = function patched(this: Socket, ms: number, callback?: () => void) { - if (ms === RESPONSE_TIMEOUT_MS) { - this.on("data", chunk => { - clientSeen += chunk.toString("latin1"); - if (clientSeen.includes("HTTP/1.1 100 Continue\r\n\r\n") - && clientSeen.includes("Link: ; rel=preload\r\n\r\n")) markInterimAtClient(); - }); + if (ms === RESPONSE_TIMEOUT_MS && transportSocket === undefined) { + transportSocket = this; + this.on("data", observeInterim); } return originalSetTimeout.call(this, ms, callback as never); } as typeof Socket.prototype.setTimeout; - const pending = post(targetPort, proxyPort, stream); - void pending.then(() => { settled = true; }, () => { settled = true; }); - const response = await pending; + const pending = post(targetPort, proxyPort, stream, abort.signal); + // Both handlers are attached before waiting for receipt; outcome never rejects. + outcome = pending.then(() => { settled = true; }, () => { settled = true; }); + expect(await settlesWithin(interimAtClient)).toBe(true); + // Complete client receipt followed by a native checkpoint drains the current parser's + // buffered-header promise continuations before another body chunk becomes available. + await new Promise(resolve => { setImmediate(resolve); }); + expect(settled).toBe(false); + expect(cancelled).toBe(false); + expect(received).not.toContain("socks"); + expect(received).not.toContain("0\r\n\r\n"); + bodyController.enqueue(encoder.encode("socks")); + bodyController.close(); - // Still reading when the remainder was released: the transport had both interim heads and - // had treated neither as the answer. - expect(settledAtRelease).toBe(false); - expect(cancelledAtRelease).toBe(false); + expect(await settlesWithin(outcome)).toBe(true); + const response = await pending; /* * The final answer reaching the caller is the receipt that those heads were consumed rather @@ -379,7 +382,9 @@ describe("SOCKS5 upload lifecycle", () => { * both of them. */ expect(response.status).toBe(200); - expect(await response.text()).toBe("posted"); + const responseText = response.text(); + expect(await settlesWithin(responseText)).toBe(true); + expect(await responseText).toBe("posted"); // Upload continuity, measured against the moment the interim answers were sent rather than // against the whole exchange. @@ -393,7 +398,13 @@ describe("SOCKS5 upload lifecycle", () => { expect(await settlesWithin(peerClosed)).toBe(true); } finally { Socket.prototype.setTimeout = originalSetTimeout; - await Promise.all([close(proxy), close(target)]); + transportSocket?.removeListener("data", observeInterim); + try { + abort.abort(new Error("informational fixture teardown")); + } finally { + await Promise.all([close(proxy), close(target)]); + if (outcome) await settlesWithin(outcome); + } } });