#576

After an early answer to an upload, the next request on the same HTTP/1.1 connection gets no answer

Openjasonkuhrtopened 4d ago

Written by an AI agent (Claude) on behalf of @jasonkuhrt.

Version

0.17.0 (main at 1d2dbbc)

Steps to reproduce

  1. An app answers an upload from its headers, before reading the body, as it does for a 413 or a 401.
  2. An HTTP/1.1 client that keeps its connection alive sends the upload through Portless, gets that answer, finishes sending the body, and sends its next request on the same connection.

This test, added to packages/portless/src/proxy.test.ts beside "keeps reading the request body after a 502 so the connection can be reused", fails on main in every run:

Test
it("keeps reading the request body after an early response so the connection can be reused", async () => {
  // Reject an upload from its headers, before reading its body, the way an
  // app answers 413 or 401.
  const backend = trackServer(
    http.createServer((req, res) => {
      if (req.method === "POST") {
        res.statusCode = 413;
        res.end("too large");
        return;
      }
      res.end("ok");
    })
  );
  await listen(backend);
  const backendAddr = backend.address();
  if (!backendAddr || typeof backendAddr === "string") throw new Error("no addr");

  const routes: RouteInfo[] = [{ hostname: "api.localhost", port: backendAddr.port }];
  const server = trackServer(
    createProxyServer({ getRoutes: () => routes, proxyPort: TEST_PROXY_PORT })
  );
  await listen(server);

  const addr = server.address();
  if (!addr || typeof addr === "string") throw new Error("no addr");

  // The client sends the rest of the body after the answer arrives, then
  // its next request on the same connection.
  const bodySize = 1024 * 1024;
  const client = net.createConnection(addr.port, "127.0.0.1");
  const statuses = await new Promise<string[]>((resolve, reject) => {
    let data = "";
    let sentRest = false;
    const statuses = () => data.match(/HTTP\/1\.1 \d{3}/g) || [];
    const timer = setTimeout(() => resolve(statuses()), 2000);
    client.on("data", (chunk) => {
      data += chunk;
      if (!sentRest && data.endsWith("too large")) {
        sentRest = true;
        client.write(Buffer.alloc(bodySize - 1024));
        client.write("GET / HTTP/1.1\r\nHost: api.localhost\r\n\r\n");
      }
      if (statuses().length === 2) {
        clearTimeout(timer);
        resolve(statuses());
      }
    });
    client.on("error", reject);
    client.write(
      `POST /upload HTTP/1.1\r\nHost: api.localhost\r\nContent-Length: ${bodySize}\r\n\r\n`
    );
    client.write(Buffer.alloc(1024));
  });
  client.destroy();
  expect(statuses).toEqual(["HTTP/1.1 413", "HTTP/1.1 200"]);
});

Expected behavior

The client gets the 413. Once it has sent the rest of the body, its next request on the same connection gets an answer, as it does when the client talks to the app directly.

Actual behavior

The client gets the 413, and its next request on that connection never gets an answer. Nothing is logged.

AssertionError: expected [ 'HTTP/1.1 413' ] to deeply equal [ 'HTTP/1.1 413', 'HTTP/1.1 200' ]

The same exchange from a Node http.Agent with keepAlive: true, one connection, 3 runs each:

Client to Portless 0.17.0Next request on the connection
HTTP/1.1no answer within 2 s, every run
HTTP/1.1 over TLSno answer within 2 s, every run
HTTP/2, one session200, every run

Cause

sequenceDiagram
    participant C as Client
    participant P as Portless
    participant A as App
    C->>P: POST /upload, Content-Length 1 MB, first 1 KB
    P->>A: POST /upload, first 1 KB
    A-->>P: 413, body unread
    P-->>C: 413
    Note over P: response finishes, emits close<br/>close handler destroys proxyReq<br/>req stays unpiped and paused
    C->>P: rest of the body, then GET /
    Note over P: req is never read again,<br/>so GET / is never parsed

Node emits close on a ServerResponse once the response finishes, not only when the client disconnects. The handler at proxy.ts#L742-L748 destroys proxyReq in both cases. When the app answered early, proxyReq is still receiving the piped body, so destroying it unpipes req and leaves it paused. Nothing reads the rest of the body after that, and the request that follows it on the connection is never parsed.

#489 fixed the same stall after a backend error by unpiping and resuming req in the error handler. The close handler needs the same:

       res.on("close", () => {
         connection.cancel?.();
         if (!proxyReq.destroyed) {
+          req.unpipe(proxyReq);
+          req.resume();
           proxyReq.destroy();
         }
       });

This follows #525: on 0.17.0 the early answer reaches the client, since #477's keep-alive pool stopped the app from resetting the upstream connection. This stall is what remains after it.

Environment

macOS 27.2, Node 24.22.2.

Activity

0

No activity yet.