fix(startup): recover Win11 renderer STATUS_BREAKPOINT launch crashes via build-scoped sandbox fallback (#9891)

On Windows 11 build 26200 the sandboxed renderer breakpoints (STATUS_BREAKPOINT
/ 0x80000003, exit -2147483645) ~2s after launch on nearly every start. The
existing GPU-child-crash fallback only disables hardware acceleration and cannot
fix a renderer *sandbox* fault, so the renderer-recovery circuit breaker reloads
the same crashing renderer 3x and dead-ends at "Orca keeps failing to load".

Add a build-scoped renderer sandbox fallback mirroring the GPU fallback marker
system: after 3 launch-window renderer STATUS_BREAKPOINT crashes on win32, persist
a marker and relaunch with the top-level renderer created unsandboxed
(webPreferences.sandbox:false) - the surgical, per-window equivalent of the
reporter-confirmed --no-sandbox, keeping GPU/utility/network and webview-guest
sandboxes intact. The marker is scoped to appVersion+electronVersion and
auto-clears on any update, so a fixed Electron build regains the sandbox
automatically.

Detection keys strictly on exit code -2147483645 so renderer OOM (#9872, -36861)
and other crashes never trigger the downgrade; the dashboard pop-out honors the
same flag so it does not breakpoint either. Worst case (sandbox:false still
crashes) degrades to today's dialog - never an infinite relaunch loop.

The fallback depends on per-window sandbox config: it is documented and guarded
(a durable renderer_sandbox_fallback_neutralized breadcrumb) that adding a global
app.enableSandbox() would ignore the per-window opt-out and neutralize the fix.
This commit is contained in:
OrcaWin
2026-07-22 15:37:31 -07:00
parent 7304776c20
commit cb489a0416
12 changed files with 702 additions and 5 deletions
+1
View File
@@ -8,6 +8,7 @@ export type AppRelaunchReason =
| 'profile-switch'
| 'profile-transfer'
| 'renderer-request'
| 'renderer-sandbox-fallback'
export function relaunchApp(reason: AppRelaunchReason, data?: CrashReportBreadcrumbData): void {
// Why: the current process can exit immediately after app.relaunch(), so
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_RENDERER_SANDBOX_FALLBACK_THRESHOLD,
DEFAULT_RENDERER_SANDBOX_FALLBACK_WINDOW_MS,
RendererSandboxCrashFallbackTracker,
STATUS_BREAKPOINT_EXIT_CODE,
isRendererSandboxFallbackCrashCandidate
} from './renderer-sandbox-crash-decision'
function makeTracker(overrides: { windowMs?: number; threshold?: number } = {}) {
return new RendererSandboxCrashFallbackTracker({
windowMs: overrides.windowMs ?? DEFAULT_RENDERER_SANDBOX_FALLBACK_WINDOW_MS,
threshold: overrides.threshold ?? DEFAULT_RENDERER_SANDBOX_FALLBACK_THRESHOLD
})
}
describe('RendererSandboxCrashFallbackTracker', () => {
it('engages once the threshold of in-window crashes is reached', () => {
const tracker = makeTracker({ threshold: 3 })
expect(tracker.recordRendererCrash(1_000)).toEqual({
shouldEngageFallback: false,
crashesInWindow: 1
})
expect(tracker.recordRendererCrash(3_000)).toEqual({
shouldEngageFallback: false,
crashesInWindow: 2
})
expect(tracker.recordRendererCrash(6_000)).toEqual({
shouldEngageFallback: true,
crashesInWindow: 3
})
expect(tracker.hasEngaged()).toBe(true)
})
it('ignores crashes outside the post-launch window', () => {
const tracker = makeTracker({ windowMs: 30_000, threshold: 2 })
expect(tracker.recordRendererCrash(30_001)).toEqual({
shouldEngageFallback: false,
crashesInWindow: 0
})
expect(tracker.recordRendererCrash(-1)).toEqual({
shouldEngageFallback: false,
crashesInWindow: 0
})
expect(tracker.recordRendererCrash(Number.NaN)).toEqual({
shouldEngageFallback: false,
crashesInWindow: 0
})
// A crash exactly on the window boundary still counts.
expect(tracker.recordRendererCrash(30_000).crashesInWindow).toBe(1)
})
it('engages at most once and stops counting afterwards', () => {
const tracker = makeTracker({ threshold: 2 })
tracker.recordRendererCrash(500)
expect(tracker.recordRendererCrash(1_000).shouldEngageFallback).toBe(true)
// Further crashes never re-engage, so the caller relaunches only once.
expect(tracker.recordRendererCrash(1_500)).toEqual({
shouldEngageFallback: false,
crashesInWindow: 2
})
})
})
describe('isRendererSandboxFallbackCrashCandidate', () => {
it('matches only a win32 renderer STATUS_BREAKPOINT exit', () => {
expect(
isRendererSandboxFallbackCrashCandidate({
platform: 'win32',
source: 'renderer',
exitCode: STATUS_BREAKPOINT_EXIT_CODE
})
).toBe(true)
})
it('rejects renderer OOM (#9872) and other renderer exit codes', () => {
expect(
isRendererSandboxFallbackCrashCandidate({
platform: 'win32',
source: 'renderer',
exitCode: -36861
})
).toBe(false)
expect(
isRendererSandboxFallbackCrashCandidate({
platform: 'win32',
source: 'renderer',
exitCode: 5
})
).toBe(false)
expect(
isRendererSandboxFallbackCrashCandidate({
platform: 'win32',
source: 'renderer',
exitCode: null
})
).toBe(false)
})
it('rejects GPU/child STATUS_BREAKPOINT and non-win32 platforms', () => {
expect(
isRendererSandboxFallbackCrashCandidate({
platform: 'win32',
source: 'child',
exitCode: STATUS_BREAKPOINT_EXIT_CODE
})
).toBe(false)
expect(
isRendererSandboxFallbackCrashCandidate({
platform: 'darwin',
source: 'renderer',
exitCode: STATUS_BREAKPOINT_EXIT_CODE
})
).toBe(false)
expect(
isRendererSandboxFallbackCrashCandidate({
platform: 'linux',
source: 'renderer',
exitCode: STATUS_BREAKPOINT_EXIT_CODE
})
).toBe(false)
})
})
@@ -0,0 +1,93 @@
export type RendererSandboxCrashFallbackOptions = {
/** Window after launch in which clustered renderer breakpoints indicate a sandbox-incompatible build. */
windowMs: number
/** Launch-window STATUS_BREAKPOINT renderer crashes that trigger the unsandboxed-renderer fallback. */
threshold: number
}
// Why: on Win11 build 26200 the sandboxed renderer breakpoints during startup
// (STATUS_BREAKPOINT / 0x80000003) within ~2s, repeatedly, on nearly every
// launch (#9891). Disabling hardware acceleration does not help - the renderer
// sandbox itself is unusable on this build - so a burst right after launch is
// the signal to relaunch with the top-level renderer unsandboxed.
// Why 60s (vs the GPU tracker's 30s): GPU child processes die pre-window, but
// the renderer breakpoints ~2s AFTER the window loads, so the launch burst can
// land later on a slow cold start. Matching the renderer-recovery breaker's 60s
// window means any breakpoint loop the breaker would react to is preempted by
// this fallback first, instead of dead-ending at the "keeps failing to load" dialog.
export const DEFAULT_RENDERER_SANDBOX_FALLBACK_WINDOW_MS = 60_000
export const DEFAULT_RENDERER_SANDBOX_FALLBACK_THRESHOLD = 3
// Windows STATUS_BREAKPOINT (0x80000003) as a signed 32-bit exit code. This is
// Chromium's generic CHECK/IMMEDIATE_CRASH code on Windows, not a sandbox-unique
// signature, so the fallback is best-effort and degrades safely if ineffective.
export const STATUS_BREAKPOINT_EXIT_CODE = -2147483645
/**
* Tracks launch-time renderer STATUS_BREAKPOINT crashes and decides when to
* relaunch this build with the top-level renderer unsandboxed. Pure and
* deterministic: callers pass `msSinceLaunch` so behavior is testable without
* timers, mirroring {@link GpuCrashFallbackTracker}.
*
* Only crashes inside the post-launch window count: a renderer that dies hours
* into a session is not a launch-time sandbox incompatibility.
*/
export class RendererSandboxCrashFallbackTracker {
private readonly windowMs: number
private readonly threshold: number
private crashesInWindow = 0
private engaged = false
constructor(options: RendererSandboxCrashFallbackOptions) {
this.windowMs = options.windowMs
this.threshold = options.threshold
}
/**
* Records a renderer breakpoint at `msSinceLaunch` and reports whether this
* crash just pushed the count over the threshold (i.e. fallback should engage
* now). Returns false for crashes outside the window or after fallback already
* engaged, so the caller relaunches at most once.
*/
recordRendererCrash(msSinceLaunch: number): {
shouldEngageFallback: boolean
crashesInWindow: number
} {
if (
this.engaged ||
!Number.isFinite(msSinceLaunch) ||
msSinceLaunch < 0 ||
msSinceLaunch > this.windowMs
) {
return { shouldEngageFallback: false, crashesInWindow: this.crashesInWindow }
}
this.crashesInWindow += 1
if (this.crashesInWindow >= this.threshold) {
this.engaged = true
return { shouldEngageFallback: true, crashesInWindow: this.crashesInWindow }
}
return { shouldEngageFallback: false, crashesInWindow: this.crashesInWindow }
}
hasEngaged(): boolean {
return this.engaged
}
}
/**
* True for the renderer crashes that should feed the sandbox-fallback tracker:
* a win32 top-level renderer that exited with STATUS_BREAKPOINT. Deliberately
* narrow so renderer OOM (#9872, exit -36861) and non-Windows crashes never trip
* the sandbox downgrade.
*/
export function isRendererSandboxFallbackCrashCandidate({
platform,
source,
exitCode
}: {
platform: NodeJS.Platform
source: 'renderer' | 'child'
exitCode: number | null
}): boolean {
return platform === 'win32' && source === 'renderer' && exitCode === STATUS_BREAKPOINT_EXIT_CODE
}
+105 -3
View File
@@ -90,6 +90,21 @@ import {
GpuCrashFallbackTracker,
isGpuFallbackCrashCandidate
} from './crash-reporting/gpu-crash-fallback-decision'
import {
readActiveRendererSandboxFallbackMarker,
writeRendererSandboxFallbackMarker
} from './startup/renderer-sandbox-fallback-marker'
import {
DEFAULT_RENDERER_SANDBOX_FALLBACK_THRESHOLD,
DEFAULT_RENDERER_SANDBOX_FALLBACK_WINDOW_MS,
RendererSandboxCrashFallbackTracker,
STATUS_BREAKPOINT_EXIT_CODE,
isRendererSandboxFallbackCrashCandidate
} from './crash-reporting/renderer-sandbox-crash-decision'
import {
isRendererSandboxFallbackActive,
setRendererSandboxFallbackActive
} from './window/renderer-sandbox-fallback-state'
import {
shouldSuppressDevEducation,
suppressDevEducationForStore
@@ -275,13 +290,18 @@ let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
// Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op).
let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled'
// Why: GPU child crashes clustered right after launch indicate a broken driver; track them to switch this build to software rendering.
const gpuLaunchTimeMs = Date.now()
// Why: GPU child crashes and renderer STATUS_BREAKPOINT crashes clustered right after launch indicate a broken driver or sandbox; both trackers measure from this shared launch time.
const appLaunchTimeMs = Date.now()
const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({
windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD
})
let gpuFallbackActiveThisLaunch = false
// Why: on Win11 build 26200 the sandboxed renderer breakpoints on launch (#9891); a burst means this build must run its top-level renderer unsandboxed.
const rendererSandboxCrashFallbackTracker = new RendererSandboxCrashFallbackTracker({
windowMs: DEFAULT_RENDERER_SANDBOX_FALLBACK_WINDOW_MS,
threshold: DEFAULT_RENDERER_SANDBOX_FALLBACK_THRESHOLD
})
let localPtyStartupReady: Promise<void> = Promise.resolve()
let localPtyProviderStartupReady: Promise<void> = Promise.resolve()
const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000
@@ -640,6 +660,7 @@ if (hasSingleInstanceLock) {
configureElectronNetworkCompatibility()
enableRendererHeapHeadroom()
maybeApplyGpuFallbackForThisLaunch()
maybeApplyRendererSandboxFallbackForThisLaunch()
if (!gpuFallbackActiveThisLaunch) {
enableMainProcessGpuFeatures()
}
@@ -1026,6 +1047,17 @@ function openMainWindow(): BrowserWindow {
},
webContentsId
)
// Why: a launch-time renderer STATUS_BREAKPOINT burst means this build's renderer can't run sandboxed (#9891); engage the build-scoped unsandboxed-renderer fallback before the recovery breaker dead-ends at the "keeps failing to load" dialog. Only genuine (teardown 'none') main-window crashes count.
if (
isRendererSandboxFallbackCrashCandidate({
platform: process.platform,
source: 'renderer',
exitCode: details.exitCode ?? null
}) &&
getExpectedTeardownScope(webContentsId) === 'none'
) {
handleRendererSandboxCrash(details.exitCode ?? null, details.reason)
}
},
shouldRecoverRenderer: (details, webContentsId) =>
shouldRecoverRendererAfterProcessGone({
@@ -1041,6 +1073,7 @@ function openMainWindow(): BrowserWindow {
void presentRendererRecoveryPrompt(recentRecoveryCount)
},
deferLoad: true,
disableRendererSandbox: isRendererSandboxFallbackActive(),
title: devInstanceIdentity.name,
getKeybindings: () => keybindings?.getOverrides(),
onBeforeReload: ({ ignoreCache, webContentsId }) => {
@@ -1368,7 +1401,7 @@ function handleGpuChildCrash(reason: string, exitCode: number | null): void {
if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) {
return
}
const result = gpuCrashFallbackTracker.recordGpuCrash(Date.now() - gpuLaunchTimeMs)
const result = gpuCrashFallbackTracker.recordGpuCrash(Date.now() - appLaunchTimeMs)
if (!result.shouldEngageFallback) {
return
}
@@ -1404,6 +1437,75 @@ function handleGpuChildCrash(reason: string, exitCode: number | null): void {
app.exit(0)
}
// Why: read the renderer-sandbox-fallback marker before app.whenReady() so the first BrowserWindow is created unsandboxed. Windows desktop only; sets no command-line switch — the effect is applied per-window in createMainWindow.
function maybeApplyRendererSandboxFallbackForThisLaunch(): void {
if (isServeMode || process.platform !== 'win32') {
return
}
const marker = readActiveRendererSandboxFallbackMarker(
app.getPath('userData'),
getGpuFallbackEnvironment()
)
if (!marker) {
return
}
setRendererSandboxFallbackActive(true)
recordCrashBreadcrumb('renderer_sandbox_fallback_applied', {
crashesInWindow: marker.crashesInWindow,
exitCode: marker.exitCode
})
// Why: per-window sandbox:false is silently ignored if the sandbox is forced globally (app.enableSandbox()/--enable-sandbox), which would send #9891 machines back into the crash loop. Surface that so it's diagnosable instead of invisible.
if (app.commandLine.hasSwitch('enable-sandbox')) {
recordDurableCrashBreadcrumb('renderer_sandbox_fallback_neutralized', {
crashesInWindow: marker.crashesInWindow
})
}
}
// Why: a burst of launch-time renderer STATUS_BREAKPOINT crashes means the sandboxed renderer is unusable on this build (#9891) — persist a build-scoped marker and relaunch with the top-level renderer unsandboxed, preempting the recovery breaker's dead-end dialog.
function handleRendererSandboxCrash(exitCode: number | null, reason: string): void {
// Already unsandboxed this launch, or shutting down: nothing more to do. If sandbox:false still crashes it degrades to the recovery-breaker dialog, never an infinite relaunch loop.
if (isRendererSandboxFallbackActive() || isQuitting || isServeMode) {
return
}
const result = rendererSandboxCrashFallbackTracker.recordRendererCrash(
Date.now() - appLaunchTimeMs
)
if (!result.shouldEngageFallback) {
return
}
const environment = getWindowsGpuFallbackEnvironment()
if (!environment) {
return
}
recordCrashBreadcrumb('renderer_sandbox_fallback_engaged', {
reason,
exitCode,
crashesInWindow: result.crashesInWindow
})
try {
writeRendererSandboxFallbackMarker(
app.getPath('userData'),
{
engagedAt: Date.now(),
crashesInWindow: result.crashesInWindow,
exitCode: exitCode ?? STATUS_BREAKPOINT_EXIT_CODE
},
environment
)
} catch (error) {
console.warn('[renderer-sandbox-fallback] failed to persist marker:', error)
return
}
isQuitting = true
relaunchApp('renderer-sandbox-fallback', {
processReason: reason,
exitCode,
crashesInWindow: result.crashesInWindow
})
app.exit(0)
}
function recordProcessGoneCrash(
source: 'renderer' | 'child',
processType: string,
@@ -0,0 +1,147 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
RENDERER_SANDBOX_FALLBACK_MARKER_FILE,
clearRendererSandboxFallbackMarker,
readActiveRendererSandboxFallbackMarker,
readRendererSandboxFallbackMarker,
writeRendererSandboxFallbackMarker
} from './renderer-sandbox-fallback-marker'
describe('renderer-sandbox-fallback-marker', () => {
let userDataPath: string
const environment = {
appVersion: '1.4.150',
electronVersion: '43.1.0',
platform: 'win32' as const
}
beforeEach(() => {
userDataPath = mkdtempSync(join(os.tmpdir(), 'orca-renderer-sandbox-fallback-test-'))
})
afterEach(() => {
rmSync(userDataPath, { recursive: true, force: true })
})
it('round-trips a written marker', () => {
writeRendererSandboxFallbackMarker(
userDataPath,
{ engagedAt: 123, crashesInWindow: 3, exitCode: -2147483645 },
environment
)
expect(readRendererSandboxFallbackMarker(userDataPath)).toEqual({
schemeVersion: 1,
engagedAt: 123,
crashesInWindow: 3,
exitCode: -2147483645,
appVersion: '1.4.150',
electronVersion: '43.1.0',
platform: 'win32'
})
})
it('returns null when no marker exists', () => {
expect(readRendererSandboxFallbackMarker(userDataPath)).toBeNull()
expect(readActiveRendererSandboxFallbackMarker(userDataPath, environment)).toBeNull()
})
it('keeps an active marker for repeated launches on the same build', () => {
writeRendererSandboxFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 3, exitCode: -2147483645 },
environment
)
expect(existsSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE))).toBe(true)
expect(
readActiveRendererSandboxFallbackMarker(userDataPath, environment)?.crashesInWindow
).toBe(3)
expect(existsSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE))).toBe(true)
expect(readActiveRendererSandboxFallbackMarker(userDataPath, environment)?.exitCode).toBe(
-2147483645
)
})
it('clears an active marker when the app or Electron build changes', () => {
writeRendererSandboxFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 3, exitCode: -2147483645 },
environment
)
expect(
readActiveRendererSandboxFallbackMarker(userDataPath, {
...environment,
appVersion: '1.4.151'
})
).toBeNull()
expect(existsSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE))).toBe(false)
writeRendererSandboxFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 3, exitCode: -2147483645 },
environment
)
expect(
readActiveRendererSandboxFallbackMarker(userDataPath, {
...environment,
electronVersion: '44.0.0'
})
).toBeNull()
expect(existsSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE))).toBe(false)
})
it('clears an active marker outside Windows', () => {
writeRendererSandboxFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 3, exitCode: -2147483645 },
environment
)
expect(
readActiveRendererSandboxFallbackMarker(userDataPath, { ...environment, platform: 'linux' })
).toBeNull()
expect(existsSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE))).toBe(false)
})
it('clears a corrupt or wrong-version marker', () => {
writeFileSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE), '{ not json')
expect(readRendererSandboxFallbackMarker(userDataPath)).toBeNull()
expect(readActiveRendererSandboxFallbackMarker(userDataPath, environment)).toBeNull()
expect(existsSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE))).toBe(false)
writeFileSync(
join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE),
JSON.stringify({ schemeVersion: 999, engagedAt: 1, crashesInWindow: 1, exitCode: -1 })
)
expect(readRendererSandboxFallbackMarker(userDataPath)).toBeNull()
expect(readActiveRendererSandboxFallbackMarker(userDataPath, environment)).toBeNull()
expect(existsSync(join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE))).toBe(false)
})
it('rejects a marker missing the exitCode field', () => {
writeFileSync(
join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE),
JSON.stringify({
schemeVersion: 1,
engagedAt: 1,
crashesInWindow: 3,
appVersion: '1.4.150',
electronVersion: '43.1.0',
platform: 'win32'
})
)
expect(readRendererSandboxFallbackMarker(userDataPath)).toBeNull()
})
it('can explicitly clear the marker', () => {
writeRendererSandboxFallbackMarker(
userDataPath,
{ engagedAt: 1, crashesInWindow: 3, exitCode: -2147483645 },
environment
)
clearRendererSandboxFallbackMarker(userDataPath)
expect(readRendererSandboxFallbackMarker(userDataPath)).toBeNull()
})
})
@@ -0,0 +1,126 @@
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
/**
* Persisted "run this build's top-level renderer unsandboxed" marker.
*
* Why a standalone file (not the Store): createMainWindow must pick
* webPreferences.sandbox for the first window, which happens before the settings
* Store is constructed inside whenReady. A tiny JSON marker in userData reads
* synchronously during early startup, mirroring gpu-fallback-marker.ts.
*/
export const RENDERER_SANDBOX_FALLBACK_MARKER_FILE = 'renderer-sandbox-fallback.json'
export const RENDERER_SANDBOX_FALLBACK_SCHEME_VERSION = 1
export type RendererSandboxFallbackEnvironment = {
appVersion: string
electronVersion: string
platform: NodeJS.Platform
}
export type WindowsRendererSandboxFallbackEnvironment = RendererSandboxFallbackEnvironment & {
platform: 'win32'
}
export type RendererSandboxFallbackMarker = {
schemeVersion: number
engagedAt: number
crashesInWindow: number
exitCode: number
appVersion: string
electronVersion: string
platform: 'win32'
}
function markerPath(userDataPath: string): string {
return join(userDataPath, RENDERER_SANDBOX_FALLBACK_MARKER_FILE)
}
export function readRendererSandboxFallbackMarker(
userDataPath: string
): RendererSandboxFallbackMarker | null {
try {
const parsed = JSON.parse(readFileSync(markerPath(userDataPath), 'utf-8')) as Partial<
Record<keyof RendererSandboxFallbackMarker, unknown>
>
if (parsed.schemeVersion !== RENDERER_SANDBOX_FALLBACK_SCHEME_VERSION) {
return null
}
if (
typeof parsed.engagedAt !== 'number' ||
!Number.isFinite(parsed.engagedAt) ||
typeof parsed.crashesInWindow !== 'number' ||
!Number.isFinite(parsed.crashesInWindow) ||
typeof parsed.exitCode !== 'number' ||
!Number.isFinite(parsed.exitCode) ||
typeof parsed.appVersion !== 'string' ||
typeof parsed.electronVersion !== 'string' ||
parsed.platform !== 'win32'
) {
return null
}
return {
schemeVersion: RENDERER_SANDBOX_FALLBACK_SCHEME_VERSION,
engagedAt: parsed.engagedAt,
crashesInWindow: parsed.crashesInWindow,
exitCode: parsed.exitCode,
appVersion: parsed.appVersion,
electronVersion: parsed.electronVersion,
platform: parsed.platform
}
} catch {
// missing or corrupt means no fallback requested
}
return null
}
export function writeRendererSandboxFallbackMarker(
userDataPath: string,
info: { engagedAt: number; crashesInWindow: number; exitCode: number },
environment: WindowsRendererSandboxFallbackEnvironment
): void {
const marker: RendererSandboxFallbackMarker = {
schemeVersion: RENDERER_SANDBOX_FALLBACK_SCHEME_VERSION,
engagedAt: info.engagedAt,
crashesInWindow: info.crashesInWindow,
exitCode: info.exitCode,
appVersion: environment.appVersion,
electronVersion: environment.electronVersion,
platform: 'win32'
}
writeFileSync(markerPath(userDataPath), JSON.stringify(marker))
}
export function clearRendererSandboxFallbackMarker(userDataPath: string): void {
try {
rmSync(markerPath(userDataPath), { force: true })
} catch {
// best effort; a stale marker is revalidated on the next launch
}
}
export function readActiveRendererSandboxFallbackMarker(
userDataPath: string,
environment: RendererSandboxFallbackEnvironment
): RendererSandboxFallbackMarker | null {
const marker = readRendererSandboxFallbackMarker(userDataPath)
if (!marker) {
if (existsSync(markerPath(userDataPath))) {
clearRendererSandboxFallbackMarker(userDataPath)
}
return null
}
if (
environment.platform !== 'win32' ||
marker.platform !== environment.platform ||
marker.appVersion !== environment.appVersion ||
marker.electronVersion !== environment.electronVersion
) {
// Why: the marker is sticky only for the build that observed the crash
// burst; updates get one fresh sandboxed attempt automatically.
clearRendererSandboxFallbackMarker(userDataPath)
return null
}
return marker
}
+44
View File
@@ -133,6 +133,50 @@ describe('createMainWindow', () => {
expect(browserWindowInstance.loadURL).not.toHaveBeenCalled()
})
it('unsandboxes the renderer only when the #9891 sandbox fallback is engaged', () => {
const webContents = {
on: vi.fn(),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isDevToolsOpened: vi.fn(),
openDevTools: vi.fn(),
closeDevTools: vi.fn()
}
const browserWindowInstance = {
webContents,
on: vi.fn(),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
show: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return browserWindowInstance
})
createMainWindow(null, { deferLoad: true, disableRendererSandbox: true })
expect(browserWindowMock).toHaveBeenCalledWith(
expect.objectContaining({
webPreferences: expect.objectContaining({ sandbox: false })
})
)
browserWindowMock.mockClear()
createMainWindow(null, { deferLoad: true, disableRendererSandbox: false })
expect(browserWindowMock).toHaveBeenCalledWith(
expect.objectContaining({
webPreferences: expect.objectContaining({ sandbox: true })
})
)
})
it('enables renderer sandboxing and opens external links safely', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
+5 -1
View File
@@ -157,6 +157,8 @@ type CreateMainWindowOptions = {
onBeforeReload?: (options: { ignoreCache: boolean; webContentsId: number }) => void
/** Marks the in-place recovery reload so did-finish-load's PTY orphan sweep spares live sessions until restore re-attaches (#5787). */
onBeforeRecoveryReload?: (webContentsId: number) => void
/** Run this window's top-level renderer unsandboxed (build-scoped #9891 fallback after repeated launch-time renderer STATUS_BREAKPOINT crashes); webview guests stay sandbox:true via will-attach-webview. */
disableRendererSandbox?: boolean
}
export function loadMainWindow(mainWindow: BrowserWindow): void {
@@ -247,7 +249,9 @@ export function createMainWindow(
...platformBlurOptions,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
// Why: keep the renderer sandboxed by default; only the build-scoped #9891 fallback unsandboxes this one window after repeated launch-time STATUS_BREAKPOINT crashes. Webview guests stay sandbox:true via will-attach-webview.
// NOTE: this per-window opt-out only works because Orca never calls app.enableSandbox() (global enforcement ignores per-window sandbox:false). See renderer-sandbox-fallback-state.ts before adding it.
sandbox: opts?.disableRendererSandbox !== true,
webviewTag: true
}
})
@@ -140,6 +140,7 @@ import {
isDashboardPopoutRenderer,
zoomDashboardPopoutIfFocused
} from './dashboard-popout-window'
import { setRendererSandboxFallbackActive } from './renderer-sandbox-fallback-state'
type FakeWindow = InstanceType<typeof BrowserWindowMock>
@@ -174,6 +175,8 @@ describe('createOrFocusDashboardPopout', () => {
beforeEach(() => {
instances.length = 0
isMock.dev = false
// Reset the shared #9891 launch flag so window-sandbox assertions are isolated.
setRendererSandboxFallbackActive(false)
vi.stubEnv('ELECTRON_RENDERER_URL', '')
getAllDisplaysMock.mockReturnValue([{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }])
})
@@ -185,6 +188,12 @@ describe('createOrFocusDashboardPopout', () => {
vi.clearAllMocks()
})
it('unsandboxes the pop-out renderer when the #9891 sandbox fallback is active', () => {
setRendererSandboxFallbackActive(true)
createOrFocusDashboardPopout(makeStore() as never)
expect(instances[0].options.webPreferences?.sandbox).toBe(false)
})
it('creates a native-frame window with the shared preload and no webview surface', () => {
createOrFocusDashboardPopout(makeStore() as never)
+3 -1
View File
@@ -5,6 +5,7 @@ import type { Store } from '../persistence'
import { rectHasVisibleAreaOnAnyDisplay } from './window-bounds-validation'
import { sendToTrustedUIRenderer } from '../ipc/ui'
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
import { isRendererSandboxFallbackActive } from './renderer-sandbox-fallback-state'
import { stepUIZoomLevel, type UIZoomDirection } from '../../shared/ui-zoom-level'
import { nativeZoomCommandMatchesKeybindings } from '../../shared/window-shortcut-policy'
import {
@@ -168,7 +169,8 @@ export function createOrFocusDashboardPopout(
// no such chrome yet, so a native frame is the correct default here.
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
// Why: keep the pop-out renderer sandboxed by default; honor the same build-scoped #9891 fallback as the main window so this companion renderer doesn't STATUS_BREAKPOINT on a sandbox-incompatible Windows build.
sandbox: !isRendererSandboxFallbackActive(),
// Why: Chromium shares zoom by origin; a separate in-memory session keeps pop-out zoom window-local.
partition: DASHBOARD_POPOUT_PARTITION,
// Why: the dashboard is plain DOM — no <webview> guests — so keep the
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
isRendererSandboxFallbackActive,
setRendererSandboxFallbackActive
} from './renderer-sandbox-fallback-state'
afterEach(() => {
setRendererSandboxFallbackActive(false)
})
describe('renderer-sandbox-fallback-state', () => {
it('defaults to inactive', () => {
expect(isRendererSandboxFallbackActive()).toBe(false)
})
it('reflects the last set value', () => {
setRendererSandboxFallbackActive(true)
expect(isRendererSandboxFallbackActive()).toBe(true)
setRendererSandboxFallbackActive(false)
expect(isRendererSandboxFallbackActive()).toBe(false)
})
})
@@ -0,0 +1,24 @@
// Why: the build-scoped #9891 fallback decision — run this launch's renderers
// unsandboxed after repeated launch-time STATUS_BREAKPOINT crashes — is resolved
// once before app.whenReady() and then read when creating each window. A shared
// flag lets every window creator (main window and the dashboard pop-out, which
// is spawned deep in an IPC handler) honor it without threading an option
// through every call site.
//
// IMPORTANT (security-relevant): the fallback works by creating those windows
// with webPreferences.sandbox:false, which Electron only honors PER-WINDOW when
// the app does NOT call app.enableSandbox(). Orca deliberately relies on
// per-window sandbox config so the default stays sandboxed and only the affected
// build's main/pop-out renderers opt out - webview guests and every other window
// remain sandboxed. Adding a global app.enableSandbox() would ignore the
// per-window opt-out entirely and silently neutralize this fallback (#9891
// machines would crash-loop again); don't, without a replacement.
let rendererSandboxFallbackActive = false
export function setRendererSandboxFallbackActive(value: boolean): void {
rendererSandboxFallbackActive = value
}
export function isRendererSandboxFallbackActive(): boolean {
return rendererSandboxFallbackActive
}