Files
orca/src/main/startup/main-process-preflight.ts
T
Neil 7b530f1eb5 fix(crash-reporting): record Orca-initiated tree kills so a killed renderer is decidable (#18367)
* fix(windows): refuse tree-kills of Orca's own Chromium pids and record the rest

G2 is 20 field reports that share only a symptom. It is at least four
fingerprints: ~15 Windows `reason=killed exitCode=1`, 3 POSIX SIGKILL under
memory pressure (G4-oom), 2 duplicate reports of one macOS V8 Proxy Resolver
SIGKILL, and 1 `0x80000003` install-dir ACL crash (G1; #17740 ships in
v1.4.196 only, not 1.4.195). Nothing here claims to fix all of them.

Two changes:

1. Behaviour. `classifyWindowsTreeKillTarget` returns `own` for any direct
   child of the main process — which our renderer, GPU and network-service
   utility all are — so PTY teardown could `taskkill /T /F` Orca's own UI
   (#10680). Both that classifier and `terminateWindowsProcessTree` now refuse
   any pid Electron is currently accounting for in `getAppMetrics()`.

2. Diagnosis. An Orca-issued kill and an external one are byte-identical in
   every field the crash report records today, so the cluster is undecidable.
   Every main-process force-kill choke point now records a durable
   `self_tree_kill` breadcrumb, and `process_gone` reports carry
   `selfInitiatedTreeKills` naming the pid and its offset from the death.
   A refused kill records `self_tree_kill_refused_own_chromium`, which is
   falsifiable: if it ever shows up in the field, we were the killer.

* fix(crash-reporting): coalesce self-kill breadcrumbs and scope the discriminator

Round-1 review remediation. Three blocking findings, all accepted.

1. Breadcrumb flood (accepted). recordSelfInitiatedTreeKill wrote an
   uncoalesced durable crumb from two routine teardown paths, and the
   reviewer reproduced 12 terminal closes x 3 process groups completely
   evicting the 30-slot ring — including this PR's own refusal crumb — plus a
   forced writeSync per killed group. It now uses the existing
   recordCoalescedDurableCrashBreadcrumb (5s window for pid-addressed
   taskkills, 60s for routine group/job teardown), so a burst costs one ring
   slot and one flush. The refusal crumb is coalesced per victim pid, so a
   retry loop cannot flood while a distinct pid always gets its own crumb.
   Regression test replays the reviewer's exact 12x3 reproduction and asserts
   the refusal crumb and a pre-existing gpu_process_crashed both survive.

2. Undifferentiated count (accepted). posix-process-group and win-pty-job are
   structurally incapable of reaching a Chromium process, and scope was absent
   from the persisted string. Scope is now in every entry
   (`<scope>/<site>/pid<N> +Nms`), and the count is split:
   selfInitiatedTreeKillCount now counts only pid-addressed taskkills — the
   kills that can land on a recycled pid that is now our renderer — with
   pty-scoped sweeps in selfInitiatedGroupKillCount. The list is renamed
   selfInitiatedKills because it carries both, and sorts pid-addressed kills
   first so truncation never drops the discriminating ones for teardown noise.
   The reviewer's repro (routine macOS terminal close + unrelated exit-133
   crash) now yields selfInitiatedTreeKillCount undefined.

3. Recording gaps and a false comment (accepted). New
   admitSelfInitiatedTreeKill gate: it refuses own-Chromium pids and records
   the rest, and all three main-process taskkill families now go through it —
   terminateWindowsProcessTree plus codex-accounts/service.ts and
   claude-accounts (which keep their own spawn lifetimes). The false "single
   taskkill choke point" comment is gone. The runProcess choke point the
   investigation asked for is instrumented via a
   setProcessTreeKillObserver seam in src/shared/child-process — shared code
   runs in the CLI and relay so it cannot import the main breadcrumb store —
   registered in main preflight. The codex app-server POSIX group teardowns
   and the claude POSIX branch record too. The module doc no longer claims
   absence is discriminating: it enumerates what is instrumented and names the
   direct process.kill(-pid) sites that are not.

Non-blocking, also fixed:
- Breadcrumb calls moved out of the try blocks whose catch is the ESRCH
  contract (posix-pty-process-groups, codex teardown, claude POSIX), so a
  throw from the diagnostic path can never be reported as a failed kill.
- Detail truncation now bounds the first entry too, matching its comment.
- own-chromium-tree-kill-refusal.test.ts renamed to
  own-chromium-tree-kill-guard.test.ts, colocated with the module it tests.

Not changed, with reasons:
- Date.now() vs performance.now(): kept. Offsets are computed against
  goneAt = Date.now() in process-gone-recorder; a monotonic clock here would
  make the offsets meaningless. The reviewer verified this and agreed it is
  not a defect.
- app.getAppMetrics() per force-kill remains unbenchmarked. It reads
  in-process browser state rather than enumerating the OS process table, and a
  TTL cache would let a recycled pid slip past the refusal, so it stays
  uncached.
- The ~15 remaining direct process.kill(-pid) sites (browser routes,
  notebooks, automation prechecks, ephemeral VM recipes) are not instrumented.
  Rather than claim coverage this PR does not have, the module doc names them.

claude-command-process.ts crossed the 300-line cap, so terminateClaudeProcess
moved to claude-login-process-termination.ts. No max-lines suppression added.

* fix(crash-reporting): scope the self-kill guard to its real host topology

Round-2 review findings on the own-Chromium tree-kill guard.

BLOCKING 1 — "the own-Chromium refusal is a no-op in the process that issues
the pty-descendant-sweep taskkill". Correct on the mechanism, wrong on the
consequence; REBUTTED in part and documented in full.

Confirmed: the only non-test `setAppEnvironment` installs are
main-process-preflight.ts:177 (Electron) and orcad-entry.ts:84 (Node, whose
`getAppMetrics()` is `[]`); daemon-init-fresh-import.ts is a test harness. So
in the standalone daemon `readOrcaChromiumProcessPids()` is empty and
`admitSelfInitiatedTreeKill` always admits.

But that is not a live hazard. `killWithDescendantSweep` reaches
`terminateWindowsProcessTree` only when `verifyWindowsTreeKillTarget` returns
`own`, and that walks ancestry back to `deps.ownerPid ?? process.pid` — the
KILLING process's pid. In the daemon that is the daemon's pid. Orca's Chromium
processes are children of Electron main, a sibling of the daemon, so their
chain never reaches it: hop 0 lands on main, and within MAX_ANCESTOR_HOPS the
walk dead-ends and returns `foreign`. The reviewer's probe passes
`ownerPid: 1000` with the renderer as a direct child of 1000 — that is the
Electron-main topology, where the AppEnvironment IS installed and the guard DOES
fire, not the daemon's. On an orcad/SSH host there is no Chromium on the box at
all, so `[]` is accurate rather than degraded.

Locked in as tests rather than prose (own-chromium-tree-kill-guard.test.ts):
a renderer classifies `foreign` from a daemon ownerPid with an empty pid set,
and `own` from main's ownerPid with an empty set — the falsifiable pair showing
the pid set is load-bearing in main and nowhere else. Documented the host
coverage in orca-chromium-process-pids.ts and own-chromium-tree-kill-guard.ts.

One genuine hole the finding exposes: `signalProcessTree`'s `taskkillTree` is a
fourth pid-addressed taskkill family (non-blocking item 2), it runs in the
daemon/relay/CLI where the guard cannot run, and it guarded only on
`!child.pid`. Reusing the predicate the codex login teardown already uses, the
win32 branch now refuses a reaped child and falls back to `killRoot` — the same
shape as the existing `!child.pid` branch. That closes the reaped-then-recycled
pid path in every host.

BLOCKING 2 — module doc overstates coverage. Rewritten: the ring is per-process
and its only reader lives in Electron main, so a count on a `render-process-gone`
covers main-issued kills only. Sites are now split into main-only, main-and-
other-hosts (runProcess choke point, POSIX PTY group sweep, Windows Job Object —
which record into a ring nothing reads when they run in the daemon or relay),
and never-instrumented, with the note that a daemon/relay omission is a
diagnostics gap, not a missed suspect, per the topology argument above.

BLOCKING 3 — the three out-of-main instrumentation sites were untested. Added
regression coverage: the runProcess seam on both branches plus the reaped-child
refusal (process-tree-termination.test.ts), the group sweep recording only
groups it actually signalled and skipping an ESRCH group
(posix-pty-process-groups.test.ts), and the Job Object recording the shell pid
only on `terminated` (windows-pty-job.test.ts). Verified red: reverting the
three production files to origin/main fails 7 of the new tests.

BLOCKING 4 — the Windows evidence validates a single-process model. Accepted.
The main2.js arms exercise `pty-descendant-sweep` inside one Electron process;
that models the in-process/degraded daemon and the local PTY provider, not the
standalone daemon. Arm C's "the 449351d6 shape is not producible with the guard"
holds for main-issued kills only. In the daemon the shape is blocked one layer
earlier, by the ancestry check, which the arms do not exercise.

NON-BLOCKING taken: `recordSelfInitiatedTreeKill` moved outside the native
`terminateJob` try in windows-pty-job.ts, so a diagnostics throw can no longer
downgrade a real termination to `unavailable` and escalate callers to a broader
kill; covered by a test. The "all three families" parenthetical is gone with the
doc rewrite. `pnpm build:relay` run: exit 0, all seven targets built.

NON-BLOCKING declined: codex-accounts/service.ts records before the spawn
because a refusal must prevent the spawn — the crumb means "we were about to
kill this pid", which is the artifact worth having; the existing comment already
says so. `app.getAppMetrics()` perf is unbenchmarked and unchanged by this round.

Verification: pnpm tc clean; oxlint clean on touched paths;
check:code-quality:changed 0 new findings; oxfmt applied. 730 tests pass across
shared/child-process, main/crash-reporting, main/pty, main/windows and the guard
and descendant-sweep suites. The 4 failures in providers/git/codex-integration
reproduce on HEAD without these changes.

* fix(crash-reporting): keep the reaped-pid skip from flipping the termination barrier

The win32 hasExited short-circuit correctly avoids taskkill on a pid Windows
may have reissued, but it resolved `true` — verified tree termination. A
taskkill against a reaped pid already resolved `false`, and run-process turns
`true` into barrierTerminationVerified + terminationReporter.report(), which
releases the git admission grant on root exit instead of on `close`. That
admits the next git command while a descendant holding the inherited pipes is
still writing the repo. Resolve `false` so the skip changes only which process
we refuse to signal, not what the barrier claims.
2026-09-03 02:40:58 -07:00

331 lines
18 KiB
TypeScript

import { app, ipcMain, powerMonitor, session } from 'electron'
import { is } from '@electron-toolkit/utils'
import os from 'node:os'
import { join } from 'node:path'
import { maybeRedirectCliLaunch } from './cli-launch-redirect'
import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv'
import {
configureDevUserDataPath,
configureElectronNetworkCompatibility,
configureOrcaUserDataPathEnv,
disableUnsupportedChromiumFeatures,
enableMainProcessGpuFeatures,
installDevParentDisconnectQuit,
installDevParentSignalQuit,
installDevParentWatchdog,
patchPackagedProcessPath,
optOutOfHiddenPageWakeUpThrottling
} from './configure-process'
import { installServeSupervisorDisconnectQuit } from '../serve-update-handoff'
import {
installUncaughtPipeErrorGuard,
installUnhandledRejectionLogging
} from './main-process-error-guards'
import { hydrateShellPath, mergePathSegments } from './hydrate-shell-path'
import { configureRemoteServerUpdater } from '../runtime/remote-server-updater'
import {
getRemoteServerUpdaterSnapshot,
checkForRemoteServerUpdate,
downloadRemoteServerUpdate,
installRemoteServerUpdate,
isQuittingForUpdate
} from '../updater'
import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity'
import { enableRendererHeapHeadroom } from './renderer-heap-headroom'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics'
import { startEventLoopStallProbe } from './event-loop-stall-probe'
import { startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe'
import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation'
import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary'
import { createServeDesktopActivationGate } from './serve-desktop-activation'
import {
shouldBypassSingleInstanceLock,
shouldSkipSingleInstanceLock,
acquireSingleInstanceLock,
logSingleInstanceLockBypass,
logSingleInstanceLockFailure,
SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE
} from './single-instance-lock'
import { setAppEnvironment } from '../../shared/app-environment'
import { ElectronAppEnvironment } from '../host/electron-app-environment'
import { installProcessTreeKillBreadcrumbObserver } from '../crash-reporting/self-initiated-tree-kill-log'
import { setSecretStore } from '../../shared/secret-store'
import { ElectronSecretStore } from '../host/electron-secret-store'
import { setPtyHostBindings } from '../ipc/pty-host-bindings'
import { electronRuntimeDesktopSurface } from '../host/electron-runtime-desktop-surface'
import { setRuntimeDesktopSurface } from '../runtime/runtime-desktop-surface'
import { electronRuntimeBrowserCommandsFactory } from '../host/electron-browser-commands'
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
import { electronHttpClient } from '../host/electron-http-client'
import { setMainHttpClient } from '../network/http-client'
import { electronSpeechServiceFactories } from '../host/electron-speech-services'
import { setSpeechServiceFactories } from '../speech/speech-runtime-service'
import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal'
import { desktopWorktreeWatcherRemoval } from '../ipc/filesystem-watcher'
import { setDefaultProxySessionResolver } from '../network/proxy-settings'
import { initDataPath, getCanonicalUserDataPath } from '../persistence'
import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default'
import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence'
import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store'
import { initStatsPath } from '../stats/collector'
import { initClaudeUsagePath } from '../claude-usage/store'
import { initCodexUsagePath } from '../codex-usage/store'
import { initOpenCodeUsagePath } from '../opencode-usage/store'
import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol'
import { startCrashpadCapture } from '../crash-reporting/crashpad-capture'
import { CrashReportStore } from '../crash-reporting/crash-report-store'
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb'
import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics'
import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity'
import {
ensureVirtualDisplayForHeadlessServe,
hasUsableLinuxDisplay,
MISSING_LINUX_DISPLAY_MESSAGE
} from './ensure-virtual-display'
import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle'
import { mainProcessState as state } from './main-process-state'
import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime'
export type MainProcessPreflightOptions = {
focusExistingWindow: () => void
requestDesktopActivation: (argv?: readonly string[]) => void
}
/** Performs all module-scope work that must happen before Electron's ready event. */
export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean {
// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below.
// The redirect runs before the serve-argv rewrite so it still matches on the launch argv verbatim.
// Direct serve stays in-process so its signal handlers own all children.
const cliLaunchRedirect = maybeRedirectCliLaunch({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
execPath: process.execPath
})
if (cliLaunchRedirect.redirected) {
app.exit(cliLaunchRedirect.status)
}
// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the
// Electron process without the CLI rewrite that injects `--serve` (#12677).
// Guarded so a normal GUI launch keeps its original argv array identity.
if (argvRequestsServeMode(process.argv)) {
process.argv = normalizeServeModeArgv(process.argv)
}
state.isServeMode = process.argv.includes('--serve')
// Fail before Chromium's missing-display teardown can segfault (#13719).
if (app.isPackaged && !state.isServeMode && !hasUsableLinuxDisplay()) {
process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`)
app.exit(1)
}
if (state.isServeMode) {
reserveServeStdoutForReadiness()
}
state.devInstanceIdentity = getDevInstanceIdentity(is.dev)
state.devAgentHookEndpointNamespace = state.devInstanceIdentity.isDev
? state.devInstanceIdentity.appUserModelId
: undefined
state.desktopActivationGate = createServeDesktopActivationGate({
initialState: state.isServeMode ? 'initializing' : 'ready',
activateWindow: () => {
// Why: an updater replacement must not resurrect the old app bundle.
if (!isQuittingForUpdate()) {
options.focusExistingWindow()
}
},
onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`)
})
installUncaughtPipeErrorGuard()
// Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report).
installUnhandledRejectionLogging()
// Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron.
process.env.ORCA_APP_VERSION = app.getVersion()
configureRemoteServerUpdater({
getSnapshot: getRemoteServerUpdaterSnapshot,
check: checkForRemoteServerUpdate,
download: downloadRemoteServerUpdate,
install: installRemoteServerUpdate
})
patchPackagedProcessPath()
// Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH.
if (app.isPackaged && process.platform !== 'win32') {
void hydrateShellPath().then((result) => {
if (result.ok) {
mergePathSegments(result.segments)
} else {
// Why: on failure the seeded fallbacks stay in front. For an nvm user that is
// now their `default` version rather than the newest install, so it is usually
// survivable — but it is still not what their shell would have resolved. Name
// the reason so it shows up in a log bundle instead of as a missing CLI.
console.warn(
`[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH`
)
}
})
}
// Why before any spawn: `signalProcessTree` is shared with the CLI and relay, so
// it can only reach the main-process breadcrumb store through a registered observer.
installProcessTreeKillBreadcrumbObserver()
const isDev = is.dev
configureDevUserDataPath(isDev)
configureOrcaUserDataPathEnv()
// Why these four lines are one step (#16761): the two above decide where userData lives, and
// everything below may resolve a path. Installing the accessor any later leaves a window where an
// early resolve either throws — which is what killed `orca serve` — or, worse, memoizes the
// pre-override directory and silently writes user state to the wrong place for the whole session.
// Safe this early: ElectronAppEnvironment holds no state and calls `app` lazily per accessor, so it
// changes no timing, and initDataPath only joins strings.
setAppEnvironment(new ElectronAppEnvironment())
// Why captured now: after the dev/E2E override above, and before app.setName('Orca') (whenReady)
// changes how userData resolves on a case-sensitive filesystem. See persistence.ts:20-28.
initDataPath()
state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
if (state.startupDiagnosticsEnabled) {
logStartupDiagnostic('before-single-instance-lock', {
version: app.getVersion(),
packaged: app.isPackaged,
platform: process.platform,
osRelease: os.release(),
userData: app.getPath('userData'),
e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR)
})
startEventLoopStallProbe()
}
// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576).
// Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache
// look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart.
startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) })
// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces.
// Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312).
const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode })
const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode })
if (bypass) {
// Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist.
logSingleInstanceLockBypass()
}
const hasLock = skip || bypass || acquireSingleInstanceLock(app, options.requestDesktopActivation)
if (state.startupDiagnosticsEnabled) {
logStartupDiagnostic('single-instance-lock-result', {
acquired: hasLock,
bypassed: bypass,
skippedForDev: skip
})
}
if (!hasLock) {
// Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line.
logSingleInstanceLockFailure()
// Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935).
app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
return false
}
// Why first in this block: the accessor throws until installed and everything below may read a
// credential. The constructor does not touch `safeStorage` — it resolves lazily per call — so
// installing here changes no timing, in particular not the pre-ready Keychain service-name
// resolution. The app-environment port and the userData capture install earlier still, next to
// the path decision they depend on.
setSecretStore(new ElectronSecretStore())
// Why at process level, not per-window: pty.ts registers against injected surfaces so
// it can load without electron, and an Electron main process always has ipcMain —
// whether a window exists is irrelevant. Installing this in attachMainWindowServices
// meant `orca serve` registered its PTY handlers against no-ops before any window
// attached, so a paired desktop owner never received them.
setPtyHostBindings({ ipc: ipcMain, power: powerMonitor })
// Why also at process level: the runtime's notification, window-lookup and
// tab-create-reply channel are desktop-only. A Node host installs none and the
// runtime routes notifications to paired clients instead.
setRuntimeDesktopSurface(electronRuntimeDesktopSurface)
// Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser
// cluster into the graph. The desktop installs it; a Node host installs none and every
// browser RPC rejects, which capability filtering already tells clients about.
setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)
// Why here: proxy-settings only needed electron for `session.defaultSession`. The
// desktop supplies it; a Node host has no Chromium proxy config to consult, so the
// environment variables are the whole answer there.
setDefaultProxySessionResolver(() => session.defaultSession)
// Why here: integrations use Chromium's network stack on the desktop. A Node host
// falls back to the platform default, which is a real behavioural difference (proxy
// read from the environment, Node's user agent) rather than a transparent swap.
setMainHttpClient(electronHttpClient)
// Why here: constructing the speech services is what pulls Electron's streaming net
// request in. A host without them rejects speech calls rather than pretending.
setSpeechServiceFactories(electronSpeechServiceFactories)
setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
const shouldCoupleToDevParent = isDev && !state.isServeMode
installDevParentDisconnectQuit(shouldCoupleToDevParent)
installDevParentWatchdog(shouldCoupleToDevParent)
installDevParentSignalQuit(shouldCoupleToDevParent)
// Why not at module scope with the other lifetime couplings (#16761): this resolves the handoff
// path, so it throws until setAppEnvironment() above installs the accessor — which killed every
// `orca serve` process before it could listen. After initDataPath() specifically, so the
// path-equality check against the CLI's env var uses the dir captured before app.setName().
// Safe to defer, and must stay synchronous: no 'disconnect' can be delivered until this module
// finishes evaluating, so moving this behind an await would open a real orphan window.
installServeSupervisorDisconnectQuit(state.isServeMode)
// Why here: initDataPath above gives the canonical userData path for the record file; the write
// itself lands for the next launch (see macos-press-and-hold-default.ts).
applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())
// Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence.
initSessionParseCachePersistence({
filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'),
appVersion: app.getVersion()
})
initOrcaProfilePaths()
// Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28.
initStatsPath()
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
// Why: Electron resolves the macOS safeStorage Keychain service name
// ("<app name> Safe Storage") before `ready`, so the setName in whenReady is
// too late to move it — dev otherwise lands on the package.json name. Dev-only
// so a packaged build keeps deriving the key from its own CFBundleName.
// Safe here: dev always pins userData via app.setPath (configure-process.ts),
// so setName cannot shift the paths captured just above.
if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) {
app.setName(state.devInstanceIdentity.appName)
}
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
registerDocPreviewSchemePrivileges()
// Why: must precede app.whenReady() so Crashpad is installed before the
// first renderer spawns; a CHECK before this point is still exit-code-only.
startCrashpadCapture()
state.crashReports = CrashReportStore.fromUserData()
state.gpuCrashDiagnostics =
process.platform === 'win32'
? new GpuCrashDiagnosticsRecorder({
provider: {
getGPUInfo: (infoType) => app.getGPUInfo(infoType),
getGPUFeatureStatus: () => app.getGPUFeatureStatus()
},
recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data)
})
: null
recordCrashBreadcrumb('app_started', {
packaged: app.isPackaged,
platform: process.platform,
...getMainProcessLifecycleIdentity()
})
disableUnsupportedChromiumFeatures()
// Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below.
optOutOfHiddenPageWakeUpThrottling()
configureElectronNetworkCompatibility()
enableRendererHeapHeadroom()
maybeApplyGpuFallbackForThisLaunch()
if (!state.gpuFallbackActiveThisLaunch) {
enableMainProcessGpuFeatures()
}
// Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed.
state.headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({
isServeMode: state.isServeMode
})
// Why: continuing without Xvfb lets Ozone initialize without a display and SIGSEGV (#17615).
if (state.isServeMode && !state.headlessBrowserDisplayAvailable) {
process.stderr.write(`${MISSING_LINUX_DISPLAY_MESSAGE}\n`)
app.exit(1)
}
initializeSyntheticTitleRuntime()
registerGpuLifecycleHandlers()
return true
}