#531

502s with an empty error message on page loads: the proxy opens one uncapped upstream socket per request, and a busy backend's listen queue (128 on macOS) resets the rest

Opendimadev01opened 13d ago

Summary

The proxy dials a new upstream connection for every request (http.request with createConnection: () => createLoopbackConnection(route.port) and no agent), with no limit. A dev-server page load sends a few hundred module requests at once, so the backend gets a few hundred connects at once. While its event loop is busy, for example Vite transforming modules, it cannot accept() them. macOS holds at most 128 pending connections per listen socket (kern.ipc.somaxconn) and resets the rest on loopback right away. Each reset becomes a 502 with a blank log line, and one failed module import leaves the page white.

Environment

  • portless 0.15.8 as the launchd proxy service (proxy start --foreground --port 443 --https); the repro below uses 0.15.12. src/proxy.ts on main (5758ac594) dials the same way.
  • macOS 27.2, Apple silicon (M3 Max), Node 24.22.2
  • Backend: Vite 8.3.5, started with --host 127.0.0.1, serving a React app whose main page loads about 470 modules

What happens

In the browser, the page sometimes stays blank: a few to a few hundred module requests come back as the 502 page. Reloading doesn't reliably fix it, and curl of the same URLs one at a time returns 200. The service log gets one blank line per failure:

Proxy error for <host>: 

Fetching the page's 470 module URLs through the :443 proxy with curl --http2 --parallel --parallel-max 300 gave about 300 502s against a freshly started Vite and 26 to 132 against a warm one. Bursts of 50 at a time never failed.

The error the proxy logs is an AggregateError (hence the empty message) with inner errors ECONNRESET 127.0.0.1 and ECONNREFUSED ::1. 127.0.0.1 was reset by the full queue, and the fallback to ::1 is refused because Vite listens on 127.0.0.1 only. netstat -Lan -p tcp shows maxqlen 128 for the backend's port.

Why

  • src/proxy.ts:460 (and the two WebSocket paths) passes createConnection without an agent, so each proxied request opens its own socket, and nothing limits how many are in flight to one backend.
  • Node asks for a backlog of 511, but macOS clamps it to kern.ipc.somaxconn, 128 by default.
  • A busy backend only drains its queue between event-loop turns. Once more than 128 connects are pending, the kernel resets the rest. Over HTTP/2 the browser sends the whole batch at once, so the proxy turns one burst on a single connection into that many simultaneous connects.

This is a different trigger from #488, which shows the same blank line. There the proxy's own loop stalls and the 250 ms Happy Eyeballs timer abandons a connect that succeeded (ETIMEDOUT 127.0.0.1). Here nothing times out: the reset is immediate, so a longer attempt timeout or remembering the last address wouldn't help.

Reproduction

No Vite needed. npm i portless@0.15.12, then node repro.mjs 300. It runs Portless's own createProxyServer (plain HTTP) in front of a backend whose event loop is busy for 1 s while 300 requests arrive. The client paces its HTTP/1.1 sockets 50 at a time, so only the proxy-to-backend hop is tested.

Results on the machine above, the same in every run (3 or more each):

requests200502inner dial errors
1281280–
20012872ECONNRESET 127.0.0.1 + ECONNREFUSED ::1 × 72
300128172same × 172

At 470, 128 still succeed every run, but the client's own HTTP/1.1 sockets start overflowing the proxy's queue as well, which a browser's single HTTP/2 connection doesn't.

repro.mjs
// node repro.mjs [requests=300] [busyMs=1000]
//
// One upstream, Portless's own createProxyServer in front of it, and a client
// that sends `requests` requests through the proxy at once, the way a browser
// loads a Vite page's modules. The upstream's event loop is busy for `busyMs`
// when the burst arrives, like a dev server transforming modules, so it cannot
// accept() the connections the proxy opens, one per request.
import { fork } from "node:child_process";
import http from "node:http";
import net from "node:net";

const [role, arg] = process.argv.slice(2);

