import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { DEFAULT_CONFIG } from "../src/config/defaults.ts"; import type { RouterConfig } from "../src/config/types.ts"; import { startServer, type StartedServer } from "../src/server/http.ts"; describe("HTTP server resilience against dead streams", () => { let handle: StartedServer; let baseUrl: string; beforeAll(() => { const cfg: RouterConfig = { ...DEFAULT_CONFIG, server: { host: "127.0.0.1", port: 0, maxConcurrentTurns: 24, subagentProfile: "auto-sub" }, ledger: { ...DEFAULT_CONFIG.ledger, path: ":memory:" }, context: { ...DEFAULT_CONFIG.context, enabled: false }, logLevel: "silent", }; handle = startServer(cfg); baseUrl = `http://127.0.0.1:${handle.server.port}`; }); afterAll(async () => { await handle.stop(); }); test("server answers /v1/models cleanly initially", async () => { const res = await fetch(`${baseUrl}/v1/models`); expect(res.status).toBe(200); const json = (await res.json()) as { data: unknown[] }; expect(Array.isArray(json.data)).toBe(true); }); test("cancelling a streaming /v1/chat/completions client does not crash the server", async () => { const res = await fetch(`${baseUrl}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "auto", stream: true, messages: [{ role: "user", content: "hello" }], }), }); // Immediately cancel the reader mid-stream (simulates client disconnect) const reader = res.body?.getReader(); expect(reader).toBeDefined(); await reader?.cancel("client abruptly dropped"); // Allow microtasks and I/O ticks to settle without real wall-clock delays await new Promise((resolve) => { setImmediate(() => resolve()); }); // and answers subsequent requests cleanly. const modelsRes = await fetch(`${baseUrl}/v1/models`); expect(modelsRes.status).toBe(200); const json = (await modelsRes.json()) as { data: unknown[] }; expect(Array.isArray(json.data)).toBe(true); }); test("a malformed request body does not consume a concurrency slot", async () => { // Regression: the slot was acquired before the body was parsed and only // released in runTurn's finally, so every rejected body leaked one slot // and 24 of them turned the router into a permanent 429. for (let i = 0; i < 30; i++) { const res = await fetch(`${baseUrl}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, body: "this is not json", }); expect(res.status).toBe(400); } const res = await fetch(`${baseUrl}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "auto", stream: false, messages: [{ role: "user", content: "hello" }] }), }); // Anything but "too many concurrent turns"; with no API key the turn // itself fails at dispatch, which is fine here. expect(res.status).not.toBe(429); await res.text(); }); });