diff --git a/backend/src/runners/loopback.test.ts b/backend/src/runners/loopback.test.ts index b964c1a6c..6db6a8127 100644 --- a/backend/src/runners/loopback.test.ts +++ b/backend/src/runners/loopback.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import type { MuxStream } from "@useagent/runner-protocol"; import { FORWARDER_IDLE_MS, LoopbackForwarders } from "./loopback"; @@ -31,6 +31,29 @@ describe("loopback forwarders", () => { forwarders.closeAll(); }); + test("a connection that errors and then closes frees its listener for the sweep", () => { + let now = 1_000_000; + // Capture the socket handlers so the error-then-close order can be driven directly. + let handlers: Bun.SocketHandler<{ closed: boolean }> | undefined; + const listen = spyOn(Bun, "listen").mockImplementation(((options: { socket: typeof handlers }) => { + handlers = options.socket; + return { port: 40_000, stop() {} }; + }) as unknown as typeof Bun.listen); + try { + const forwarders = new LoopbackForwarders({ now: () => now }); + forwarders.address("c1", 80, never); + const socket = { data: undefined, end() {} } as unknown as Bun.Socket<{ closed: boolean }>; + handlers!.open!(socket); + handlers!.error!(socket, new Error("ECONNRESET")); + handlers!.close!(socket, undefined); + now += FORWARDER_IDLE_MS; + expect(forwarders.sweep()).toBe(1); + expect(forwarders.size).toBe(0); + } finally { + listen.mockRestore(); + } + }); + test("a stream that cannot be opened ends the browser connection", async () => { const forwarders = new LoopbackForwarders(); const address = forwarders.address("c1", 80, () => { diff --git a/backend/src/runners/loopback.ts b/backend/src/runners/loopback.ts index e0f20b20f..3d7baae7e 100644 --- a/backend/src/runners/loopback.ts +++ b/backend/src/runners/loopback.ts @@ -55,6 +55,14 @@ export class LoopbackForwarders { } const entry: Entry = { forwarder: null as unknown as Forwarder, connections: 0, lastActivity: this.now() }; const forwarders = this; + // A socket can report error() and then close(); count it out of the listener once. + const releaseConnection = (socket: Socket): boolean => { + if (socket.data.closed) return false; + socket.data.closed = true; + entry.connections = Math.max(0, entry.connections - 1); + entry.lastActivity = forwarders.now(); + return true; + }; const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, @@ -100,15 +108,11 @@ export class LoopbackForwarders { void drain(socket); }, close(socket) { - const data = socket.data; - if (data.closed) return; - data.closed = true; - entry.connections = Math.max(0, entry.connections - 1); - entry.lastActivity = forwarders.now(); - data.stream?.end(); + if (!releaseConnection(socket)) return; + socket.data.stream?.end(); }, error(socket) { - socket.data.closed = true; + releaseConnection(socket); socket.data.stream?.reset("browser side errored"); }, drain() {