if (role === "--upstream") {
  const server = http.createServer((_req, res) => res.end("ok"));
  server.listen(0, "127.0.0.1", () => process.send(server.address().port));
  process.on("message", () => {
    const until = Date.now() + Number(arg);
    while (Date.now() < until) {}
  });
} else if (role === "--proxy") {
  const { createProxyServer } = await import("portless");
  const errors = {};
  const dialErrors = {};
  // onError only gets err.message, which is empty for an AggregateError.
  const emit = net.Socket.prototype.emit;
  net.Socket.prototype.emit = function (event, err, ...rest) {
    if (event === "error" && err instanceof AggregateError) {
      const key = err.errors.map((e) => `${e.code} ${e.address}`).join(" + ");
      dialErrors[key] = (dialErrors[key] ?? 0) + 1;
    }
    return emit.call(this, event, err, ...rest);
  };
  const server = createProxyServer({
    getRoutes: () => [{ hostname: "app.localhost", port: Number(arg), pid: 0 }],
    proxyPort: 0,
    onError: (message) => (errors[message] = (errors[message] ?? 0) + 1),
  });
  server.listen(0, "127.0.0.1", () => process.send(server.address().port));
  process.on("message", () => process.send({ dialErrors, errors }));
} else {
  const requests = Number(process.argv[2] ?? 300);
  const busyMs = Number(process.argv[3] ?? 1000);
  const started = (child) =>
    new Promise((resolve) => child.once("message", resolve));
  const script = new URL(import.meta.url).pathname;
  const upstream = fork(script, ["--upstream", String(busyMs)]);
  const upstreamPort = await started(upstream);
  const proxy = fork(script, ["--proxy", String(upstreamPort)]);
  const proxyPort = await started(proxy);

  upstream.send("get busy");
  await new Promise((resolve) => setTimeout(resolve, 50));
  const agent = new http.Agent({ keepAlive: false, maxSockets: Infinity });
  const statuses = {};
  const pending = [];
  // A browser sends these over one HTTP/2 connection. Plain HTTP/1.1 needs a
  // socket each, so open them 50 at a time to keep the proxy's own listen
  // queue out of the picture; all of them still land inside busyMs.
  for (let i = 0; i < requests; i++) {
    if (i > 0 && i % 50 === 0)
      await new Promise((resolve) => setTimeout(resolve, 10));
    pending.push(
      new Promise((resolve) => {
        http
          .get(
            {
              agent,
              host: "127.0.0.1",
              port: proxyPort,
              path: `/module-${i}.js`,
              headers: { host: "app.localhost" },
            },
            (res) => {
              statuses[res.statusCode] = (statuses[res.statusCode] ?? 0) + 1;
              res.resume().on("end", resolve);
            },
          )
          .on("error", (err) => {
            statuses[err.code] = (statuses[err.code] ?? 0) + 1;
            resolve();
          });
      }),
    );
  }
  await Promise.all(pending);
  proxy.send("report");
  const { dialErrors, errors } = await started(proxy);
  console.log({
    node: process.version,
    requests,
    busyMs,
    statuses,
    proxyErrors: errors,
    dialErrors,
  });
  upstream.kill();
  proxy.kill();
}

Suggested fix

