diff --git a/apps/desktop/electron/backend-claim.test.ts b/apps/desktop/electron/backend-claim.test.ts new file mode 100644 index 0000000000..851f88d1c5 --- /dev/null +++ b/apps/desktop/electron/backend-claim.test.ts @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' + +import { test } from 'vitest' + +import { + claimDecision, + createBackendOutputTail, + DEFAULT_OUTPUT_TAIL_LIMIT, + isPidOnlyStartMarker, + pidOnlyStartMarker, + probeStartMarker, + processStartMarker +} from './backend-claim' + +// --- claimDecision: the #93608 policy --------------------------------------- + +test('probe success claims with the full start marker (unchanged behavior)', () => { + const decision = claimDecision(true, { ok: true, startMarker: 'linux:12345' }) + + assert.deepEqual(decision, { action: 'claim', startMarker: 'linux:12345' }) +}) + +test('probe success claims even when the child already exited (ownership records the incarnation)', () => { + // The claim itself must not invent a failure: the exit handler owns cleanup. + const decision = claimDecision(false, { ok: true, startMarker: 'win:99' }) + + assert.deepEqual(decision, { action: 'claim', startMarker: 'win:99' }) +}) + +test('probe failure on a LIVE child degrades to PID-only identity — never kills a healthy backend (#93608)', () => { + const decision = claimDecision(true, { ok: false, reason: 'powershell.exe timed out after 30000ms' }) + + assert.equal(decision.action, 'degrade') + assert.match((decision as { reason: string }).reason, /timed out/) +}) + +test('probe failure on a DEAD child fails closed so the caller can attach the stderr tail', () => { + const decision = claimDecision(false, { ok: false, reason: 'Get-Process: no process with ID 4242' }) + + assert.equal(decision.action, 'fail') + assert.match((decision as { reason: string }).reason, /4242/) +}) + +// --- probeStartMarker: throw → value ---------------------------------------- + +test('probeStartMarker converts a probe throw into { ok: false, reason }', async () => { + const probe = await probeStartMarker(4242, async () => { + throw new Error('PowerShell 5.1 cold start exceeded budget') + }) + + assert.deepEqual(probe, { ok: false, reason: 'PowerShell 5.1 cold start exceeded budget' }) +}) + +test('probeStartMarker passes a successful marker through', async () => { + const probe = await probeStartMarker(4242, async pid => `linux:${pid}`) + + assert.deepEqual(probe, { ok: true, startMarker: 'linux:4242' }) +}) + +// --- real probe: drives the actual OS helper (PowerShell on the Windows lane) --- + +test('processStartMarker resolves a real marker for the current process', async () => { + const marker = await processStartMarker(process.pid) + + assert.match(marker, /^(linux|win|winms|ps):.+/) +}) + +test('processStartMarker rejects for a PID that does not exist', async () => { + // Largest PIDs are bounded well below this on every supported platform. + await assert.rejects(processStartMarker(2 ** 30 + 12345)) +}) + +// --- PID-only marker helpers -------------------------------------------------- + +test('pidOnlyStartMarker round-trips through isPidOnlyStartMarker', () => { + const marker = pidOnlyStartMarker(4242) + + assert.equal(marker, 'pid-only:4242') + assert.equal(isPidOnlyStartMarker(marker), true) + assert.equal(isPidOnlyStartMarker('linux:12345'), false) + assert.equal(isPidOnlyStartMarker(undefined), false) +}) + +// --- output tail ring buffer ---------------------------------------------------- + +test('output tail keeps only the most recent bytes once past the limit', () => { + const tail = createBackendOutputTail(16) + + tail.append('0123456789') + tail.append('abcdefghij') + + assert.equal(tail.text(), '456789abcdefghij') + assert.equal(tail.text().length, 16) +}) + +test('output tail default limit is ~8KB', () => { + const tail = createBackendOutputTail() + + tail.append('x'.repeat(DEFAULT_OUTPUT_TAIL_LIMIT + 500)) + + assert.equal(tail.text().length, DEFAULT_OUTPUT_TAIL_LIMIT) + assert.equal(DEFAULT_OUTPUT_TAIL_LIMIT, 8192) +}) + +test('output tail interleaves stdout and stderr attached from spawn time', () => { + const child = { stderr: new EventEmitter(), stdout: new EventEmitter() } + const tail = createBackendOutputTail(64) + + tail.attach(child) + child.stdout.emit('data', Buffer.from('booting\n')) + child.stderr.emit('data', Buffer.from("ModuleNotFoundError: No module named 'hermes_cli'\n")) + + assert.match(tail.text(), /booting/) + assert.match(tail.text(), /ModuleNotFoundError/) +}) + +test('describe() is empty when nothing was captured, formatted when output exists', () => { + const tail = createBackendOutputTail(64) + + assert.equal(tail.describe(), '') + + tail.append('Traceback (most recent call last):\n') + assert.match(tail.describe(), /^\nRecent backend output:\nTraceback/) +}) + +test('attach tolerates a child with missing stdio streams', () => { + const tail = createBackendOutputTail(64) + + tail.attach({ stderr: null, stdout: null }) + assert.equal(tail.text(), '') +}) diff --git a/apps/desktop/electron/backend-claim.ts b/apps/desktop/electron/backend-claim.ts new file mode 100644 index 0000000000..761b1bcf60 --- /dev/null +++ b/apps/desktop/electron/backend-claim.ts @@ -0,0 +1,196 @@ +/** + * backend-claim.ts + * + * The start-marker probe and the claim decision for a freshly spawned local + * backend child, extracted from main.ts so the policy is testable without + * booting Electron — including on a Windows CI lane that drives the probe + * with REAL PowerShell (`processStartMarker` shells out to powershell.exe). + * + * Why this exists (#93608): `claimBackendChild` used to hard-fail on ANY + * probe error — `Get-Process` timing out on a PowerShell 5.1 cold start + * (see #87169) killed a perfectly healthy backend, the renderer "repaired" + * by respawning, and the next probe timeout killed that one too. The rule is + * now the same one `createParentStartMarkerResolver` already applies to the + * parent marker: a failed probe against a LIVE child degrades to PID-only + * identity instead of killing the child; only a child that actually DIED + * keeps the fail-closed throw (now carrying its stderr tail, so the real + * exit reason reaches desktop.log and the boot UI). + */ + +import { execFile } from 'node:child_process' +import fs from 'node:fs' + +import { electronProcessStartMarker } from './parent-process-identity' +import { hiddenWindowsChildOptions } from './windows-child-options' + +export function execText(command: string, args: string[], { timeout = 3000 } = {}): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => { + if (error) { + reject(error) + } else { + resolve(String(stdout || '').trim()) + } + }) + }) +} + +/** + * Cross-platform process start marker: a value that changes when a PID is + * reused, so `pid + marker` identifies one specific process incarnation. + * Throws when the probe fails — callers decide what a failure means (see + * `claimDecision` / `probeStartMarker`). + */ +export async function processStartMarker(pid: number): Promise { + if (process.platform === 'linux') { + const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8') + + const fields = stat + .slice(stat.lastIndexOf(')') + 1) + .trim() + .split(/\s+/) + + if (!/^\d+$/.test(fields[19] || '')) { + throw new Error(`Invalid /proc start marker for PID ${pid}`) + } + + return `linux:${fields[19]}` + } + + if (process.platform === 'win32') { + const electronMarker = + pid === process.pid ? electronProcessStartMarker(pid, process.pid, process.getCreationTime?.()) : null + + if (electronMarker) { + return electronMarker + } + + const ticks = await execText( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks` + ], + // PowerShell 5.1 cold starts routinely exceed the default 3s execText + // budget (2.4-8s observed in #87169); give the marker probe headroom. + { timeout: 30_000 } + ) + + if (!/^\d+$/.test(ticks)) { + throw new Error(`Invalid Windows start marker for PID ${pid}`) + } + + return `win:${ticks}` + } + + const started = await execText('ps', ['-p', String(pid), '-o', 'lstart=']) + + if (!started) { + throw new Error(`Missing process start marker for PID ${pid}`) + } + + return `ps:${started}` +} + +export type StartMarkerProbe = { ok: true; startMarker: string } | { ok: false; reason: string } + +/** Run the marker probe, converting a throw into a value the pure decision can consume. */ +export async function probeStartMarker( + pid: number, + probe: (pid: number) => Promise = processStartMarker +): Promise { + try { + return { ok: true, startMarker: await probe(pid) } + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) } + } +} + +const PID_ONLY_MARKER_PREFIX = 'pid-only:' + +/** + * Degraded identity marker recorded when the start-marker probe failed but + * the child was verifiably alive. It satisfies the ownership schema (a + * non-empty startMarker) while telling identity matchers that only PID + * liveness — plus the command-line check layered on top — can be verified. + */ +export function pidOnlyStartMarker(pid: number): string { + return `${PID_ONLY_MARKER_PREFIX}${pid}` +} + +export function isPidOnlyStartMarker(startMarker: unknown): boolean { + return typeof startMarker === 'string' && startMarker.startsWith(PID_ONLY_MARKER_PREFIX) +} + +export type ClaimDecision = + { action: 'claim'; startMarker: string } | { action: 'degrade'; reason: string } | { action: 'fail'; reason: string } + +/** + * Pure claim policy for a freshly spawned backend child: + * + * - probe succeeded → claim with the full start marker (unchanged). + * - probe failed, child ALIVE → degrade to PID-only identity; NEVER kill a + * healthy backend over a flaky identity probe. + * - probe failed, child DEAD → fail closed; the child's death is the real + * story and the caller attaches its stderr tail. + */ +export function claimDecision(childAlive: boolean, probe: StartMarkerProbe): ClaimDecision { + if (probe.ok === true) { + return { action: 'claim', startMarker: probe.startMarker } + } + + const { reason } = probe + + return childAlive ? { action: 'degrade', reason } : { action: 'fail', reason } +} + +export interface BackendOutputTail { + /** Attach stdout/stderr data listeners to a just-spawned child. */ + attach(child: { + stdout?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null + stderr?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null + }): void + append(chunk: unknown): void + /** The buffered tail (most recent `limit` characters), or ''. */ + text(): string + /** Human-readable suffix for error messages, or '' when nothing buffered. */ + describe(): string +} + +export const DEFAULT_OUTPUT_TAIL_LIMIT = 8192 + +/** + * Ring-buffered tail of a child's combined stdout+stderr, attached at SPAWN + * time — before the claim, before the READY wait — so an early crash's real + * stderr (traceback, missing module, bad config) survives into the ownership + * error and the before-ready exit messages instead of a bare exit code. + */ +export function createBackendOutputTail(limit: number = DEFAULT_OUTPUT_TAIL_LIMIT): BackendOutputTail { + let buffer = '' + + const append = (chunk: unknown) => { + buffer += String(chunk) + + if (buffer.length > limit) { + buffer = buffer.slice(buffer.length - limit) + } + } + + return { + append, + attach(child) { + child.stdout?.on('data', append) + child.stderr?.on('data', append) + }, + text() { + return buffer + }, + describe() { + const text = buffer.trim() + + return text ? `\nRecent backend output:\n${text}` : '' + } + } +} diff --git a/apps/desktop/electron/backend-ready.test.ts b/apps/desktop/electron/backend-ready.test.ts index eb09068624..b51987a773 100644 --- a/apps/desktop/electron/backend-ready.test.ts +++ b/apps/desktop/electron/backend-ready.test.ts @@ -216,3 +216,47 @@ test('waitForDashboardReadyFile rejects when the child exits before file readine tmp.cleanup() } }) + +// --------------------------------------------------------------------------- +// describeOutputTail (#93608): the child's real stderr reaches the exit error +// --------------------------------------------------------------------------- + +test('exit-before-announcement error carries the buffered output tail (stdout path)', async () => { + const child = makeFakeChild() + + const wait = waitForDashboardPortAnnouncement(child, { + describeOutputTail: () => '\nRecent backend output:\nModuleNotFoundError: hermes_cli' + }) + + child.emit('exit', 1, null) + + await assert.rejects(wait, /exited before port announcement \(1\)[\s\S]*ModuleNotFoundError: hermes_cli/) +}) + +test('exit-before-announcement error carries the buffered output tail (ready-file path)', async () => { + const child = makeFakeChild() + const readyFile = path.join(os.tmpdir(), `hermes-ready-${process.pid}-${Date.now()}.json`) + + const wait = waitForDashboardPortAnnouncement(child, { + describeOutputTail: () => '\nRecent backend output:\nTraceback (most recent call last)', + readyFile + }) + + child.emit('exit', null, 'SIGSEGV') + + await assert.rejects(wait, /exited before port announcement \(SIGSEGV\)[\s\S]*Traceback/) +}) + +test('exit-before-announcement error stays clean when no output was buffered', async () => { + const child = makeFakeChild() + + const wait = waitForDashboardPortAnnouncement(child, {}) + + child.emit('exit', 137, null) + + await assert.rejects(wait, error => { + assert.match((error as Error).message, /exited before port announcement \(137\)$/) + + return true + }) +}) diff --git a/apps/desktop/electron/backend-ready.ts b/apps/desktop/electron/backend-ready.ts index 05b9b67351..d7644fc06a 100644 --- a/apps/desktop/electron/backend-ready.ts +++ b/apps/desktop/electron/backend-ready.ts @@ -51,7 +51,7 @@ function resolvePortAnnounceTimeoutMs(env = process.env) { * on every terminal path — resolve, reject, or timeout — so repeated * backend spawns don't leak listener slots on the child. */ -function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) { +function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs(), describeOutputTail = () => '') { return new Promise((resolve, reject) => { let buf = '' let done = false @@ -88,7 +88,7 @@ function waitForDashboardPort(child, timeoutMs = resolvePortAnnounceTimeoutMs()) function onExit(code, signal) { cleanup() - reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`)) + reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`)) } function onError(err) { @@ -122,7 +122,12 @@ function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) { } } -function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnnounceTimeoutMs()) { +function waitForDashboardReadyFile( + readyFile, + child, + timeoutMs = resolvePortAnnounceTimeoutMs(), + describeOutputTail = () => '' +) { return new Promise((resolve, reject) => { let done = false let interval = null @@ -154,7 +159,7 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno function onExit(code, signal) { cleanup() - reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`)) + reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`)) } function onError(err) { @@ -182,17 +187,20 @@ function waitForDashboardReadyFile(readyFile, child, timeoutMs = resolvePortAnno function waitForDashboardPortAnnouncement( child, options: { - readyFile?: fs.PathOrFileDescriptor + /** Returns a formatted stdout/stderr tail suffix for exit errors (#93608). */ + describeOutputTail?: () => string + readyFile?: fs.PathOrFileDescriptor | null timeoutMs?: number } = {} ) { const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs() + const describeOutputTail = options.describeOutputTail ?? (() => '') if (options.readyFile) { - return waitForDashboardReadyFile(options.readyFile, child, timeoutMs) + return waitForDashboardReadyFile(options.readyFile, child, timeoutMs, describeOutputTail) } - return waitForDashboardPort(child, timeoutMs) + return waitForDashboardPort(child, timeoutMs, describeOutputTail) } export { diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dac67ffc3c..675291a8a3 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,4 +1,4 @@ -import { execFile, execFileSync, spawn } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import crypto from 'node:crypto' import fs from 'node:fs' import http from 'node:http' @@ -33,6 +33,16 @@ import { import { classifyActiveRuntime } from './active-runtime-state' import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' +import { + type BackendOutputTail, + claimDecision, + createBackendOutputTail, + execText, + isPidOnlyStartMarker, + pidOnlyStartMarker, + probeStartMarker, + processStartMarker +} from './backend-claim' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, hermesManagedNodePathEntries, normalizeHermesHomeRoot } from './backend-env' @@ -221,11 +231,7 @@ import { import { runNativeLogin } from './native-oauth-login' import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } from './native-token-store' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' -import { - createParentStartMarkerResolver, - electronProcessStartMarker, - parentWatchdogEnv -} from './parent-process-identity' +import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' import { registerPetOverlayIpc } from './pet-overlay-ipc' import { buildRegistryProfileRoutes, @@ -3160,70 +3166,9 @@ function writeBackendOwnership(contents) { } } -function execText(command, args, { timeout = 3000 } = {}) { - return new Promise((resolve, reject) => { - execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => { - if (error) { - reject(error) - } else { - resolve(String(stdout || '').trim()) - } - }) - }) -} - -async function processStartMarker(pid) { - if (process.platform === 'linux') { - const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8') - - const fields = stat - .slice(stat.lastIndexOf(')') + 1) - .trim() - .split(/\s+/) - - if (!/^\d+$/.test(fields[19] || '')) { - throw new Error(`Invalid /proc start marker for PID ${pid}`) - } - - return `linux:${fields[19]}` - } - - if (IS_WINDOWS) { - const electronMarker = - pid === process.pid ? electronProcessStartMarker(pid, process.pid, process.getCreationTime?.()) : null - - if (electronMarker) { - return electronMarker - } - - const ticks = await execText( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - `$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks` - ], - // PowerShell 5.1 cold starts routinely exceed the default 3s execText - // budget (2.4-8s observed in #87169); give the marker probe headroom. - { timeout: 30_000 } - ) - - if (!/^\d+$/.test(ticks)) { - throw new Error(`Invalid Windows start marker for PID ${pid}`) - } - - return `win:${ticks}` - } - - const started = await execText('ps', ['-p', String(pid), '-o', 'lstart=']) - - if (!started) { - throw new Error(`Missing process start marker for PID ${pid}`) - } - - return `ps:${started}` -} +// execText and processStartMarker moved to backend-claim.ts (#93608) so the +// claim/probe policy is testable — including on Windows CI with real +// PowerShell — without booting Electron. main.ts calls through the module. async function backendCommandForPid(pid) { try { @@ -3245,6 +3190,22 @@ async function backendCommandForPid(pid) { } async function processIdentityMatches(identity) { + // Degraded PID-only identity (#93608): the start-marker probe failed while + // the child was verifiably alive, so only PID liveness can be checked here. + // backendIdentityMatches layers the command-line check on top before + // anything destructive relies on the answer. + if (isPidOnlyStartMarker(identity.startMarker)) { + try { + process.kill(identity.pid, 0) + + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code + + return code === 'ESRCH' || code === 'ENOENT' ? false : code === 'EPERM' ? true : undefined + } + } + try { return (await processStartMarker(identity.pid)) === identity.startMarker } catch (error) { @@ -3367,14 +3328,42 @@ const desktopParentStartMarker = createParentStartMarkerResolver({ } }) -async function claimBackendChild(child, command, profile, nonce) { +async function claimBackendChild(child, command, profile, nonce, outputTail: BackendOutputTail | null = null) { + // Probe/claim policy lives in backend-claim.ts (#93608): a marker probe + // that fails against a LIVE child degrades to PID-only identity — matching + // createParentStartMarkerResolver — instead of killing a healthy backend + // over a flaky Get-Process (PS 5.1 cold starts, #87169). Only a child that + // actually died keeps the fail-closed throw, now carrying its stderr tail. + const probe = await probeStartMarker(child.pid) + const decision = claimDecision(child.exitCode === null && !child.killed, probe) + + if (decision.action === 'fail') { + stopBackendChild(child) + await waitForBackendExit(child) + throw new Error( + `Hermes backend (PID ${child.pid}) died before its identity could be recorded: ${decision.reason}${outputTail?.describe() ?? ''}` + ) + } + + let startMarker + + if (decision.action === 'degrade') { + startMarker = pidOnlyStartMarker(child.pid) + rememberLog( + `WARNING: process start marker probe failed for live Hermes backend PID ${child.pid}; ` + + `claiming with PID-only identity instead of stopping it: ${decision.reason}` + ) + } else { + startMarker = decision.startMarker + } + try { const identity = await backendOwnership.claim({ command, nonce, pid: child.pid, profile, - startMarker: await processStartMarker(child.pid), + startMarker, // Record the spawning Electron so reapOrphans can tell an orphaned // backend (parent gone) from one owned by a live instance — a live // parent's backend is never reaped (#87295). @@ -3388,7 +3377,9 @@ async function claimBackendChild(child, command, profile, nonce) { } catch (error) { stopBackendChild(child) await waitForBackendExit(child) - throw new Error(`Could not persist ownership for the Hermes backend: ${error.message}`) + throw new Error( + `Could not persist ownership for the Hermes backend: ${error.message}${outputTail?.describe() ?? ''}` + ) } } @@ -10561,7 +10552,13 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po entry.process = child entry.token = token - await claimBackendChild(child, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce) + // Buffer stdout+stderr from the instant of spawn (#93608): an early crash's + // traceback must survive into the claim error and the before-ready exit + // message instead of a bare exit code. rememberLog attaches later, after + // the claim, and would miss anything printed before it. + const outputTail = createBackendOutputTail() + outputTail.attach(child) + await claimBackendChild(child, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce, outputTail) child.stdout.on('data', rememberLog) child.stderr.on('data', rememberLog) @@ -10586,13 +10583,18 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po if (!ready) { rejectStart?.( - new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) + new Error( + `Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).${outputTail.describe()}` + ) ) } }) // Discover the ephemeral port the child bound to - const port = await Promise.race([waitForDashboardPortAnnouncement(child, { readyFile }), startFailed]) + const port = await Promise.race([ + waitForDashboardPortAnnouncement(child, { describeOutputTail: () => outputTail.describe(), readyFile }), + startFailed + ]) if (readyFile) { fs.unlink(readyFile, () => {}) @@ -10937,7 +10939,19 @@ async function startHermes() { }) ) - await claimBackendChild(hermesProcess, `${backend.command} ${backend.args.join(' ')}`, profile, backendNonce) + // Buffer stdout+stderr from the instant of spawn (#93608): an early + // crash's traceback must survive into the claim error and the + // before-ready exit message shown by the boot UI. rememberLog attaches + // later, after the claim, and would miss anything printed before it. + const primaryOutputTail = createBackendOutputTail() + primaryOutputTail.attach(hermesProcess) + await claimBackendChild( + hermesProcess, + `${backend.command} ${backend.args.join(' ')}`, + profile, + backendNonce, + primaryOutputTail + ) const processOwner = backendConnectionState.attachProcess(connectionAttempt, hermesProcess) if (!processOwner) { @@ -10996,7 +11010,7 @@ async function startHermes() { sendBackendExit({ code, signal }) if (!backendReady) { - const message = `Hermes backend exited before it became ready (${signal || code}).` + const message = `Hermes backend exited before it became ready (${signal || code}).${primaryOutputTail.describe()}` updateBootProgress( { error: message, @@ -11018,7 +11032,10 @@ async function startHermes() { // Discover the ephemeral port the child bound to const port = await Promise.race([ - waitForDashboardPortAnnouncement(hermesProcess, { readyFile }), + waitForDashboardPortAnnouncement(hermesProcess, { + describeOutputTail: () => primaryOutputTail.describe(), + readyFile + }), backendStartFailed ])