From 8829c5e619d36c50c7085942623552f29cb2c21b Mon Sep 17 00:00:00 2001 From: lcy Date: Sun, 18 Oct 2026 10:26:31 +0800 Subject: [PATCH] fix(server): answer requests that arrive before the request listener is attached NodeHttpServer listens before HttpRouter.serve adds the "request" listener, and building the route layers in between takes a few hundred ms. Node drops a request emitted with no listener, so a client that connects as soon as the port opens waits forever. Hold those requests and replay them once the real listener is attached. Closes #56641 --- packages/opencode/src/server/server.ts | 25 +++++++- .../test/server/httpapi-listen.test.ts | 58 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 440b992c1557..0e50eb8b9fdd 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" -import { createServer } from "node:http" +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" import { MDNS } from "./mdns" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" @@ -198,6 +198,7 @@ function forceClose(state: ListenerState) { function serverLayer(opts: { port: number; hostname: string }) { const server = createServer() + holdEarlyRequests(server) const serverRef = { closeStarted: false, forceStop: false } const close = server.close.bind(server) // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by @@ -223,4 +224,26 @@ function serverLayer(opts: { port: number; hostname: string }) { ) } +// NodeHttpServer starts listening before HttpRouter.serve attaches the "request" listener, and the +// route layers built in between take a few hundred ms. Node emits a request that arrives in that +// window to no listener and drops it, leaving the client waiting on an open socket forever. Hold +// those requests and replay them once the real listener is attached. +function holdEarlyRequests(server: ReturnType) { + const pending: Array<[IncomingMessage, ServerResponse]> = [] + const hold = (req: IncomingMessage, res: ServerResponse) => { + pending.push([req, res]) + } + const release = (event: string | symbol, listener: unknown) => { + if (event !== "request" || listener === hold) return + server.off("request", hold) + server.off("newListener", release) + // newListener fires before the listener is added, so replay on the next tick. + process.nextTick(() => { + for (const [req, res] of pending.splice(0)) server.emit("request", req, res) + }) + } + server.on("request", hold) + server.on("newListener", release) +} + export * as Server from "./server" diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 585c59cb4317..341191975b94 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -353,6 +353,36 @@ describe("HttpApi Server.listen", () => { } }) + test("answers a request that arrives before the routes finish loading", async () => { + // The port accepts connections before the route layers are built and the request handler is + // attached. A request sent on the first accepted connection must still be answered. + const port = await freePort() + Flag.OPENCODE_SERVER_PASSWORD = auth.password + Flag.OPENCODE_SERVER_USERNAME = auth.username + process.env.OPENCODE_SERVER_PASSWORD = auth.password + process.env.OPENCODE_SERVER_USERNAME = auth.username + const starting = Server.listen({ hostname: "127.0.0.1", port }) + let listener: Awaited | undefined + try { + const socket = await connectWhenAccepting(port) + const status = await withTimeout( + new Promise((resolve, reject) => { + socket.once("data", (chunk) => resolve(String(chunk).split("\r\n")[0])) + socket.once("error", reject) + socket.write( + `GET /global/health HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: ${authorization()}\r\nConnection: close\r\n\r\n`, + ) + }), + 10_000, + "timed out waiting for an answer to a request sent while the server was starting", + ).finally(() => socket.destroy()) + expect(status).toBe("HTTP/1.1 200 OK") + } finally { + listener = await starting + await stop(listener, "timed out cleaning up early-request listener") + } + }, 30_000) + test("port 0 prefers 4096 when free", async () => { if (!(await isPortFree(4096))) return const listener = await startListener() @@ -463,3 +493,31 @@ function occupyPort(port: number) { server.listen(port, "127.0.0.1", () => resolve(server)) }) } + +function freePort() { + return new Promise((resolve, reject) => { + const probe = net.createServer() + probe.once("error", reject) + probe.listen(0, "127.0.0.1", () => { + const address = probe.address() + probe.close(() => (typeof address === "object" && address ? resolve(address.port) : reject(new Error("no port")))) + }) + }) +} + +async function connectWhenAccepting(port: number) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + const socket = await new Promise((resolve) => { + const candidate = net.connect(port, "127.0.0.1") + candidate.once("connect", () => resolve(candidate)) + candidate.once("error", () => { + candidate.destroy() + resolve(undefined) + }) + }) + if (socket) return socket + await new Promise((resolve) => setTimeout(resolve, 5)) + } + throw new Error(`port ${port} never accepted a connection`) +}