From 0459c6dc0a38e292f8087f380b663281bef1e3ee Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 16 Sep 2026 06:15:15 -0300 Subject: [PATCH] fix(cli): surface fatal [STARTUP] boot diagnostics without --log (#13314) (#13779) Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging). --- bin/cli/runtime/processSupervisor.mjs | 12 +++ bin/cli/utils/ensureAndroidCacheDir.mjs | 21 ++++++ ...visor-surfaces-fatal-startup-diagnostic.md | 1 + ...ces-fatal-startup-diagnostic-13314.test.ts | 74 +++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 changelog.d/fixes/13314-supervisor-surfaces-fatal-startup-diagnostic.md create mode 100644 tests/unit/cli-supervisor-surfaces-fatal-startup-diagnostic-13314.test.ts diff --git a/bin/cli/runtime/processSupervisor.mjs b/bin/cli/runtime/processSupervisor.mjs index 3d7bf39742..3bef5fa08d 100644 --- a/bin/cli/runtime/processSupervisor.mjs +++ b/bin/cli/runtime/processSupervisor.mjs @@ -14,6 +14,7 @@ import { stopProcessGracefully } from "../../../src/shared/platform/windowsProce import { isFatalInstrumentationHookFailure, formatAndroidInstrumentationFailureHint, + isFatalStartupDiagnostic, } from "../utils/ensureAndroidCacheDir.mjs"; const CRASH_LOG_LINES = 50; @@ -55,12 +56,14 @@ export class ServerSupervisor { this.child = null; this.isShuttingDown = false; this.instrumentationFailureHintPrinted = false; + this.fatalStartupDiagnosticPrinted = false; } start() { this.startedAt = Date.now(); this.crashLog = []; this.instrumentationFailureHintPrinted = false; + this.fatalStartupDiagnosticPrinted = false; const showLog = process.env.OMNIROUTE_SHOW_LOG === "1"; // #6321: stdout used to be discarded (`"ignore"`) whenever `--log`/OMNIROUTE_SHOW_LOG @@ -99,6 +102,15 @@ export class ServerSupervisor { ) ); } + // #13314: surface any `[STARTUP] Fatal:`-guarded boot diagnostic + // immediately, even without --log — otherwise it is only buffered and + // reaches the operator on exit/crash, which never happens when the + // HTTP listener still comes up after the fatal failure (every route + // then 500s with zero visible diagnostic anywhere). + if (!this.fatalStartupDiagnosticPrinted && isFatalStartupDiagnostic(text)) { + this.fatalStartupDiagnosticPrinted = true; + process.stderr.write(text.endsWith("\n") ? text : `${text}\n`); + } }; if (this.child.stdout) { diff --git a/bin/cli/utils/ensureAndroidCacheDir.mjs b/bin/cli/utils/ensureAndroidCacheDir.mjs index 0e3f2d20ec..88fe314dff 100644 --- a/bin/cli/utils/ensureAndroidCacheDir.mjs +++ b/bin/cli/utils/ensureAndroidCacheDir.mjs @@ -105,6 +105,27 @@ export function isFatalInstrumentationHookFailure(text) { return /Unsupported platform:\s*android/i.test(text); } +/** + * Detect any fatal boot-time diagnostic guarded by the `[STARTUP] Fatal:` + * prefix (`src/instrumentation-node.ts::ensureDbReadyForBoot()`, + * `src/instrumentation.ts::register()`, and any future guard using the same + * marker). #13314: in the default `omniroute serve` mode (no `--log`), + * `ServerSupervisor` only buffers stdout/stderr and flushes it to the real + * console on exit/crash/readiness-timeout — so if the HTTP listener still + * comes up after a fatal boot diagnostic was already printed (e.g. the + * better-sqlite3 / node:sqlite driver cascade failing hard), the operator + * sees "OmniRoute is running!" with zero visible diagnostic anywhere, and + * every route 500s. This generalizes the #10028 Android/Termux carve-out to + * every `[STARTUP] Fatal:` guard, not just that one platform-specific string. + * + * @param {string} text + * @returns {boolean} + */ +export function isFatalStartupDiagnostic(text) { + if (!text) return false; + return /^\[STARTUP\] Fatal:/m.test(text); +} + /** * Operator-facing hint when that instrumentation failure shows up in child * output — defense in depth if prep was skipped or a future Next.js probe diff --git a/changelog.d/fixes/13314-supervisor-surfaces-fatal-startup-diagnostic.md b/changelog.d/fixes/13314-supervisor-surfaces-fatal-startup-diagnostic.md new file mode 100644 index 0000000000..b2aa05be52 --- /dev/null +++ b/changelog.d/fixes/13314-supervisor-surfaces-fatal-startup-diagnostic.md @@ -0,0 +1 @@ +- **fix(cli):** `omniroute serve` now surfaces a fatal `[STARTUP] Fatal: ...` boot diagnostic (e.g. a DB driver init failure) to the console immediately, even without `--log`, instead of only when the process later crashes or restarts (#13314) — thanks @Orion1943 diff --git a/tests/unit/cli-supervisor-surfaces-fatal-startup-diagnostic-13314.test.ts b/tests/unit/cli-supervisor-surfaces-fatal-startup-diagnostic-13314.test.ts new file mode 100644 index 0000000000..77bdaa2fb0 --- /dev/null +++ b/tests/unit/cli-supervisor-surfaces-fatal-startup-diagnostic-13314.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// #13314: ServerSupervisor's default (non `--log`) mode only buffers a +// fatal `[STARTUP] Fatal: ...` boot diagnostic in-memory and flushes it to +// the real console only when the child process exits. If the HTTP listener +// still comes up after a fatal DB-driver-cascade failure was already +// printed, the operator sees "OmniRoute is running!" with every route +// 500ing and zero diagnostic output anywhere. This regression test asserts +// the fatal line now reaches the real console immediately, not only on +// exit/crash. +test("ServerSupervisor surfaces a fatal [STARTUP] Fatal: boot diagnostic to the real console even when the child never exits (default, non --log mode)", async () => { + const { ServerSupervisor } = await import("../../bin/cli/runtime/processSupervisor.mjs"); + delete process.env.OMNIROUTE_SHOW_LOG; + + const dir = mkdtempSync(join(tmpdir(), "omniroute-issue13314-")); + const childScript = join(dir, "fake-server.mjs"); + writeFileSync( + childScript, + ` + console.error("[STARTUP] Fatal: Database driver initialization failed: better-sqlite3 invalid, node:sqlite fallback also failed"); + console.log("Ready on 0.0.0.0:20128"); + setInterval(() => {}, 1000); + ` + ); + + const seenOnRealConsole: string[] = []; + const origStdoutWrite = process.stdout.write.bind(process.stdout); + const origStderrWrite = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + seenOnRealConsole.push(String(chunk)); + // @ts-expect-error - forwarding varargs to the real writer + return origStdoutWrite(chunk, ...rest); + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + seenOnRealConsole.push(String(chunk)); + // @ts-expect-error - forwarding varargs to the real writer + return origStderrWrite(chunk, ...rest); + }) as typeof process.stderr.write; + + const supervisor = new ServerSupervisor({ + serverPath: childScript, + env: { ...process.env }, + maxRestarts: 2, + memoryLimit: 256, + }); + + try { + supervisor.start(); + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const bufferedLog = supervisor.getRecentLog().join("\n"); + const printedToRealConsole = seenOnRealConsole.join(""); + + assert.match( + bufferedLog, + /\[STARTUP\] Fatal: Database driver initialization failed/, + "expected the fatal boot diagnostic to be captured into the supervisor's buffer" + ); + + assert.match( + printedToRealConsole, + /\[STARTUP\] Fatal: Database driver initialization failed/, + "expected the fatal boot diagnostic to reach the real console even though the child process never exits" + ); + } finally { + process.stdout.write = origStdoutWrite; + process.stderr.write = origStderrWrite; + supervisor.stop(); + } +});