mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
5e5fe288d8
Every `docker stop` of a TREK instance with a browser attached exited 137, exactly ten seconds after the stop. `http.Server.close()` stops new connections and then waits for the open ones to end by themselves — and a WebSocket never does. TREK holds one per open tab, so the close callback never ran. `closeDb()` and `process.exit(0)` lived inside that callback, which left the only remaining exit a fallback timer set to 10000ms: exactly when Docker sends SIGKILL. Docker starts counting when it sends the signal and Node only once the handler is dispatched, so that timer started strictly later and could never win. It was unreachable code in every default deployment. Waiting for Nest was not an option either: `WsAdapter.close()` is the one place that terminates clients, and Nest reaches it only after every onModuleDestroy hook has resolved — including the plugin supervisor's SIGTERM grace for its forked children. The sockets holding the server open would be hostage to the slowest hook in the container. So the sockets are now released deliberately: ws clients get 1001 "going away", idle keep-alive sockets are dropped at once, and anything still attached after a 2s drain is destroyed. Nest is closed alongside that, the database is closed on both exit paths rather than only the unreachable one, and the fallback fires at 5s — inside Docker's grace, so a forced exit is still ours. The sequence moved into src/shutdown.ts behind injected dependencies. It was untestable by construction before, which is part of why this went unnoticed; it now has nine tests, including the signal that beats the bootstrap. Closes #2193 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5VwmiBQ9sPCJwfmpv64r3
158 lines
7.2 KiB
TypeScript
158 lines
7.2 KiB
TypeScript
import 'reflect-metadata';
|
|
import 'dotenv/config';
|
|
// Fail-fast env validation — must stay directly after dotenv so a malformed
|
|
// variable aborts before any other module runs its import-time side effects
|
|
// (config.ts key resolution, db/database.ts initDb, ...).
|
|
import './app-config/boot-validate';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import type { INestApplication } from '@nestjs/common';
|
|
import { buildApp, getHttpServer } from './bootstrap';
|
|
|
|
// data/tmp is the driver-agnostic global scratch dir (restore-upload spool,
|
|
// mirror stream staging) and stays boot-created here. Driver-owned roots — the
|
|
// uploads/ category tree and data/backups — are ensured by LocalDriver.init on
|
|
// every storage-registry load (boot and reload(), storage-registry.service.ts),
|
|
// which keeps the #1762 EACCES guarantee: a non-writable bind mount still
|
|
// fails loudly at startup, inside app.init(), instead of as a stray 500 on
|
|
// first upload. The Dockerfile `mkdir -p` list is pinned to the registry's
|
|
// category prefixes by tests/unit/uploads-dirs.test.ts.
|
|
const tmpDir = path.join(__dirname, '../data/tmp');
|
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
|
|
import { getAppUrl, getMcpSafeUrl, readEnv } from './app-config';
|
|
|
|
const PORT = readEnv().app.port;
|
|
const HOST = readEnv().app.host;
|
|
const APP_VERSION: string = readEnv().app.appVersion || (require('../package.json') as { version: string }).version;
|
|
|
|
const onListen = () => {
|
|
const { logInfo: sLogInfo, logWarn: sLogWarn } = require('./nest/audit/audit-log.logger');
|
|
const env = readEnv();
|
|
const LOG_LVL = (env.app.logLevel || 'info').toLowerCase();
|
|
const tz = env.app.tz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
const origins = env.http.allowedOriginsRaw || '(same-origin)';
|
|
const appUrl = getAppUrl();
|
|
const resolvedAppUrl = getMcpSafeUrl();
|
|
const banner = [
|
|
'──────────────────────────────────────',
|
|
' TREK API started',
|
|
` Version ${APP_VERSION}`,
|
|
...(HOST ? [` Host: ${HOST}`] : []),
|
|
` Container Port: ${PORT}`,
|
|
` App URL: ${appUrl}`,
|
|
` Environment: ${env.app.nodeEnv?.toLowerCase() || 'development'}`,
|
|
` Timezone: ${tz}`,
|
|
` Origins: ${origins}`,
|
|
` Log level: ${LOG_LVL}`,
|
|
` Log file: /app/data/logs/trek.log`,
|
|
` PID: ${process.pid}`,
|
|
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
|
'──────────────────────────────────────',
|
|
];
|
|
banner.forEach(l => console.log(l));
|
|
sLogInfo('NestJS serving all routes (Express decommissioned)');
|
|
if (env.app.appUrl) {
|
|
let parsedAppUrl: URL | null = null;
|
|
try { parsedAppUrl = new URL(env.app.appUrl); } catch { /* invalid */ }
|
|
|
|
if (!parsedAppUrl) {
|
|
sLogWarn(`APP_URL: "${env.app.appUrl}" is not a valid URL — it will be ignored.`);
|
|
}
|
|
|
|
const mcpSafe = parsedAppUrl !== null && (
|
|
parsedAppUrl.protocol === 'https:' ||
|
|
parsedAppUrl.hostname === 'localhost' ||
|
|
parsedAppUrl.hostname === '127.0.0.1'
|
|
);
|
|
if (!mcpSafe) {
|
|
sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`);
|
|
}
|
|
}
|
|
if (env.demo.enabled) sLogInfo('Demo mode: ENABLED');
|
|
if (env.demo.enabled && env.app.isProduction) {
|
|
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
|
}
|
|
// Nothing else to boot by hand: the crons are domain providers on the
|
|
// scheduling registrar, and /ws is the Nest gateway buildApp already bound.
|
|
};
|
|
|
|
let server: http.Server;
|
|
let nestApp: INestApplication;
|
|
|
|
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
|
|
async function bootstrap(): Promise<void> {
|
|
// The whole surface runs on the single NestJS app now (Express decommissioned):
|
|
// global pipeline + /uploads + every /api domain + the platform/transport routes
|
|
// (/mcp, /.well-known, OAuth SDK, SPA catch-all). buildApp() owns the composition
|
|
// order; it is shared with the integration-test harness so they can't drift.
|
|
nestApp = await buildApp();
|
|
// The server buildApp created and bound /ws to. Creating a second one here
|
|
// would serve the REST API fine and leave the gateway attached to a socket
|
|
// nobody listens on.
|
|
server = getHttpServer();
|
|
|
|
// A bind failure has to be fatal and loud, and it needs saying explicitly.
|
|
// listen() reports failure by event, not by rejecting, so the catch around
|
|
// bootstrap() never sees it. And since buildApp attaches the ws server to this
|
|
// http server, ws has already registered its own `error` listener on it, which
|
|
// is enough for Node to stop throwing on an unhandled one. Without this the
|
|
// process would survive EADDRINUSE, never run onListen, and serve nothing
|
|
// while looking healthy.
|
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
const where = HOST ? `${HOST}:${PORT}` : `:${PORT}`;
|
|
console.error(`Fatal: cannot listen on ${where} — ${err.code ?? ''} ${err.message}`);
|
|
process.exit(1);
|
|
});
|
|
|
|
if (HOST) server.listen(PORT, HOST, onListen);
|
|
else server.listen(PORT, onListen);
|
|
}
|
|
|
|
bootstrap().catch((err) => {
|
|
console.error('Fatal: failed to bootstrap server', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
//
|
|
// The sequence itself lives in ./shutdown so it can be tested without a process
|
|
// to exit; this is only the wiring. See that file for why the order matters —
|
|
// in short, #2193: nothing here could ever release a WebSocket, so `docker
|
|
// stop` always ended in SIGKILL and exit 137.
|
|
let shuttingDown = false;
|
|
function shutdown(signal: string): void {
|
|
// A second signal — the SIGINT that follows a Ctrl-C, or an impatient
|
|
// orchestrator sending SIGTERM twice — must not start a second teardown on
|
|
// top of the first one.
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
const { logInfo: sLogInfo, logError: sLogError } = require('./nest/audit/audit-log.logger');
|
|
const { closeMcpSessions } = require('./mcp');
|
|
const { getServer } = require('./nest/realtime/ws-state');
|
|
const { runShutdown } = require('./shutdown');
|
|
runShutdown(signal, {
|
|
// Both are still undefined if the signal beat bootstrap(); the sequence
|
|
// handles that and still exits rather than hanging or throwing.
|
|
server,
|
|
// nestApp.close() stops every cron via the scheduling registrar's shutdown
|
|
// hook, and tears the plugin supervisor's forked children down.
|
|
closeNestApp: async () => { await nestApp?.close(); },
|
|
getWsClients: () => getServer()?.clients ?? null,
|
|
closeMcpSessions,
|
|
closeDb: () => { require('./db/database').closeDb(); },
|
|
logInfo: sLogInfo,
|
|
logError: sLogError,
|
|
exit: (code: number) => process.exit(code),
|
|
}).catch((err: unknown) => {
|
|
// Fire-and-forget would make this an unhandled rejection, which on Node 22
|
|
// is a crash — a worse ending than the one we are here to fix.
|
|
sLogError(`Shutdown failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|