diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index 9b6fe77e7..0864c38f0 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -969,11 +969,16 @@ export async function serve({ return server; } -export async function startGatewayHealthServer(host: string, port: number): Promise { +export async function startGatewayHealthServer( + host: string, + port: number, + isReady: () => boolean = () => true, +): Promise { const server = http.createServer((req, res) => { if (req.method === "GET" && req.url?.split("?", 1)[0] === "/health") { - const body = JSON.stringify({ status: "ok" }); - res.writeHead(200, { + const ready = Boolean(isReady()); + const body = JSON.stringify(ready ? { status: "ok" } : { status: "starting" }); + res.writeHead(ready ? 200 : 503, { "content-type": "application/json", "content-length": Buffer.byteLength(body), }); @@ -984,7 +989,18 @@ export async function startGatewayHealthServer(host: string, port: number): Prom res.writeHead(404, { "content-type": "text/plain", "content-length": Buffer.byteLength(body) }); res.end(body); }); - await new Promise((resolve) => server.listen(port, host, resolve)); + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + server.off("listening", onListening); + reject(error); + }; + const onListening = (): void => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.listen(port, host, onListening); + }); return server; } @@ -1236,7 +1252,10 @@ export async function gateway({ } const bindHost = host ?? loaded.gateway.host; const bindPort = port == null ? loaded.gateway.port : Number(port); - const healthServer = await startGatewayHealthServer(bindHost, bindPort); + // Track gateway readiness so the /health endpoint can accurately signal 503 during startup + // (or if startup stalls) and 200 only after every dependent service has started. + let gatewayReady = false; + const healthServer = await startGatewayHealthServer(bindHost, bindPort, () => gatewayReady); const deliverToChannelNow = async ( msg: OutboundMessage, @@ -1493,10 +1512,19 @@ export async function gateway({ }); await cron.start(); + // Fire-and-forget: several channel adapters (Telegram/Discord/Slack) return a promise + // from start() that only settles when the channel shuts down (grammy polling loop, + // Discord gateway lifetime, Slack Socket Mode connection). Awaiting startAll() here + // would deadlock gateway startup. Readiness therefore reflects "channel init dispatched", + // not "every channel has fully connected"; per-channel connection health is surfaced + // separately by the manager. void manager.startAll(); transcriptMonitor?.start(); await heartbeat.start(); const inboundTask = loop.run(); + // All dependent services have completed startup; flip readiness so the /health endpoint + // reports 200. Until this point the endpoint responds with 503 to reflect actual state. + gatewayReady = true; console.log( `memmy gateway started (${manager.enabledChannels.join(", ") || "no channels enabled"})`, ); @@ -1522,6 +1550,8 @@ export async function gateway({ cron, healthServer, stop: async () => { + // Shutdown began; /health should immediately stop advertising readiness. + gatewayReady = false; modelCatalogWatcher.close(); heartbeat.stop(); cron.stop(); diff --git a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts index 7be3b5920..026ae005b 100644 --- a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts @@ -860,6 +860,44 @@ describe("CLI command helpers", () => { } }); + it("gateway health helper reports 503 until readiness is signaled", async () => { + let ready = false; + const server = await startGatewayHealthServer("127.0.0.1", 0, () => ready); + try { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + const notReady = await fetch(`http://127.0.0.1:${port}/health`); + expect(notReady.status).toBe(503); + expect(await notReady.json()).toEqual({ status: "starting" }); + + ready = true; + const nowReady = await fetch(`http://127.0.0.1:${port}/health`); + expect(nowReady.status).toBe(200); + expect(await nowReady.json()).toEqual({ status: "ok" }); + + ready = false; + const shuttingDown = await fetch(`http://127.0.0.1:${port}/health`); + expect(shuttingDown.status).toBe(503); + expect(await shuttingDown.json()).toEqual({ status: "starting" }); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } + }); + + it("gateway health helper rejects when the port is already bound", async () => { + const first = await startGatewayHealthServer("127.0.0.1", 0); + try { + const address = first.address(); + const port = typeof address === "object" && address ? address.port : 0; + await expect(startGatewayHealthServer("127.0.0.1", port)).rejects.toMatchObject({ + code: "EADDRINUSE", + }); + } finally { + await new Promise((resolve, reject) => first.close((error) => (error ? reject(error) : resolve()))); + } + }); + async function startCronGatewayWithFakeLoop({ processDirect, isSessionBusy = vi.fn(() => false),