Co-authored-by: Cursor <cursoragent@cursor.com> (cherry picked from commit ecaf213873ca00654d1b3cf543eb56e5bf3279c3)
323 lines
10 KiB
TypeScript
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' } }
|
|
}
|