Repository: denoland/deno
Pull request: fix(node/http): preserve request body on stale keepalive retry

Closes https://github.com/denoland/deno/issues/32006

When a reused keepAlive socket goes stale, the retry logic was not re-sending the request body for POST/PUT/PATCH requests. The body data in outputData was consumed by _flushBuffer() before the stale connection error surfaced at op_node_http_await_response, so the retried request had no body to send, causing it to hang indefinitely.

Fix by saving outputData before flushing on reused sockets and restoring it on retry, along with resetting the body stream state so _writeHeader() creates a fresh TransformStream.

Changed files:
filename: ext/node/polyfills/http.ts
patch: @@ -572,6 +572,11 @@ class ClientRequest extends OutgoingMessage {
           this._bodyWriteRid,
           baseConnRid,
         );
+        // Save body data before flushing so it can be restored if
+        // a stale keepAlive socket fails during the response phase.
+        if (this.reusedSocket && this.outputData.length > 0) {
+          this._outputDataForRetry = [...this.outputData];
+        }
         this._flushBuffer();
 
         const infoPromise = op_node_http_await_information(
@@ -741,6 +746,16 @@ class ClientRequest extends OutgoingMessage {
             this._headerSent = false;
             this.reusedSocket = false;
             this._req = null;
+            // Reset body stream state so _writeHeader() creates a
+            // fresh TransformStream on the next attempt.
+            this._bodyWriter = null;
+            this._bodyWriteRid = null;
+            this._bodyWritable = null;
+            // Restore body data that was consumed by the failed attempt.
+            if (this._outputDataForRetry) {
+              this.outputData = this._outputDataForRetry;
+              this._outputDataForRetry = null;
+            }
             this.agent.addRequest(this, this._agentOptions);
             return;
           }

filename: tests/specs/node/http_agent_keepalive_stale_post/__test__.jsonc
patch: @@ -0,0 +1,4 @@
+{
+  "args": "run -A main.ts",
+  "output": "main.out"
+}

filename: tests/specs/node/http_agent_keepalive_stale_post/main.out
patch: @@ -0,0 +1,3 @@
+Request 1: OK {"received":"{\"test\":true}"}
+Request 2: OK {"received":"{\"test\":true}"}
+PASS

filename: tests/specs/node/http_agent_keepalive_stale_post/main.ts
patch: @@ -0,0 +1,110 @@
+// Test that POST requests on stale keepAlive connections are transparently
+// retried with the request body intact (not lost, which causes hangs).
+// Regression test for https://github.com/denoland/deno/issues/32006
+import http from "node:http";
+import net from "node:net";
+
+let idleSocketClosed: Promise<void>;
+let resolveIdleSocketClosed: () => void;
+idleSocketClosed = new Promise((r) => (resolveIdleSocketClosed = r));
+
+const server = net.createServer((socket) => {
+  let idleTimer: ReturnType<typeof setTimeout> | null = null;
+
+  const resetIdle = () => {
+    if (idleTimer) clearTimeout(idleTimer);
+    idleTimer = setTimeout(() => socket.destroy(), 50);
+  };
+
+  let buffer = "";
+  socket.on("data", (data) => {
+    buffer += data.toString();
+    const headerEnd = buffer.indexOf("\r\n\r\n");
+    if (headerEnd === -1) return;
+
+    // Extract content-length from headers
+    const headers = buffer.substring(0, headerEnd);
+    const clMatch = headers.match(/content-length:\s*(\d+)/i);
+    const contentLength = clMatch ? parseInt(clMatch[1]) : 0;
+    const bodyStart = headerEnd + 4;
+    const body = buffer.substring(bodyStart, bodyStart + contentLength);
+
+    if (body.length < contentLength) return; // wait for full body
+
+    const responseBody = JSON.stringify({ received: body });
+    const response = [
+      "HTTP/1.1 200 OK",
+      `Content-Length: ${responseBody.length}`,
+      "Connection: keep-alive",
+      "",
+      responseBody,
+    ].join("\r\n");
+    socket.write(response);
+    buffer = "";
+    resetIdle();
+  });
+
+  socket.on("close", () => {
+    if (idleTimer) clearTimeout(idleTimer);
+    resolveIdleSocketClosed();
+  });
+});
+
+const agent = new http.Agent({
+  keepAlive: true,
+  maxSockets: 1,
+});
+
+function postRequest(port: number): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const timeout = setTimeout(() => reject(new Error("timeout")), 5000);
+    const req = http.request(
+      {
+        hostname: "127.0.0.1",
+        port,
+        method: "POST",
+        agent,
+        headers: { "Content-Length": "13" },
+      },
+      (res) => {
+        let data = "";
+        res.on("data", (c: Buffer) => (data += c));
+        res.on("end", () => {
+          clearTimeout(timeout);
+          resolve(data);
+        });
+      },
+    );
+    req.on("error", (e: Error) => {
+      clearTimeout(timeout);
+      reject(e);
+    });
+    req.write('{"test":true}');
+    req.end();
+  });
+}
+
+server.listen(0, async () => {
+  const port = (server.address() as net.AddressInfo).port;
+  try {
+    // First POST request - establishes keepAlive connection
+    const first = await postRequest(port);
+    console.log("Request 1: OK", first);
+
+    // Wait until the server actually closes the idle socket
+    await idleSocketClosed;
+    await new Promise((r) => setTimeout(r, 50));
+
+    // Second POST request on stale socket - should retry with body
+    const second = await postRequest(port);
+    console.log("Request 2: OK", second);
+
+    console.log("PASS");
+  } catch (e) {
+    console.error("FAIL:", (e as Error).message);
+    Deno.exit(1);
+  } finally {
+    server.close();
+    agent.destroy();
+  }
+});