Files
hermes-agent/apps/desktop/electron/windows-stack-cookie-fallback.ts
KoNit-K 085d9ee608 fix(desktop): pass --disable-gpu on 0xC0000409 relaunch so marker write failure cannot loop
Co-authored-by: Cursor <cursoragent@cursor.com>
(cherry picked from commit ecaf213873ca00654d1b3cf543eb56e5bf3279c3)
2026-09-25 01:09:23 -05:00

323 lines
10 KiB
TypeScript

/**
* Windows Chromium renderer recovery for #108047.
*
* On some local Windows hosts the renderer dies with
* STATUS_STACK_BUFFER_OVERRUN (`0xC0000409` / signed exit `-1073740791`,
* FAST_FAIL_STACK_COOKIE_CHECK_FAILURE). After the shared crash-loop budget
* (3 / 60s) is exhausted, the window would otherwise stay blank: the
* existing #38216 sandbox fallback only matches STATUS_BREAKPOINT
* (`0x80000003`) and must not drop the sandbox for this signature.
*
* Recovery, all scoped to win32 renderer `crashed` + this exit code:
*
* 1. One-shot relaunch with GPU disabled (sticky per app version). Next
* launch, before app `ready`, reads the marker and applies the same
* switches as the remote-display path (`disableHardwareAcceleration` +
* `disable-gpu-compositing`).
* 2. If GPU is already off, the override forbids disabling GPU, or this
* process already attempted the relaunch — do not loop; the caller
* surfaces the visible renderer error page instead of a blank window.
*
* An app update re-probes GPU once (a new Electron may have fixed the host).
* Successful boot keeps the sticky fallback within the same version.
*
* Pure helpers stay injectable so tests never boot Electron.
*/
import fs from 'node:fs'
import path from 'node:path'
export const WINDOWS_GPU_STACK_COOKIE_MARKER_FILENAME = 'windows-gpu-stack-cookie-fallback.json'
/** STATUS_STACK_BUFFER_OVERRUN as a signed Win32 exit code (WER / Chromium). */
export const WINDOWS_STACK_COOKIE_EXIT = -1073740791
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
const DISABLE_GPU_SWITCH = '--disable-gpu'
const DISABLE_GPU_COMPOSITING_SWITCH = '--disable-gpu-compositing'
export type GpuStackCookieMarkerState = 'booting' | 'fallback' | 'ok'
export type GpuStackCookieFallbackReason = 'renderer-crash-loop'
export interface GpuStackCookieMarker {
state: GpuStackCookieMarkerState
/** Why the fallback engaged (state === 'fallback'). */
reason?: GpuStackCookieFallbackReason
/** App version that entered fallback — a version change triggers a re-probe. */
version?: string
/** This boot is a GPU re-probe after an app update. */
reprobe?: boolean
}
export interface GpuStackCookieLaunchDecision {
enable: boolean
reason: string | null
nextMarker: GpuStackCookieMarker
}
export interface RendererStackCookieCrashLoopOptions {
platform?: NodeJS.Platform | string
reason?: string
exitCode?: number | string
alreadyGpuDisabled?: boolean
relaunchAttempted?: boolean
gpuOverrideOff?: boolean
}
export function gpuStackCookieMarkerPath(userDataDir: string): string {
return path.join(String(userDataDir || ''), WINDOWS_GPU_STACK_COOKIE_MARKER_FILENAME)
}
export function isWindowsStackCookieExit(exitCode: unknown): boolean {
const n = Number(exitCode)
if (!Number.isFinite(n)) {
return false
}
// Signed STATUS_STACK_BUFFER_OVERRUN, or the same 32-bit pattern as unsigned.
return n === WINDOWS_STACK_COOKIE_EXIT || n >>> 0 === 0xc0000409
}
export function isHermesDesktopGpuOverrideOff(env: NodeJS.ProcessEnv = process.env): boolean {
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
return GPU_OVERRIDE_OFF.has(override)
}
/**
* True when this process already launched with GPU off — Chromium argv
* (`--disable-gpu`) or HERMES_DESKTOP_DISABLE_GPU on. Mirrors
* `alreadyHasNoSandbox`: the crash-loop relaunch MUST pass these switches so
* the next process is protected even if the sticky marker write failed.
*/
export function alreadyHasDisableGpu(argv: readonly string[] = [], env: NodeJS.ProcessEnv = process.env): boolean {
if (Array.isArray(argv) && argv.some(arg => arg === DISABLE_GPU_SWITCH)) {
return true
}
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
return GPU_OVERRIDE_ON.has(override)
}
/** Relaunch argv with a single `--disable-gpu` + compositing switch. */
export function buildDisableGpuRelaunchArgs(argv: readonly string[]): string[] {
const args = (Array.isArray(argv) ? argv : []).filter(
arg => arg !== DISABLE_GPU_SWITCH && arg !== DISABLE_GPU_COMPOSITING_SWITCH
)
args.push(DISABLE_GPU_SWITCH, DISABLE_GPU_COMPOSITING_SWITCH)
return args
}
export function parseGpuStackCookieMarker(raw: unknown): GpuStackCookieMarker | null {
if (!raw || typeof raw !== 'object') {
return null
}
const record = raw as Record<string, unknown>
const state = record.state
if (state !== 'booting' && state !== 'fallback' && state !== 'ok') {
return null
}
const marker: GpuStackCookieMarker = { state }
if (record.reason === 'renderer-crash-loop') {
marker.reason = 'renderer-crash-loop'
}
if (typeof record.version === 'string' && record.version) {
marker.version = record.version
}
if (record.reprobe === true) {
marker.reprobe = true
}
return marker
}
export function readGpuStackCookieMarker(
userDataDir: string,
{ readFileSync = fs.readFileSync } = {}
): GpuStackCookieMarker | null {
try {
const raw = JSON.parse(readFileSync(gpuStackCookieMarkerPath(userDataDir), 'utf8'))
return parseGpuStackCookieMarker(raw)
} catch {
return null
}
}
export function writeGpuStackCookieMarker(
userDataDir: string,
marker: GpuStackCookieMarker,
{
mkdirSync = fs.mkdirSync,
writeFileSync = fs.writeFileSync
}: {
mkdirSync?: typeof fs.mkdirSync
writeFileSync?: typeof fs.writeFileSync
} = {}
): void {
const dir = String(userDataDir || '')
if (!dir) {
return
}
mkdirSync(dir, { recursive: true })
writeFileSync(gpuStackCookieMarkerPath(dir), `${JSON.stringify(marker)}\n`, 'utf8')
}
export function gpuStackCookieFallbackMarker(
reason: GpuStackCookieFallbackReason,
appVersion?: string
): GpuStackCookieMarker {
const marker: GpuStackCookieMarker = { state: 'fallback', reason }
if (appVersion) {
marker.version = appVersion
}
return marker
}
/**
* After the main window reaches ready-to-show: keep the sticky GPU fallback
* when this launch disabled GPU for #108047, otherwise mark a clean boot so
* a later app update can re-probe hardware acceleration.
*/
export function markerAfterSuccessfulGpuStackCookieBoot(options: {
fallbackActive: boolean
appVersion?: string
}): GpuStackCookieMarker {
if (!options.fallbackActive) {
return { state: 'ok' }
}
return gpuStackCookieFallbackMarker('renderer-crash-loop', options.appVersion)
}
/**
* True when a renderer crash loop carries the #108047 stack-cookie signature
* and a one-shot GPU-disable relaunch should replace the dead window.
* Gated on the exit code so unrelated crash loops (sandbox breakpoint, OOM)
* don't silently disable GPU, and so an explicit GPU-off override is honored.
*/
export function shouldRelaunchForRendererStackCookieCrashLoop(options: RendererStackCookieCrashLoopOptions): boolean {
if ((options.platform ?? process.platform) !== 'win32') {
return false
}
if (options.alreadyGpuDisabled || options.relaunchAttempted || options.gpuOverrideOff) {
return false
}
if (String(options.reason || '') !== 'crashed') {
return false
}
return isWindowsStackCookieExit(options.exitCode)
}
/**
* True when the #108047 signature matched but GPU fallback cannot run
* (already disabled, override off, or this process already relaunched).
* The caller should load the visible renderer error page instead of a blank
* window. Does not drop the Chromium sandbox.
*/
export function shouldSurfaceErrorForRendererStackCookieCrashLoop(
options: RendererStackCookieCrashLoopOptions
): boolean {
if ((options.platform ?? process.platform) !== 'win32') {
return false
}
if (String(options.reason || '') !== 'crashed') {
return false
}
if (!isWindowsStackCookieExit(options.exitCode)) {
return false
}
return Boolean(options.alreadyGpuDisabled || options.relaunchAttempted || options.gpuOverrideOff)
}
/**
* Launch-time transition: decide whether this Windows launch disables GPU
* for the sticky #108047 stack-cookie fallback AND what the marker becomes.
*
* - `fallback` is sticky within one app version. A version change re-probes
* GPU once so a fixed host returns to hardware acceleration.
* - A marker without a version stays sticky (legacy / no-version contract).
* - `--disable-gpu` already in argv (crash-loop relaunch / Chromium switch)
* is honored even if the sticky marker write failed, but is NOT made sticky
* from argv alone.
* - `HERMES_DESKTOP_DISABLE_GPU` explicitly off (`0`/`false`/`no`/`off`)
* fail-opens: do not disable GPU.
*/
export function decideWindowsGpuStackCookieLaunch(
options: {
platform?: NodeJS.Platform | string
argv?: readonly string[]
env?: NodeJS.ProcessEnv
marker?: GpuStackCookieMarker | null
appVersion?: string
} = {}
): GpuStackCookieLaunchDecision {
const appVersion = String(options.appVersion || '')
const argv = options.argv ?? process.argv
const env = options.env ?? process.env
const marker = options.marker ?? null
if ((options.platform ?? process.platform) !== 'win32') {
return { enable: false, reason: null, nextMarker: { state: 'booting' } }
}
if (isHermesDesktopGpuOverrideOff(env)) {
const nextMarker: GpuStackCookieMarker = marker?.state === 'fallback' ? marker : { state: 'booting' }
return { enable: false, reason: null, nextMarker }
}
// Honor an in-process `--disable-gpu` relaunch (or env override) even when
// the sticky marker write failed. Not made sticky from argv alone — the
// marker lifecycle stays unchanged, matching sandbox's already-enabled path.
if (alreadyHasDisableGpu(argv, env)) {
const nextMarker: GpuStackCookieMarker = marker?.state === 'fallback' ? marker : { state: 'booting' }
return { enable: true, reason: 'already-enabled', nextMarker }
}
if (marker?.state === 'fallback') {
if (marker.version && appVersion && marker.version !== appVersion) {
return {
enable: false,
reason: null,
nextMarker: { state: 'booting', reprobe: true }
}
}
return {
enable: true,
reason: 'sticky-fallback',
nextMarker: { ...marker, version: marker.version || appVersion || undefined }
}
}
return { enable: false, reason: null, nextMarker: { state: 'booting' } }
}