From 5a7e92962f1181f85817a4666c44f9c3c866fda3 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 11 Aug 2026 14:12:56 +0300 Subject: [PATCH] fix(server): keep slow searches alive Configure Bun server idle timeouts so database-backed search requests can complete beyond Bun default 10 seconds without resetting the ALB connection. Agent: agent-ea --- src/server/app.ts | 20 ++++++++++++++++++++ src/server/index.ts | 1 + test/server.test.ts | 31 ++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/server/app.ts b/src/server/app.ts index 5e2a92b..217d655 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -17,6 +17,8 @@ const jsonHeaders = { export const MAX_REQUEST_BODY_SIZE_ENV = "HASNA_SESSIONS_MAX_REQUEST_BODY_SIZE"; export const SELF_HOSTED_DEFAULT_MAX_REQUEST_BODY_SIZE = 512 * 1024 * 1024; +export const SERVER_IDLE_TIMEOUT_ENV = "HASNA_SESSIONS_IDLE_TIMEOUT_SECONDS"; +export const DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS = 60; function json(payload: Record, status = 200): Response { return new Response(JSON.stringify(payload, null, 2), { status, headers: jsonHeaders }); @@ -117,6 +119,21 @@ export function resolveMaxRequestBodySize(env: NodeJS.ProcessEnv = process.env): return isCloudMode(env) ? SELF_HOSTED_DEFAULT_MAX_REQUEST_BODY_SIZE : undefined; } +export function resolveServerIdleTimeoutSeconds( + env: NodeJS.ProcessEnv = process.env, +): number { + const configured = env[SERVER_IDLE_TIMEOUT_ENV]?.trim(); + if (!configured) return DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS; + + const seconds = Number(configured); + if (!Number.isInteger(seconds) || seconds < 0 || seconds > 255) { + throw new Error( + `${SERVER_IDLE_TIMEOUT_ENV} must be an integer from 0 through 255 seconds.`, + ); + } + return seconds; +} + /** Serve mode string for the health/version contract. */ function serveMode(): "cloud" | "local" { return isCloudMode() ? "cloud" : "local"; @@ -373,6 +390,7 @@ export function createSessionsServer(options: { hostname?: string; enableMcp?: boolean; maxRequestBodySize?: number; + idleTimeout?: number; } = {}) { const pkg = getPackageInfo(); const hostname = options.hostname ?? process.env.HOST ?? "127.0.0.1"; @@ -380,10 +398,12 @@ export function createSessionsServer(options: { ? options.port : Number.parseInt(process.env.PORT || "3456", 10); const maxRequestBodySize = options.maxRequestBodySize ?? resolveMaxRequestBodySize(); + const idleTimeout = options.idleTimeout ?? resolveServerIdleTimeoutSeconds(); return Bun.serve({ hostname, port: Number.isFinite(port) ? port : 3456, + idleTimeout, ...(maxRequestBodySize === undefined ? {} : { maxRequestBodySize }), async fetch(request) { if (options.enableMcp) { diff --git a/src/server/index.ts b/src/server/index.ts index 1902b8f..f7296db 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -26,6 +26,7 @@ Environment: HASNA_SESSIONS_DATABASE_URL cloud Postgres DSN (cloud mode) HASNA_SESSIONS_API_SIGNING_KEY HMAC signing key for /v1 API-key auth HASNA_SESSIONS_MAX_REQUEST_BODY_SIZE max request body bytes/units (cloud default: 512MiB) + HASNA_SESSIONS_IDLE_TIMEOUT_SECONDS in-flight HTTP idle timeout (default: 60, max: 255) Endpoints: GET /health liveness -> { status, version, mode } diff --git a/test/server.test.ts b/test/server.test.ts index a360098..ba6876d 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,11 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createSessionsServer, + DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS, MAX_REQUEST_BODY_SIZE_ENV, + resolveServerIdleTimeoutSeconds, resolveMaxRequestBodySize, + SERVER_IDLE_TIMEOUT_ENV, SELF_HOSTED_DEFAULT_MAX_REQUEST_BODY_SIZE, } from "../src/server/app"; import { getPackageInfo } from "../src/lib/package"; @@ -13,6 +16,32 @@ import { getDatabase, resetDatabase, closeDatabase } from "../src/db/database"; import { saveParsedSession } from "../src/db/sessions"; describe("createSessionsServer", () => { + it("keeps slow in-flight requests alive past Bun's 10-second default", () => { + let serveOptions: Record | undefined; + const serveSpy = spyOn(Bun, "serve").mockImplementation((options: Record) => { + serveOptions = options; + return { port: 0, stop() {} } as never; + }); + + try { + createSessionsServer({ hostname: "127.0.0.1", port: 0 }); + expect(serveOptions?.idleTimeout).toBe(DEFAULT_SERVER_IDLE_TIMEOUT_SECONDS); + } finally { + serveSpy.mockRestore(); + } + }); + + it("accepts explicit idle-timeout overrides and rejects values Bun cannot serve", () => { + expect(resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "0" })).toBe(0); + expect(resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "255" })).toBe(255); + expect(() => + resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "256" }), + ).toThrow(SERVER_IDLE_TIMEOUT_ENV); + expect(() => + resolveServerIdleTimeoutSeconds({ [SERVER_IDLE_TIMEOUT_ENV]: "10.5" }), + ).toThrow(SERVER_IDLE_TIMEOUT_ENV); + }); + it("preserves Bun's default body limit in local mode unless configured", () => { expect(resolveMaxRequestBodySize({ HASNA_SESSIONS_STORAGE_MODE: "local" })).toBeUndefined(); });