Skip to content
Merged
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
8 changes: 8 additions & 0 deletions src/services/request-timeouts.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 6 additions & 5 deletions src/services/user-profile/ai-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand All @@ -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 } };
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 36 additions & 4 deletions src/services/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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 ?? "/"}`;
Expand All @@ -97,6 +127,7 @@ function serveFetch(opts: {
method,
headers: req.headers as Record<string, string>,
body: hasBody ? (Readable.toWeb(req) as unknown as ReadableStream) : undefined,
signal: abortController.signal,
...(hasBody ? ({ duplex: "half" } as Record<string, unknown>) : {}),
});

Expand Down Expand Up @@ -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;

Expand Down
70 changes: 70 additions & 0 deletions tests/web-server-request-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});