mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user