Written by an AI agent (Claude) on behalf of @jasonkuhrt.
Version
0.17.0 (main at 1d2dbbc)
Steps to reproduce
- The proxy's pool holds two idle sockets to a backend.
- A GET is sent on one of them, and the backend resets it.
- The replay is sent on the other, and the backend resets that one too.
The existing test "replays a request when the backend resets a pooled connection" injects that reset as what an idle close looks like when it crosses the proxy's write. This test does the same with two pooled sockets instead of one. Added beside it in packages/portless/src/proxy.test.ts, it fails on main in every run:
Test
it("replays on a new connection when every pooled connection is stale", async () => {
// Answer the first request on a connection and reset the next one, which
// is what an idle close looks like when it crosses our write. Here both
// pooled connections do it.
const handled = new WeakSet<net.Socket>();
const warming: http.ServerResponse[] = [];
let resets = 0;
const backend = trackServer(
http.createServer((req, res) => {
if (handled.has(req.socket)) {
resets++;
req.socket.resetAndDestroy();
return;
}
handled.add(req.socket);
if (req.url !== "/warm") return res.end("ok");
// Hold both warm-up answers so the pool opens two connections.
warming.push(res);
if (warming.length === 2) for (const pending of warming) pending.end("ok");
})
);
await listen(backend);
const backendAddr = backend.address();
if (!backendAddr || typeof backendAddr === "string") throw new Error("no addr");
const errors: string[] = [];
const routes: RouteInfo[] = [{ hostname: "myapp.localhost", port: backendAddr.port }];
const server = trackServer(
createProxyServer({
getRoutes: () => routes,
proxyPort: TEST_PROXY_PORT,
onError: (msg) => errors.push(msg),
})
);
await listen(server);
await Promise.all([
request(server, { host: "myapp.localhost", path: "/warm" }),
request(server, { host: "myapp.localhost", path: "/warm" }),
]);
const res = await request(server, { host: "myapp.localhost" });
expect(res.status).toBe(200);
expect(res.body).toBe("ok");
expect(resets).toBe(1);
expect(errors).toEqual([]);
});Expected behavior
The replay reaches the backend on a connection that works, and the client gets its 200, as it does with one such socket.
Actual behavior
The client gets a 502, and the proxy logs Proxy error for myapp.localhost: read ECONNRESET.
AssertionError: expected 502 to be 200 // Object.is equalityCause
sequenceDiagram
participant C as Client
participant P as Portless
participant B as Backend
Note over P,B: pool: idle sockets 1 and 2
C->>P: GET /
P->>B: GET / on idle socket 1
B-->>P: reset
Note over P: reused socket and ECONNRESET, so replay once
P->>B: GET / on idle socket 2
B-->>P: reset
Note over P: already a replay
P-->>C: 502The replay at proxy.ts#L705-L719 goes through the same agent as the first attempt, so it takes the pool's next idle socket. The reset it recovers from is a sign that the backend is closing idle connections. An idle timeout can close several sockets that went idle together, and a socket whose close the proxy hasn't seen yet is still in the pool. When the replay lands on one of those, the request fails with the 502 the replay exists to prevent.
A fix: the replay goes through a second agent that dials a new connection and never keeps it, counted by the same limit on dials in progress (MAX_PENDING_UPSTREAM_DIALS). An idle socket doesn't count as ready for a replay while it waits for a slot, and proxy shutdown destroys the second agent's sockets along with the pool's.
Environment
macOS 27.2, Node 24.22.2.
Activity
0No activity yet.