diff --git a/src/main/app-relaunch.ts b/src/main/app-relaunch.ts index ab7a725c847..ebb3d340f45 100644 --- a/src/main/app-relaunch.ts +++ b/src/main/app-relaunch.ts @@ -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 diff --git a/src/main/crash-reporting/renderer-sandbox-crash-decision.test.ts b/src/main/crash-reporting/renderer-sandbox-crash-decision.test.ts new file mode 100644 index 00000000000..c67d20948e4 --- /dev/null +++ b/src/main/crash-reporting/renderer-sandbox-crash-decision.test.ts @@ -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) + }) +}) diff --git a/src/main/crash-reporting/renderer-sandbox-crash-decision.ts b/src/main/crash-reporting/renderer-sandbox-crash-decision.ts new file mode 100644 index 00000000000..839c0175cc2 --- /dev/null +++ b/src/main/crash-reporting/renderer-sandbox-crash-decision.ts @@ -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 +} diff --git a/src/main/index.ts b/src/main/index.ts index a24373bd647..ff5ca1b3b9b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = 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 = Promise.resolve() let localPtyProviderStartupReady: Promise = 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, diff --git a/src/main/startup/renderer-sandbox-fallback-marker.test.ts b/src/main/startup/renderer-sandbox-fallback-marker.test.ts new file mode 100644 index 00000000000..5b027912c67 --- /dev/null +++ b/src/main/startup/renderer-sandbox-fallback-marker.test.ts @@ -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() + }) +}) diff --git a/src/main/startup/renderer-sandbox-fallback-marker.ts b/src/main/startup/renderer-sandbox-fallback-marker.ts new file mode 100644 index 00000000000..0a04baa56f6 --- /dev/null +++ b/src/main/startup/renderer-sandbox-fallback-marker.ts @@ -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 + > + 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 +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 5b84fd1f020..5ec0d7f7784 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index e35cd24d71a..eb60d14712d 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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 } }) diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 0976a922e16..cb3c5f8fe2e 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -140,6 +140,7 @@ import { isDashboardPopoutRenderer, zoomDashboardPopoutIfFocused } from './dashboard-popout-window' +import { setRendererSandboxFallbackActive } from './renderer-sandbox-fallback-state' type FakeWindow = InstanceType @@ -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) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 32fb00008ff..fb77f10bd37 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -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 guests — so keep the diff --git a/src/main/window/renderer-sandbox-fallback-state.test.ts b/src/main/window/renderer-sandbox-fallback-state.test.ts new file mode 100644 index 00000000000..b1be2ea5108 --- /dev/null +++ b/src/main/window/renderer-sandbox-fallback-state.test.ts @@ -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) + }) +}) diff --git a/src/main/window/renderer-sandbox-fallback-state.ts b/src/main/window/renderer-sandbox-fallback-state.ts new file mode 100644 index 00000000000..2a1165eb30d --- /dev/null +++ b/src/main/window/renderer-sandbox-fallback-state.ts @@ -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 +}