fix(desktop): never kill a healthy backend on a claim probe failure; surface real stderr (#93608)
A start-marker probe failure (Get-Process timing out on a PowerShell 5.1 cold start, #87169) in claimBackendChild used to stop the freshly spawned backend and rethrow — killing a healthy backend, triggering the renderer's repair respawn, and looping. And because stderr piping only attached after the claim, every before-ready failure surfaced as a bare exit code. - extract probe + claim policy into electron/backend-claim.ts: processStartMarker/execText (moved verbatim from main.ts), probeStartMarker, and a pure claimDecision(childAlive, probe) a Windows CI lane can drive with real PowerShell - probe failure + LIVE child now degrades to PID-only identity (pid-only:<pid> marker, WARNING logged), matching the existing createParentStartMarkerResolver degrade pattern; processIdentityMatches verifies degraded identities by PID liveness (command check still layers on top in backendIdentityMatches) - probe failure + DEAD child keeps the fail-closed throw, now carrying the child's buffered stderr/stdout tail - ring-buffered ~8KB output tail attached at spawn time in BOTH spawn paths (pool + primary); tail appended to claim errors, before-ready exit messages, and backend-ready's exited-before-port-announcement errors so the real exit reason reaches desktop.log and the boot UI - tests: claimDecision matrix (degrade test fails against the old stop+throw behavior), real processStartMarker probe, ring-buffer caps, and output-tail suffixes on backend-ready exit errors
This commit is contained in:
@@ -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(), '')
|
||||
})
|
||||
@@ -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<string> {
|
||||
return new Promise<string>((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<string> {
|
||||
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<string> = processStartMarker
|
||||
): Promise<StartMarkerProbe> {
|
||||
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}` : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string>((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
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user