diff --git a/src/services/request-timeouts.ts b/src/services/request-timeouts.ts new file mode 100644 index 0000000..35e0a2c --- /dev/null +++ b/src/services/request-timeouts.ts @@ -0,0 +1,8 @@ +export const EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS = 60_000; +export const OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS = 300_000; + +const HTTP_TIMEOUT_GRACE_MS = 30_000; + +export const NODE_HTTP_IDLE_TIMEOUT_MS = + Math.max(EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS, OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS) + + HTTP_TIMEOUT_GRACE_MS; diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index 5aaa5a0..527b229 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -2,6 +2,10 @@ import type { UserProfileData } from "./types.js"; import { CONFIG } from "../../config.js"; import { log } from "../logger.js"; import { loadOpencodeProvider } from "../ai/opencode-provider-loader.js"; +import { + EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS, + OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS, +} from "../request-timeouts.js"; export interface AICleanupResult { cleaned: UserProfileData; @@ -253,7 +257,7 @@ async function callViaExternalAPI( temperature: 0.3, response_format: { type: "json_object" }, }), - signal: AbortSignal.timeout(60000), + signal: AbortSignal.timeout(EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS), }); log("AI cleanup: external API http done", { httpMs: Date.now() - t0, status: response.status }); @@ -278,9 +282,6 @@ async function callViaExternalAPI( }; } -/** Prompt timeout for profile cleanup sessions (large profiles can exceed 2 minutes). */ -const OPENCODE_CLEANUP_TIMEOUT_MS = 300000; - type PromptPart = { type?: string; text?: string }; type PromptInfo = { error?: { name: string; data?: { message?: string } }; @@ -349,7 +350,7 @@ async function callViaOpencodeWithClient( log("AI cleanup: session created", { sessionID, createMs: Date.now() - t0 }); try { - const TIMEOUT_MS = OPENCODE_CLEANUP_TIMEOUT_MS; + const TIMEOUT_MS = OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS; const promptResult = await raceWithTimeout( v2Client.session.prompt({ sessionID, diff --git a/src/services/web-server.ts b/src/services/web-server.ts index 147e6dd..2caf229 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -8,6 +8,7 @@ import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } import { assertWebServerNetworkAuth, authorizeApiRequest } from "./web-api-auth.js"; import { getOrCreateAuthToken, isAuthorizedApiRequest } from "./auth-token.js"; import { WebAuth } from "./web-auth.js"; +import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js"; import { handleListTags, handleListMemories, @@ -57,6 +58,35 @@ const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; const MIN_FAILED_TAKEOVERS = 3; +type NodeEventSource = { + on(event: string, listener: (...args: any[]) => void): unknown; +}; + +export function attachNodeDisconnectHandlers( + req: NodeEventSource & { + aborted: boolean; + complete: boolean; + socket: NodeEventSource; + }, + res: NodeEventSource & { writableEnded: boolean }, + onDisconnect: () => void +): void { + let disconnected = false; + const disconnectOnce = () => { + if (disconnected || res.writableEnded) return; + disconnected = true; + onDisconnect(); + }; + + req.on("aborted", disconnectOnce); + req.on("close", () => { + if (req.aborted || !req.complete) disconnectOnce(); + }); + req.socket.on("error", disconnectOnce); + req.socket.on("close", disconnectOnce); + res.on("close", disconnectOnce); +} + function serveFetch(opts: { port: number; hostname: string; @@ -79,15 +109,15 @@ function serveFetch(opts: { // helpers that ship with Node 18+. const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { let destroyed = false; + const abortController = new AbortController(); const cleanup = () => { if (destroyed) return; destroyed = true; + abortController.abort(); if (!res.writableEnded) res.destroy(); if (!req.socket.destroyed) req.socket.destroy(); }; - req.on("close", cleanup); - req.socket.on("error", cleanup); - req.socket.on("close", cleanup); + attachNodeDisconnectHandlers(req, res, cleanup); try { const url = `http://${opts.hostname}:${opts.port}${req.url ?? "/"}`; @@ -97,6 +127,7 @@ function serveFetch(opts: { method, headers: req.headers as Record, body: hasBody ? (Readable.toWeb(req) as unknown as ReadableStream) : undefined, + signal: abortController.signal, ...(hasBody ? ({ duplex: "half" } as Record) : {}), }); @@ -140,7 +171,8 @@ function serveFetch(opts: { // rebind after a crashed predecessor left orphaned sockets behind. server.listen({ port: opts.port, host: opts.hostname, reuseAddr: true, exclusive: false }); server.unref(); - server.timeout = 30000; + // Keep the outer transport alive longer than the longest profile-cleanup deadline. + server.timeout = NODE_HTTP_IDLE_TIMEOUT_MS; server.keepAliveTimeout = 10000; server.headersTimeout = 11000; diff --git a/tests/web-server-request-lifecycle.test.ts b/tests/web-server-request-lifecycle.test.ts new file mode 100644 index 0000000..68689ce --- /dev/null +++ b/tests/web-server-request-lifecycle.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "bun:test"; +import { EventEmitter } from "node:events"; +import { attachNodeDisconnectHandlers } from "../src/services/web-server.js"; +import { + EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS, + NODE_HTTP_IDLE_TIMEOUT_MS, + OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS, +} from "../src/services/request-timeouts.js"; + +class RequestStub extends EventEmitter { + aborted = false; + complete = false; + socket = new EventEmitter(); +} + +class ResponseStub extends EventEmitter { + writableEnded = false; +} + +describe("Node web request lifecycle", () => { + it("does not disconnect when a fully consumed request body emits close", () => { + const req = new RequestStub(); + const res = new ResponseStub(); + let disconnects = 0; + req.complete = true; + + attachNodeDisconnectHandlers(req, res, () => { + disconnects += 1; + }); + req.emit("close"); + + expect(disconnects).toBe(0); + }); + + it("disconnects once when the client aborts before the response ends", () => { + const req = new RequestStub(); + const res = new ResponseStub(); + let disconnects = 0; + + attachNodeDisconnectHandlers(req, res, () => { + disconnects += 1; + }); + req.aborted = true; + req.emit("aborted"); + req.emit("close"); + res.emit("close"); + + expect(disconnects).toBe(1); + }); + + it("ignores response close after a completed response", () => { + const req = new RequestStub(); + const res = new ResponseStub(); + let disconnects = 0; + res.writableEnded = true; + + attachNodeDisconnectHandlers(req, res, () => { + disconnects += 1; + }); + res.emit("close"); + req.socket.emit("close"); + + expect(disconnects).toBe(0); + }); + + it("keeps the HTTP timeout above every cleanup deadline", () => { + expect(NODE_HTTP_IDLE_TIMEOUT_MS).toBeGreaterThan(EXTERNAL_PROFILE_CLEANUP_TIMEOUT_MS); + expect(NODE_HTTP_IDLE_TIMEOUT_MS).toBeGreaterThan(OPENCODE_PROFILE_CLEANUP_TIMEOUT_MS); + }); +});