Give each backend port an http.Agent with maxSockets, so excess requests wait in the proxy instead of in the backend's listen queue. The agent still dials through createLoopbackConnection, so ::1-only backends keep working. I tested exactly this cap-only change, applied to 0.15.12's built dist, with the repro: 0 failures at 300, 470 and 1000 requests. Keep-alive alone (keepAlive: true, no cap) changed nothing, because every connection in a burst is new. Keep-alive on top of the cap also passed, but it isn't needed and brings back the stale-socket questions from #434.

 export function createProxyServer(options: ProxyServerOptions): ProxyServer {
   ...
+  // Caps the connections the proxy opens to each backend at once. A page load
+  // otherwise opens one per request, and a busy backend's listen queue (128 on
+  // macOS) resets the rest.
+  const upstreamAgents = new Map<number, http.Agent>();
+  const upstreamAgent = (port: number): http.Agent => {
+    let agent = upstreamAgents.get(port);
+    if (!agent) {
+      agent = new http.Agent({ maxSockets: 64 });
+      // Dial via createLoopbackConnection so ::1-only backends work too.
+      agent.createConnection = () => createLoopbackConnection(port);
+      upstreamAgents.set(port, agent);
+    }
+    return agent;
+  };
   ...
     const proxyReq = http.request(
       {
-        // Dial via createLoopbackConnection so ::1-only backends work too.
-        createConnection: () => createLoopbackConnection(route.port),
+        agent: upstreamAgent(route.port),
+        port: route.port,
         path: req.url,

Notes:

  • I left the WebSocket paths on direct dials. An upgraded socket would hold one of the 64 slots for its whole life.
  • 64 is half the macOS default queue. A setting would let people raise it with somaxconn.
  • Without keep-alive the agent still sends Connection: close and closes each socket after its response, as the proxy does today.
  • #488's fourth suggestion, logging err.code and the inner errors, would have made both issues obvious from the log.

Workaround

Put a small TCP relay between the proxy and the dev server, on the port Portless assigns. The relay accepts every connection right away (its loop is idle) and forwards at most 64 that the dev server hasn't answered yet, with the dev server on an internal port. In our project that took the 300-wide burst from about 300 502s to none, and cold page loads in the browser stopped going blank.

Related

  • #488: same blank Proxy error line, from a proxy-side stall (ETIMEDOUT)
  • #320 / #353: introduced the dual-family createLoopbackConnection dial

Activity

1
jasonkuhrt4d ago

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

This still reproduces on 0.17.0, with #477's pool and its limit of 8 dials in progress per backend.

A dial leaves that limit once its connection is up: when the TCP handshake completes for a plain backend, after secureConnect for a TLS one (proxy.ts#L501-L511). On macOS the kernel completes a handshake from the listen socket's accept queue whether or not the backend has called accept(), and resets a connect once the queue is full. So while a plain backend is busy, finished dials keep freeing slots, the proxy keeps dialing until the queue is full, and the connects after that are reset.

sequenceDiagram
    participant P as Portless, 8 dials at most
    participant K as macOS accept queue, backlog 16
    participant B as Backend, not accepting
    loop until the queue is full
        P->>K: connect
        K-->>P: handshake done, so the dial slot frees
    end
    P->>K: connect
    K-->>P: reset
    Note over P: 502 for that request

Measured on macOS 27.2, Node 24.22.2, 0.17.0: 100 GETs at once through createProxyServer to a backend that listens with a backlog of 16 and doesn't call accept() for 3 seconds. One second in, the same in every run (3). The script replaces digits in error messages with N to group them:

{"node":"v24.22.2","requests":100,"after1s":{"statuses":{"502":84},"pending":16},"dialErrors":{"Proxy error for app.localhost: Both loopback connections failed; Error: connect ECONNRESET N.N.N.N:N; Error: connect ECONNREFUSED ::N:N":84}}

The 16 pending requests are the connections sitting in the queue. A limit that held each slot until the backend accepted would have left all 100 waiting.

A burst of replays reaches the same limit once replays dial new connections (#579): in our proxy tests, 192 replays at once against a backend with a backlog of 128 got 41 × 502 with the same connect ECONNRESET. In our patched copy, a replay keeps its dial slot until the backend sends its first byte, the first sign the proxy gets that the backend accepted. That isn't a drop-in answer for ordinary dials: a response that sends nothing for a long time, such as a long poll, would hold a slot, which #477 set out to avoid for long-lived responses.

busy-backend.mjs (Node 24+, Python 3 for the listener)
// node busy-backend.mjs <portless entry, e.g. node_modules/portless/dist/index.js> [requests=100]
// A burst of ordinary GETs through Portless's createProxyServer to a backend
// that is listening but not calling accept(), like a dev server whose event
// loop is busy. Counts what the client gets back within the busy window.
import { spawn } from "node:child_process";
import http from "node:http";

const entry = process.argv[2];
const requests = Number(process.argv[3] ?? 100);
const { createProxyServer } = await import(entry);

// Listen with a backlog of 16 and never accept for 3 s.
const listener = spawn("python3", ["-I", "-c", [
  "import socket, sys, time",
  "s = socket.socket()",
  "s.bind(('127.0.0.1', 0))",
  "s.listen(16)",
  "print(s.getsockname()[1], flush=True)",
  "time.sleep(3)",
].join("\n")], { stdio: ["ignore", "pipe", "inherit"] });
const backendPort = Number(await new Promise((r) => listener.stdout.once("data", r)));

const dialErrors = {};
const proxy = createProxyServer({
  getRoutes: () => [{ hostname: "app.localhost", port: backendPort }],
  proxyPort: 0,
  onError: (m) => {
    const key = m.replace(/\d+/g, "N");
    dialErrors[key] = (dialErrors[key] ?? 0) + 1;
  },
});
await new Promise((r) => proxy.listen(0, "127.0.0.1", r));
const proxyPort = proxy.address().port;

const agent = new http.Agent({ keepAlive: true, maxSockets: requests });
const statuses = {};
for (let i = 0; i < requests; i++) {
  http
    .get({ agent, host: "127.0.0.1", port: proxyPort, path: `/m${i}.js`, headers: { host: "app.localhost" } }, (res) => {
      statuses[res.statusCode] = (statuses[res.statusCode] ?? 0) + 1;
      res.resume();
    })
    .on("error", (e) => (statuses[e.code] = (statuses[e.code] ?? 0) + 1));
}
await new Promise((r) => setTimeout(r, 1000));
console.log(JSON.stringify({ node: process.version, requests, after1s: { statuses, pending: requests - Object.values(statuses).reduce((a, b) => a + b, 0) }, dialErrors }));
listener.kill();
agent.destroy();
proxy.close();
process.exit(0);