From e53d2dfccbb96f0a40e07f798b7c72096195e439 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sat, 18 Jul 2026 14:48:27 +0700 Subject: [PATCH] fix(desktop): recover Windows GPU sandbox 0x80000003 startup crashes Grant ALL APPLICATION PACKAGES RX on the unpacked app and stick a boot marker so fatal Chromium sandbox deaths relaunch with --no-sandbox (#38216). --- apps/desktop/electron/main.ts | 117 ++++++++ .../electron/windows-sandbox-fallback.ts | 261 ++++++++++++++++++ scripts/install.ps1 | 17 ++ 3 files changed, 395 insertions(+) create mode 100644 apps/desktop/electron/windows-sandbox-fallback.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7611206434d7..4eeb3a01d889 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -144,6 +144,17 @@ import { getVenvSitePackagesEntries, resolveVenvHermesCommand } from './windows-hermes-path' +import { + alreadyHasNoSandbox, + buildNoSandboxRelaunchArgs, + grantAllApplicationPackagesAcl, + markerAfterSuccessfulBoot, + nextSandboxMarkerAfterLaunchDecision, + readSandboxMarker, + shouldEnableWindowsNoSandbox, + shouldRelaunchForGpuSandboxCrash, + writeSandboxMarker +} from './windows-sandbox-fallback' import { installWindowsSystemCaTrust } from './windows-system-ca' import { readWindowsUserEnvVar } from './windows-user-env' import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd' @@ -204,6 +215,88 @@ if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration') } +// Windows sandbox / GPU breakpoint crash recovery (#38216). +// +// Some hosts (AMD RX 6000 drivers, orphan AppContainer SIDs under %LOCALAPPDATA%, +// missing S-1-15-2-2 ACEs) kill Chromium's sandboxed GPU/renderer children with +// 0x80000003. After enough GPU deaths the browser process FATAL-exits before the +// UI is usable. Must run before app `ready` so `--no-sandbox` applies to child +// processes. The sticky marker recovers Start Menu / shortcut launches that +// never go through `hermes desktop`. +let windowsSandboxFallbackActive = false +let windowsNoSandboxRelaunchAttempted = false + +if (IS_WINDOWS) { + const windowsUserData = app.getPath('userData') + const exeDir = path.dirname(process.execPath) + + // Best-effort ACL repair first — may be enough without disabling the sandbox. + for (const target of [exeDir, windowsUserData]) { + const acl = grantAllApplicationPackagesAcl(target, { execFileSync }) + + if (acl.ok) { + console.log(`[hermes] granted ALL APPLICATION PACKAGES RX on ${target}`) + } else if (acl.error && acl.error !== 'missing-target-or-exec') { + console.warn(`[hermes] AppContainer ACL grant failed on ${target}: ${acl.error}`) + } + } + + const priorMarker = readSandboxMarker(windowsUserData) + const sandboxDecision = shouldEnableWindowsNoSandbox({ + argv: process.argv, + env: process.env, + marker: priorMarker + }) + + windowsSandboxFallbackActive = sandboxDecision.enable + + if (sandboxDecision.enable && sandboxDecision.reason !== 'already-enabled') { + app.commandLine.appendSwitch('no-sandbox') + process.env.ELECTRON_DISABLE_SANDBOX = '1' + console.log( + `[hermes] Windows sandbox fallback enabled (${sandboxDecision.reason}); launching with --no-sandbox (#38216)` + ) + } + + writeSandboxMarker(windowsUserData, nextSandboxMarkerAfterLaunchDecision({ + enabledNoSandbox: windowsSandboxFallbackActive + })) + + // Catch the first GPU breakpoint death and relaunch before Chromium's + // "GPU process isn't usable" FATAL abort ends the process with no recovery. + app.on('child-process-gone', (_event, details) => { + if ( + !shouldRelaunchForGpuSandboxCrash({ + details, + alreadyNoSandbox: windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env), + relaunchAttempted: windowsNoSandboxRelaunchAttempted + }) + ) { + return + } + + windowsNoSandboxRelaunchAttempted = true + windowsSandboxFallbackActive = true + + try { + writeSandboxMarker(app.getPath('userData'), { state: 'fallback' }) + } catch { + void 0 + } + + console.warn( + `[hermes] Windows GPU sandbox crashed (exit=${details?.exitCode}); relaunching once with --no-sandbox (#38216)` + ) + + try { + app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) }) + app.exit(0) + } catch (error) { + console.error(`[hermes] --no-sandbox relaunch failed: ${error?.message || error}`) + } + }) +} + ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) // Keep the renderer running at full speed while the window is in the background @@ -7325,6 +7418,20 @@ function createWindow() { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show() } + + // #38216: clear the mid-boot marker only after a window is actually usable. + // Keep sticky `fallback` when we launched with --no-sandbox so the next + // Start Menu click does not re-enter the GPU FATAL crash loop. + if (IS_WINDOWS) { + try { + writeSandboxMarker( + app.getPath('userData'), + markerAfterSuccessfulBoot({ fallbackActive: windowsSandboxFallbackActive }) + ) + } catch (error) { + rememberLog(`[sandbox] marker update after ready-to-show failed: ${error?.message || error}`) + } + } }) mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) @@ -9257,6 +9364,16 @@ function configureSpellChecker() { } app.on('before-quit', () => { + // Clean quit mid-boot should not trip next-launch --no-sandbox (#38216). + // FATAL GPU aborts skip before-quit, leaving the `booting` marker in place. + if (IS_WINDOWS && !windowsSandboxFallbackActive) { + try { + writeSandboxMarker(app.getPath('userData'), markerAfterSuccessfulBoot({ fallbackActive: false })) + } catch { + void 0 + } + } + // The always-on-top overlay isn't a "real" app window; close it so a stray // pet can't keep the process alive or float over a quit app. closePetOverlay() diff --git a/apps/desktop/electron/windows-sandbox-fallback.ts b/apps/desktop/electron/windows-sandbox-fallback.ts new file mode 100644 index 000000000000..f1ec674cd6ca --- /dev/null +++ b/apps/desktop/electron/windows-sandbox-fallback.ts @@ -0,0 +1,261 @@ +/** + * Windows Chromium/Electron sandbox recovery for #38216. + * + * On some Windows hosts the GPU/renderer sandboxes die with STATUS_BREAKPOINT + * (`0x80000003` / exit `-2147483645`). Chromium then FATAL-exits + * ("GPU process isn't usable. Goodbye.") before the UI is usable. + * + * Two complementary recoveries, both scoped to win32: + * + * 1. Grant `S-1-15-2-2` (ALL APPLICATION PACKAGES) RX on the install / + * userData trees. Missing that ACE plus orphan AppContainer SIDs is a + * known Chromium CHECK failure (electron/electron#51761). + * 2. Sticky boot marker: write `booting` before sandbox bring-up; clear it + * only on a clean ready. An uncleared marker on the next launch means the + * previous process aborted mid-boot → enable `--no-sandbox` (the only flag + * reporters verified as fully stable for AMD RX 6000 / hybrid GPU hosts). + * + * Pure helpers stay injectable so tests never boot Electron or touch real ACLs. + */ + +import fs from 'node:fs' +import path from 'node:path' + +export const WINDOWS_SANDBOX_MARKER_FILENAME = 'windows-sandbox-fallback.json' + +/** Well-known SID for "ALL APPLICATION PACKAGES". */ +export const ALL_APPLICATION_PACKAGES_SID = 'S-1-15-2-2' + +/** STATUS_BREAKPOINT as a signed Win32 exit code (WER / Chromium). */ +export const WINDOWS_SANDBOX_BREAKPOINT_EXIT = -2147483645 + +export type SandboxMarkerState = 'booting' | 'fallback' | 'ok' + +export interface SandboxMarker { + state: SandboxMarkerState +} + +export function sandboxMarkerPath(userDataDir: string): string { + return path.join(String(userDataDir || ''), WINDOWS_SANDBOX_MARKER_FILENAME) +} + +export function isWindowsSandboxBreakpointExit(exitCode: unknown): boolean { + const n = Number(exitCode) + + if (!Number.isFinite(n)) { + return false + } + + // Signed STATUS_BREAKPOINT, or the same 32-bit pattern as unsigned. + return n === WINDOWS_SANDBOX_BREAKPOINT_EXIT || (n >>> 0) === 0x80000003 +} + +export function alreadyHasNoSandbox( + argv: readonly string[] = [], + env: NodeJS.ProcessEnv = process.env +): boolean { + if (Array.isArray(argv) && argv.some(arg => arg === '--no-sandbox')) { + return true + } + + const disable = String(env.ELECTRON_DISABLE_SANDBOX || '') + .trim() + .toLowerCase() + + return disable === '1' || disable === 'true' || disable === 'yes' || disable === 'on' +} + +export function parseSandboxMarker(raw: unknown): SandboxMarker | null { + if (!raw || typeof raw !== 'object') { + return null + } + + const state = (raw as { state?: unknown }).state + + if (state === 'booting' || state === 'fallback' || state === 'ok') { + return { state } + } + + return null +} + +export function readSandboxMarker( + userDataDir: string, + { readFileSync = fs.readFileSync } = {} +): SandboxMarker | null { + try { + const raw = JSON.parse(readFileSync(sandboxMarkerPath(userDataDir), 'utf8')) + + return parseSandboxMarker(raw) + } catch { + return null + } +} + +export function writeSandboxMarker( + userDataDir: string, + marker: SandboxMarker, + { + 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(sandboxMarkerPath(dir), `${JSON.stringify(marker)}\n`, 'utf8') +} + +/** + * Decide whether this Windows launch should disable the Chromium sandbox. + * + * `booting` left from a prior launch → previous process aborted before ready. + * `fallback` → we already recovered once; keep the workaround sticky so the + * Start Menu shortcut does not crash every other launch. + */ +export function shouldEnableWindowsNoSandbox(options: { + platform?: NodeJS.Platform | string + argv?: readonly string[] + env?: NodeJS.ProcessEnv + marker?: SandboxMarker | null +} = {}): { enable: boolean; reason: string | null } { + if ((options.platform ?? process.platform) !== 'win32') { + return { enable: false, reason: null } + } + + const argv = options.argv ?? process.argv + const env = options.env ?? process.env + + if (alreadyHasNoSandbox(argv, env)) { + return { enable: true, reason: 'already-enabled' } + } + + const state = options.marker?.state + + if (state === 'booting') { + return { enable: true, reason: 'uncleared-boot-marker' } + } + + if (state === 'fallback') { + return { enable: true, reason: 'sticky-fallback' } + } + + return { enable: false, reason: null } +} + +/** + * Marker to persist immediately after the launch decision, before GPU/sandbox + * child processes start. Successful boots rewrite this later. + */ +export function nextSandboxMarkerAfterLaunchDecision(options: { + enabledNoSandbox: boolean +}): SandboxMarker { + if (options.enabledNoSandbox) { + return { state: 'fallback' } + } + + return { state: 'booting' } +} + +/** + * After the main window reaches ready-to-show: keep sticky fallback when we + * launched with `--no-sandbox`, otherwise mark a clean boot so the next + * launch can try the sandbox again (e.g. after an ACL grant fixed the host). + */ +export function markerAfterSuccessfulBoot(options: { fallbackActive: boolean }): SandboxMarker { + return options.fallbackActive ? { state: 'fallback' } : { state: 'ok' } +} + +/** + * Build `icacls` argv that grants ALL APPLICATION PACKAGES RX with inheritance. + * `/T` applies to existing children (win-unpacked DLLs); `/C` continues on + * errors; `/Q` stays quiet for installer logs. + */ +export function buildIcaclsGrantArgs(targetDir: string): string[] { + return [ + String(targetDir), + '/grant', + `*${ALL_APPLICATION_PACKAGES_SID}:(OI)(CI)(RX)`, + '/T', + '/C', + '/Q' + ] +} + +export function grantAllApplicationPackagesAcl( + targetDir: string, + { + platform = process.platform, + execFileSync + }: { + platform?: NodeJS.Platform | string + execFileSync?: (file: string, args: readonly string[], options?: object) => Buffer | string + } = {} +): { ok: boolean; error?: string } { + if (platform !== 'win32') { + return { ok: false } + } + + const dir = String(targetDir || '').trim() + + if (!dir || typeof execFileSync !== 'function') { + return { ok: false, error: 'missing-target-or-exec' } + } + + try { + execFileSync('icacls', buildIcaclsGrantArgs(dir), { + windowsHide: true, + timeout: 30_000, + stdio: 'ignore' + }) + + return { ok: true } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error) + } + } +} + +/** + * True when a GPU child died with the #38216 breakpoint signature and we + * should one-shot relaunch with `--no-sandbox` before Chromium FATAL-exits. + */ +export function shouldRelaunchForGpuSandboxCrash(options: { + platform?: NodeJS.Platform | string + details?: { type?: string; exitCode?: number | string } | null + alreadyNoSandbox?: boolean + relaunchAttempted?: boolean +}): boolean { + if ((options.platform ?? process.platform) !== 'win32') { + return false + } + + if (options.alreadyNoSandbox || options.relaunchAttempted) { + return false + } + + const type = String(options.details?.type || '').toLowerCase() + + if (type !== 'gpu') { + return false + } + + return isWindowsSandboxBreakpointExit(options.details?.exitCode) +} + +export function buildNoSandboxRelaunchArgs(argv: readonly string[]): string[] { + const args = (Array.isArray(argv) ? argv : []).filter(arg => arg !== '--no-sandbox') + + args.push('--no-sandbox') + + return args +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index ed82dccc7f36..be886b366bb0 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2966,6 +2966,23 @@ function Install-Desktop { # =false) because enabling it drags in signtool -> winCodeSign -> the # unfixable symlink crash; the afterPack hook runs rcedit directly. + # 3c. Grant ALL APPLICATION PACKAGES (S-1-15-2-2) RX on the unpacked app + # directory. Chromium's GPU/renderer sandboxes CHECK-fail with + # 0x80000003 when this ACE is missing alongside orphan AppContainer + # SIDs under %LOCALAPPDATA% (electron/electron#51761, hermes-agent#38216). + # Best-effort — never fail an otherwise-good install over ACL repair. + try { + $appDir = Split-Path -Parent $desktopExe + & icacls $appDir /grant "*S-1-15-2-2:(OI)(CI)(RX)" /T /C /Q | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Success "Granted AppContainer read access on $appDir" + } else { + Write-Warn "icacls AppContainer grant returned exit $LASTEXITCODE for $appDir" + } + } catch { + Write-Warn "Could not grant AppContainer ACL: $($_.Exception.Message)" + } + # 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed # Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that # command rebuilds (npm install + electron-builder) on every launch,