From 50af92e2fcda1837e912606ec562bead0dc1deaf Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Wed, 30 Sep 2026 06:21:46 +0200 Subject: [PATCH] fix: drain server requests and update deployment smoke --- apps/server/src/index.test.ts | 55 ++++++++++++++++++++++++++++++++--- apps/server/src/index.ts | 18 +++++++++++- tooling/deployment/smoke.mjs | 2 +- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/server/src/index.test.ts b/apps/server/src/index.test.ts index a05c6670e..01bbc419d 100644 --- a/apps/server/src/index.test.ts +++ b/apps/server/src/index.test.ts @@ -1,6 +1,9 @@ +import { once } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { serve } from "@hono/node-server"; const events = vi.hoisted(() => [] as string[]); +const appFetch = vi.hoisted(() => vi.fn()); vi.mock("./startup/checks", () => ({ runStartupChecks: async () => { await Promise.resolve(); @@ -12,7 +15,7 @@ vi.mock("./http/app", () => { return { createApp: () => { events.push("app created"); - return { fetch: vi.fn() }; + return { fetch: appFetch }; }, }; }); @@ -23,19 +26,63 @@ vi.mock("@reactive-resume/auth/config", () => ({ }, })); vi.mock("@hono/node-server", () => ({ - serve: () => { + serve: vi.fn(() => { events.push("server listening"); - }, + }), })); -vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 3001 } })); +vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 0 } })); afterEach(() => vi.restoreAllMocks()); describe("server startup", () => { it("finishes migrations before importing auth and seeding OAuth resources", async () => { vi.spyOn(process, "on").mockReturnValue(process); + vi.spyOn(process, "once").mockReturnValue(process); const entry = await import("./index"); expect(events).toEqual([]); await entry.main(); expect(events).toEqual(["migrations complete", "auth imported", "auth ready", "app created", "server listening"]); }); + + it.each(["SIGTERM", "SIGINT"])("drains active requests before exiting on %s", async (signal) => { + vi.spyOn(process, "on").mockReturnValue(process); + const signals = vi.spyOn(process, "once").mockReturnValue(process); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const started = Promise.withResolvers(); + const finished = Promise.withResolvers(); + appFetch.mockImplementation(() => { + started.resolve(); + return finished.promise; + }); + const { serve: realServe } = await vi.importActual("@hono/node-server"); + let server: ReturnType | undefined; + vi.mocked(serve).mockImplementationOnce((options, callback) => { + server = realServe(options, callback); + return server; + }); + await (await import("./index")).main(); + if (!server) throw new Error("Server did not start"); + const runningServer = server; + try { + if (!server.listening) await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing HTTP address"); + const url = `http://127.0.0.1:${address.port}`; + const response = fetch(url); + await started.promise; + const shutdown = signals.mock.calls.find(([name]) => name === signal)?.[1]; + expect(shutdown).toBeTypeOf("function"); + const closed = once(server, "close"); + shutdown?.(); + shutdown?.(); + expect(exit).not.toHaveBeenCalled(); + await expect(fetch(url)).rejects.toThrow(); + finished.resolve(new Response("drained")); + expect(await (await response).text()).toBe("drained"); + await closed; + expect(exit).toHaveBeenCalledExactlyOnceWith(0); + } finally { + finished.resolve(new Response("drained")); + await new Promise((resolve) => runningServer.close(() => resolve())); + } + }); }); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f2eb70ef7..17ff97296 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -24,7 +24,7 @@ export async function main() { const app = createApp(); - serve( + const server = serve( { fetch: app.fetch, port, @@ -33,6 +33,22 @@ export async function main() { console.info(`🚀 Up and running on http://localhost:${info.port}`); }, ); + + let shuttingDown = false; + const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + // Stop accepting connections, then wait for active requests before exiting. + server.close((error) => { + if (error) { + console.error("Failed to drain HTTP requests", error); + process.exit(1); + } + process.exit(0); + }); + }; + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/tooling/deployment/smoke.mjs b/tooling/deployment/smoke.mjs index a73775664..c91ecad65 100644 --- a/tooling/deployment/smoke.mjs +++ b/tooling/deployment/smoke.mjs @@ -114,7 +114,7 @@ try { apiKey: process.env.SMOKE_AI_API_KEY, }); await rpc("aiProviders/test", { id: provider.id }); - const thread = await rpc("agent/threads/create", { aiProviderId: provider.id }); + const thread = await rpc("agent/threads/start", { resumeId: id, aiProviderId: provider.id }); const attachment = await rpc( "agent/attachments/create", {