fix HTTP server lifecycle retention (#3599)

This commit is contained in:
Mikhail Merkulov
2026-07-29 16:07:50 -07:00
committed by GitHub
parent a12ab5eabc
commit 2118f02fc7
2 changed files with 132 additions and 1 deletions
+68 -1
View File
@@ -12,6 +12,7 @@
import express from 'express';
import type { Request, Response, NextFunction } from 'express';
import type { Server as HttpServer } from 'http';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
@@ -46,6 +47,7 @@ import {
type IngestionEvent,
} from '../core/ingestion/types.ts';
import { resolveOwnerHolder } from '../core/owner-holder.ts';
import { registerCleanup } from '../core/process-cleanup.ts';
/**
* /health endpoint timeout. 3s rather than 5s: Fly.io's default
@@ -55,6 +57,69 @@ import { resolveOwnerHolder } from '../core/owner-holder.ts';
*/
export const HEALTH_TIMEOUT_MS = 3000;
type HttpServerLifecycle = Pick<HttpServer, 'listening' | 'once' | 'off' | 'close'>;
type SignalSource = Pick<NodeJS.Process, 'once' | 'off'>;
type CleanupRegistrar = typeof registerCleanup;
/**
* Keep the HTTP server strongly referenced and make the daemon lifetime
* explicit instead of relying on runtime-specific event-loop behavior for an
* unobserved `app.listen()` return value. The shared abnormal-termination
* cleanup pass closes it before process exit.
*/
export function waitForHttpServerLifecycle(
server: HttpServerLifecycle,
options: {
signals?: SignalSource;
register?: CleanupRegistrar;
} = {},
): Promise<void> {
const signals = options.signals ?? process;
const register = options.register ?? registerCleanup;
return new Promise<void>((resolve, reject) => {
let settled = false;
let closePromise: Promise<void> | null = null;
const closeServer = (): Promise<void> => {
if (closePromise) return closePromise;
closePromise = new Promise<void>((closeResolve, closeReject) => {
if (!server.listening) {
closeResolve();
return;
}
server.close((error?: Error) => {
if (error) closeReject(error);
else closeResolve();
});
});
return closePromise;
};
const deregister = register('http-server', closeServer);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
server.off('close', onClose);
server.off('error', onError);
signals.off('SIGINT', onSigint);
deregister();
if (error) reject(error);
else resolve();
};
const onClose = () => finish();
const onError = (error: Error) => finish(error);
const onSigint = () => {
void closeServer().catch(onError);
};
server.once('close', onClose);
server.once('error', onError);
signals.once('SIGINT', onSigint);
});
}
/**
* v0.36.1.x #1024: bootstrap token resolution.
*
@@ -2410,7 +2475,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// ---------------------------------------------------------------------------
const clientCount = await sql`SELECT count(*)::int as count FROM oauth_clients`;
app.listen(port, bind, () => {
const httpServer = app.listen(port, bind, () => {
console.error(`
╔══════════════════════════════════════════════════════╗
║ GBrain MCP Server v${VERSION.padEnd(37)}
@@ -2435,4 +2500,6 @@ ${bootstrapFromEnv
: `║ Admin Token (paste into /admin login): ║\n║ ${bootstrapToken.substring(0, 50)}\n║ ${bootstrapToken.substring(50).padEnd(50)}\n╚══════════════════════════════════════════════════════╝`}
`);
});
await waitForHttpServerLifecycle(httpServer);
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { EventEmitter } from 'events';
import { waitForHttpServerLifecycle } from '../src/commands/serve-http.ts';
class FakeHttpServer extends EventEmitter {
listening = true;
closeCalls = 0;
close(callback?: (error?: Error) => void): this {
this.closeCalls++;
this.listening = false;
queueMicrotask(() => {
callback?.();
this.emit('close');
});
return this;
}
}
describe('HTTP server lifecycle', () => {
test('waits for shared cleanup to close the server', async () => {
const server = new FakeHttpServer();
const signals = new EventEmitter();
let cleanup: (() => Promise<void>) | undefined;
let deregistered = false;
let resolved = false;
const lifecycle = waitForHttpServerLifecycle(server, {
signals,
register(_name, fn) {
cleanup = fn;
return () => { deregistered = true; };
},
}).then(() => { resolved = true; });
await Promise.resolve();
expect(resolved).toBe(false);
expect(cleanup).toBeDefined();
await cleanup!();
await lifecycle;
expect(server.closeCalls).toBe(1);
expect(deregistered).toBe(true);
expect(signals.listenerCount('SIGINT')).toBe(0);
});
test('SIGINT closes the server through the same idempotent path', async () => {
const server = new FakeHttpServer();
const signals = new EventEmitter();
const lifecycle = waitForHttpServerLifecycle(server, {
signals,
register() {
return () => {};
},
});
signals.emit('SIGINT');
await lifecycle;
expect(server.closeCalls).toBe(1);
});
});