Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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<typeof createServer>) {
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"
58 changes: 58 additions & 0 deletions packages/opencode/test/server/httpapi-listen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof starting> | undefined
try {
const socket = await connectWhenAccepting(port)
const status = await withTimeout(
new Promise<string>((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()
Expand Down Expand Up @@ -463,3 +493,31 @@ function occupyPort(port: number) {
server.listen(port, "127.0.0.1", () => resolve(server))
})
}

function freePort() {
return new Promise<number>((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<net.Socket | undefined>((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`)
}
Loading