diff --git a/dsh-desktop-next/src/main.ts b/dsh-desktop-next/src/main.ts index 035b0f5c11..88ec7e710c 100644 --- a/dsh-desktop-next/src/main.ts +++ b/dsh-desktop-next/src/main.ts @@ -57,7 +57,7 @@ import { manualUpdateHint, releaseNotesUrl, updateLabel } from './update-state.t import { DESKTOP_SHUTDOWN_TIMEOUT_MS } from '../../dsh-plugin-desktop/src/shutdown.ts' import { isDesktopRendererStatus, NextRendererRecovery, type DesktopNavigationCause } from './renderer-recovery.ts' import { deElevateAtStartup, lowIntegrityAtStartup, windowsElevation } from './windows-elevation.ts' -import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure } from './sandbox-launch.ts' +import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure, DISABLE_GPU_SANDBOX_ARGUMENT, gpuSandboxBreakpoint, gpuSandboxFallbackArguments } from './sandbox-launch.ts' import { pickOutsideProgramDirectory, programDirectory, programDirectoryRefusal, refusedProgramDirectoryOverlap, type ProgramDirectoryOverlap, type ProgramDirectoryPurpose } from './program-directory.ts' import { startCrashReporting } from './crash-reporting.ts' @@ -1577,6 +1577,20 @@ if (ownsInstance) { if (details.reason !== 'clean-exit' && !quitting) runtime.memory.incident({ process: 'child', type: details.type, reason: details.reason, exitCode: details.exitCode }) const failure = gpuLaunchFailure(process.platform, details) if (failure && !quitting) reportSandboxLaunchFailure(failure) + // A GPU child that dies with STATUS_BREAKPOINT (0x80000003) leaves Chromium no working GPU mode, and the + // browser process ends before any window exists. Relaunch once with only the GPU sandbox disabled; the flag + // stays on the command line, so a second failure is not retried into a loop. + if (!quitting && gpuSandboxBreakpoint(process.platform, details)) { + const fallback = gpuSandboxFallbackArguments(process.argv.slice(1)) + if (fallback !== undefined) { + quitting = true + runtime.diagnostics.record({ source: 'electron', event: 'sandbox.gpu-breakpoint-fallback', level: 'warn', + fields: { exitCode: details.exitCode, argument: DISABLE_GPU_SANDBOX_ARGUMENT } }) + runtime.diagnostics.flush() + relaunchApp(app, fallback) + app.exit(0) + } + } }) app.on('render-process-gone', (_event, contents, details) => { const failure = rendererLaunchFailure(process.platform, details) diff --git a/dsh-desktop-next/src/sandbox-launch.ts b/dsh-desktop-next/src/sandbox-launch.ts index a086d3515d..d5c8dd96de 100644 --- a/dsh-desktop-next/src/sandbox-launch.ts +++ b/dsh-desktop-next/src/sandbox-launch.ts @@ -22,6 +22,12 @@ export const SBOX_ERROR_CREATE_PROCESS = 18 /** Startup outcomes in which a restart without administrator rights was attempted and the launch stayed elevated. */ const RESTART_FAILURES = new Set(['no-shell', 'shell-elevated', 'shell-other-user', 'restarted']) +/** Windows `STATUS_BREAKPOINT`: a sandboxed child died before it could report an exit. */ +export const STATUS_BREAKPOINT_EXIT = -2147483645 + +/** Chromium switch that leaves the GPU process unsandboxed while every other child stays sandboxed. */ +export const DISABLE_GPU_SANDBOX_ARGUMENT = '--disable-gpu-sandbox' + /** * Recognize a failed GPU process launch in Electron's `child-process-gone` details. * @param platform - Node platform. @@ -32,6 +38,34 @@ export function gpuLaunchFailure(platform: NodeJS.Platform, details: { type: str return platform === 'win32' && details.type === 'GPU' && details.reason === 'launch-failed' ? { process: 'gpu', code: details.exitCode } : undefined } +/** + * Recognize a sandboxed GPU child that died with `STATUS_BREAKPOINT` on Windows. + * + * Electron reports this as `reason: 'crashed'`, `exitCode` 0x80000003 rather than a sandbox launch result, so + * {@link gpuLaunchFailure} deliberately ignores it. Chromium then retries every GPU mode and ends the browser + * process ("GPU process isn't usable. Goodbye."), which is why the user only sees that nothing opens (#1487, + * #1323). It is known to happen on Windows 11 26H2 builds 26200+ (electron/electron#54614) and when an ancestor + * of the install directory carries an AppContainer package-SID ACE (#1372). `--disable-gpu-sandbox` is the + * documented workaround for the first cause and keeps every other child sandboxed. + * + * @param platform - Node platform. + * @param details - Electron child process details. + * @returns whether a GPU child died with the breakpoint status. + */ +export function gpuSandboxBreakpoint(platform: NodeJS.Platform, details: { type: string; reason: string; exitCode: number }): boolean { + return platform === 'win32' && details.type === 'GPU' && details.reason === 'crashed' && details.exitCode === STATUS_BREAKPOINT_EXIT +} + +/** + * Command line for the fallback relaunch: keep this run's arguments and append the switch. + * @param argv - argument list without the executable. + * @returns the relaunch arguments, or undefined when the switch was already requested, where a second relaunch + * would only reproduce the crash. + */ +export function gpuSandboxFallbackArguments(argv: readonly string[]): string[] | undefined { + return argv.includes(DISABLE_GPU_SANDBOX_ARGUMENT) ? undefined : [...argv, DISABLE_GPU_SANDBOX_ARGUMENT] +} + /** * Recognize a failed renderer launch in Electron's `render-process-gone` details. * @param platform - Node platform. diff --git a/dsh-desktop-next/tests/main-lifecycle.spec.ts b/dsh-desktop-next/tests/main-lifecycle.spec.ts index 76444bdbef..785f003382 100644 --- a/dsh-desktop-next/tests/main-lifecycle.spec.ts +++ b/dsh-desktop-next/tests/main-lifecycle.spec.ts @@ -658,7 +658,6 @@ it('explains the first Windows GPU launch failure in a native dialog, records it app.emit('child-process-gone', {}, gpu) Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }) app.emit('child-process-gone', {}, { type: 'Utility', reason: 'launch-failed', exitCode: 18, serviceName: 'audio.mojom.AudioService' }) - app.emit('child-process-gone', {}, { ...gpu, reason: 'crashed', exitCode: -2147483645 }) expect(dialog.showMessageBoxSync).not.toHaveBeenCalled() app.emit('child-process-gone', {}, gpu) app.emit('child-process-gone', {}, gpu) @@ -711,6 +710,33 @@ it('starts local-only Crashpad before any window and turns renderer and child fa } finally { vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }) } }) +it('relaunches once with only the GPU sandbox disabled after a Windows GPU breakpoint', async () => { + const home = mkdtempSync(join(tmpdir(), 'next-sandbox-breakpoint-')) + vi.stubEnv('DSH_DESKTOP_NEXT_HOME', home) + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + try { + await import('../src/main.ts') + await vi.waitFor(() => expect(fixture.windows).toHaveLength(1)) + const { app, dialog } = await import('electron') + Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }) + const breakpoint = { type: 'GPU', reason: 'crashed', exitCode: -2147483645 } + app.emit('child-process-gone', {}, breakpoint) + await vi.waitFor(() => expect(app.relaunch).toHaveBeenCalledOnce()) + expect(vi.mocked(app.relaunch).mock.calls[0]![0]!.args).toContain('--disable-gpu-sandbox') + expect(fixture.diagnosticAppend).toHaveBeenCalledWith(expect.objectContaining({ + source: 'electron', event: 'sandbox.gpu-breakpoint-fallback', level: 'warn', + fields: { exitCode: -2147483645, argument: '--disable-gpu-sandbox' }, + })) + expect(app.exit).toHaveBeenCalledWith(0) + expect(dialog.showMessageBoxSync).not.toHaveBeenCalled() + // The relaunched process carries the switch, so a second failure cannot restart in a loop. + app.emit('child-process-gone', {}, breakpoint) + expect(app.relaunch).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(process, 'platform', platform) + vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }) + }}) + it('explains a Windows renderer launch failure only for the Desktop windows', async () => { const home = mkdtempSync(join(tmpdir(), 'next-sandbox-renderer-')) vi.stubEnv('DSH_DESKTOP_NEXT_HOME', home) diff --git a/dsh-desktop-next/tests/sandbox-launch.spec.ts b/dsh-desktop-next/tests/sandbox-launch.spec.ts index 4bdb878465..cb9e42d7a9 100644 --- a/dsh-desktop-next/tests/sandbox-launch.spec.ts +++ b/dsh-desktop-next/tests/sandbox-launch.spec.ts @@ -1,6 +1,6 @@ /** Native explanation of Windows sandbox launch failures. */ import { describe, expect, it, vi } from 'vitest' -import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure, sandboxLaunchDialog, type SandboxLaunchContext } from '../src/sandbox-launch.ts' +import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure, sandboxLaunchDialog, type SandboxLaunchContext, DISABLE_GPU_SANDBOX_ARGUMENT, gpuSandboxBreakpoint, gpuSandboxFallbackArguments, STATUS_BREAKPOINT_EXIT } from '../src/sandbox-launch.ts' const EXECUTABLE = 'E:\\Program Files\\DSH NEXT\\DSH NEXT.exe' const GPU = { process: 'gpu', code: 18 } as const @@ -121,3 +121,20 @@ describe('lowIntegrityLaunchDialog', () => { expect(text.detail).not.toContain('icacls') }) }) + +describe('gpu sandbox breakpoint fallback', () => { + it('recognizes only a Windows GPU child that died with STATUS_BREAKPOINT', () => { + const gpu = { type: 'GPU', reason: 'crashed', exitCode: STATUS_BREAKPOINT_EXIT } + expect(gpuSandboxBreakpoint('win32', gpu)).toBe(true) + expect(gpuSandboxBreakpoint('darwin', gpu)).toBe(false) + expect(gpuSandboxBreakpoint('win32', { ...gpu, reason: 'launch-failed' })).toBe(false) + expect(gpuSandboxBreakpoint('win32', { ...gpu, exitCode: 18 })).toBe(false) + expect(gpuSandboxBreakpoint('win32', { ...gpu, type: 'Utility' })).toBe(false) + }) + + it('appends the GPU sandbox switch once and keeps the rest of the command line', () => { + expect(gpuSandboxFallbackArguments(['--next-channel'])).toEqual(['--next-channel', DISABLE_GPU_SANDBOX_ARGUMENT]) + expect(gpuSandboxFallbackArguments([])).toEqual([DISABLE_GPU_SANDBOX_ARGUMENT]) + expect(gpuSandboxFallbackArguments([DISABLE_GPU_SANDBOX_ARGUMENT])).toBeUndefined() + expect(gpuSandboxFallbackArguments(['--next-channel', DISABLE_GPU_SANDBOX_ARGUMENT])).toBeUndefined() }) +}) diff --git a/dsh-plugin-desktop-beta/src/main.ts b/dsh-plugin-desktop-beta/src/main.ts index 4f84141df8..e2d96213e7 100644 --- a/dsh-plugin-desktop-beta/src/main.ts +++ b/dsh-plugin-desktop-beta/src/main.ts @@ -57,7 +57,7 @@ import { manualUpdateHint, releaseNotesUrl, updateLabel } from './update-state.t import { DESKTOP_SHUTDOWN_TIMEOUT_MS } from '../../dsh-plugin-desktop/src/shutdown.ts' import { isDesktopRendererStatus, NextRendererRecovery, type DesktopNavigationCause } from './renderer-recovery.ts' import { deElevateAtStartup, lowIntegrityAtStartup, windowsElevation } from './windows-elevation.ts' -import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure } from './sandbox-launch.ts' +import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure, DISABLE_GPU_SANDBOX_ARGUMENT, gpuSandboxBreakpoint, gpuSandboxFallbackArguments } from './sandbox-launch.ts' import { pickOutsideProgramDirectory, programDirectory, programDirectoryRefusal, refusedProgramDirectoryOverlap, type ProgramDirectoryOverlap, type ProgramDirectoryPurpose } from './program-directory.ts' import { startCrashReporting } from './crash-reporting.ts' @@ -1577,6 +1577,20 @@ if (ownsInstance) { if (details.reason !== 'clean-exit' && !quitting) runtime.memory.incident({ process: 'child', type: details.type, reason: details.reason, exitCode: details.exitCode }) const failure = gpuLaunchFailure(process.platform, details) if (failure && !quitting) reportSandboxLaunchFailure(failure) + // A GPU child that dies with STATUS_BREAKPOINT (0x80000003) leaves Chromium no working GPU mode, and the + // browser process ends before any window exists. Relaunch once with only the GPU sandbox disabled; the flag + // stays on the command line, so a second failure is not retried into a loop. + if (!quitting && gpuSandboxBreakpoint(process.platform, details)) { + const fallback = gpuSandboxFallbackArguments(process.argv.slice(1)) + if (fallback !== undefined) { + quitting = true + runtime.diagnostics.record({ source: 'electron', event: 'sandbox.gpu-breakpoint-fallback', level: 'warn', + fields: { exitCode: details.exitCode, argument: DISABLE_GPU_SANDBOX_ARGUMENT } }) + runtime.diagnostics.flush() + relaunchApp(app, fallback) + app.exit(0) + } + } }) app.on('render-process-gone', (_event, contents, details) => { const failure = rendererLaunchFailure(process.platform, details) diff --git a/dsh-plugin-desktop-beta/src/sandbox-launch.ts b/dsh-plugin-desktop-beta/src/sandbox-launch.ts index 43e1c29463..357db11d8d 100644 --- a/dsh-plugin-desktop-beta/src/sandbox-launch.ts +++ b/dsh-plugin-desktop-beta/src/sandbox-launch.ts @@ -22,6 +22,12 @@ export const SBOX_ERROR_CREATE_PROCESS = 18 /** Startup outcomes in which a restart without administrator rights was attempted and the launch stayed elevated. */ const RESTART_FAILURES = new Set(['no-shell', 'shell-elevated', 'shell-other-user', 'restarted']) +/** Windows `STATUS_BREAKPOINT`: a sandboxed child died before it could report an exit. */ +export const STATUS_BREAKPOINT_EXIT = -2147483645 + +/** Chromium switch that leaves the GPU process unsandboxed while every other child stays sandboxed. */ +export const DISABLE_GPU_SANDBOX_ARGUMENT = '--disable-gpu-sandbox' + /** * Recognize a failed GPU process launch in Electron's `child-process-gone` details. * @param platform - Node platform. @@ -32,6 +38,34 @@ export function gpuLaunchFailure(platform: NodeJS.Platform, details: { type: str return platform === 'win32' && details.type === 'GPU' && details.reason === 'launch-failed' ? { process: 'gpu', code: details.exitCode } : undefined } +/** + * Recognize a sandboxed GPU child that died with `STATUS_BREAKPOINT` on Windows. + * + * Electron reports this as `reason: 'crashed'`, `exitCode` 0x80000003 rather than a sandbox launch result, so + * {@link gpuLaunchFailure} deliberately ignores it. Chromium then retries every GPU mode and ends the browser + * process ("GPU process isn't usable. Goodbye."), which is why the user only sees that nothing opens (#1487, + * #1323). It is known to happen on Windows 11 26H2 builds 26200+ (electron/electron#54614) and when an ancestor + * of the install directory carries an AppContainer package-SID ACE (#1372). `--disable-gpu-sandbox` is the + * documented workaround for the first cause and keeps every other child sandboxed. + * + * @param platform - Node platform. + * @param details - Electron child process details. + * @returns whether a GPU child died with the breakpoint status. + */ +export function gpuSandboxBreakpoint(platform: NodeJS.Platform, details: { type: string; reason: string; exitCode: number }): boolean { + return platform === 'win32' && details.type === 'GPU' && details.reason === 'crashed' && details.exitCode === STATUS_BREAKPOINT_EXIT +} + +/** + * Command line for the fallback relaunch: keep this run's arguments and append the switch. + * @param argv - argument list without the executable. + * @returns the relaunch arguments, or undefined when the switch was already requested, where a second relaunch + * would only reproduce the crash. + */ +export function gpuSandboxFallbackArguments(argv: readonly string[]): string[] | undefined { + return argv.includes(DISABLE_GPU_SANDBOX_ARGUMENT) ? undefined : [...argv, DISABLE_GPU_SANDBOX_ARGUMENT] +} + /** * Recognize a failed renderer launch in Electron's `render-process-gone` details. * @param platform - Node platform. diff --git a/dsh-plugin-desktop-beta/tests/main-lifecycle.spec.ts b/dsh-plugin-desktop-beta/tests/main-lifecycle.spec.ts index e3a350f00a..7236393c2c 100644 --- a/dsh-plugin-desktop-beta/tests/main-lifecycle.spec.ts +++ b/dsh-plugin-desktop-beta/tests/main-lifecycle.spec.ts @@ -658,7 +658,6 @@ it('explains the first Windows GPU launch failure in a native dialog, records it app.emit('child-process-gone', {}, gpu) Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }) app.emit('child-process-gone', {}, { type: 'Utility', reason: 'launch-failed', exitCode: 18, serviceName: 'audio.mojom.AudioService' }) - app.emit('child-process-gone', {}, { ...gpu, reason: 'crashed', exitCode: -2147483645 }) expect(dialog.showMessageBoxSync).not.toHaveBeenCalled() app.emit('child-process-gone', {}, gpu) app.emit('child-process-gone', {}, gpu) @@ -711,6 +710,33 @@ it('starts local-only Crashpad before any window and turns renderer and child fa } finally { vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }) } }) +it('relaunches once with only the GPU sandbox disabled after a Windows GPU breakpoint', async () => { + const home = mkdtempSync(join(tmpdir(), 'next-sandbox-breakpoint-')) + vi.stubEnv('DSH_DESKTOP_NEXT_HOME', home) + const platform = Object.getOwnPropertyDescriptor(process, 'platform')! + try { + await import('../src/main.ts') + await vi.waitFor(() => expect(fixture.windows).toHaveLength(1)) + const { app, dialog } = await import('electron') + Object.defineProperty(process, 'platform', { ...platform, value: 'win32' }) + const breakpoint = { type: 'GPU', reason: 'crashed', exitCode: -2147483645 } + app.emit('child-process-gone', {}, breakpoint) + await vi.waitFor(() => expect(app.relaunch).toHaveBeenCalledOnce()) + expect(vi.mocked(app.relaunch).mock.calls[0]![0]!.args).toContain('--disable-gpu-sandbox') + expect(fixture.diagnosticAppend).toHaveBeenCalledWith(expect.objectContaining({ + source: 'electron', event: 'sandbox.gpu-breakpoint-fallback', level: 'warn', + fields: { exitCode: -2147483645, argument: '--disable-gpu-sandbox' }, + })) + expect(app.exit).toHaveBeenCalledWith(0) + expect(dialog.showMessageBoxSync).not.toHaveBeenCalled() + // The relaunched process carries the switch, so a second failure cannot restart in a loop. + app.emit('child-process-gone', {}, breakpoint) + expect(app.relaunch).toHaveBeenCalledOnce() + } finally { + Object.defineProperty(process, 'platform', platform) + vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }) + }}) + it('explains a Windows renderer launch failure only for the Desktop windows', async () => { const home = mkdtempSync(join(tmpdir(), 'next-sandbox-renderer-')) vi.stubEnv('DSH_DESKTOP_NEXT_HOME', home) diff --git a/dsh-plugin-desktop-beta/tests/sandbox-launch.spec.ts b/dsh-plugin-desktop-beta/tests/sandbox-launch.spec.ts index c11740f28f..b45c1fdf76 100644 --- a/dsh-plugin-desktop-beta/tests/sandbox-launch.spec.ts +++ b/dsh-plugin-desktop-beta/tests/sandbox-launch.spec.ts @@ -1,6 +1,6 @@ /** Native explanation of Windows sandbox launch failures. */ import { describe, expect, it, vi } from 'vitest' -import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure, sandboxLaunchDialog, type SandboxLaunchContext } from '../src/sandbox-launch.ts' +import { createSandboxLaunchReporter, gpuLaunchFailure, lowIntegrityLaunchDialog, rendererLaunchFailure, sandboxLaunchDialog, type SandboxLaunchContext, DISABLE_GPU_SANDBOX_ARGUMENT, gpuSandboxBreakpoint, gpuSandboxFallbackArguments, STATUS_BREAKPOINT_EXIT } from '../src/sandbox-launch.ts' const EXECUTABLE = 'E:\\Program Files\\DSH Desktop Beta\\DSH Desktop Beta.exe' const GPU = { process: 'gpu', code: 18 } as const @@ -121,3 +121,20 @@ describe('lowIntegrityLaunchDialog', () => { expect(text.detail).not.toContain('icacls') }) }) + +describe('gpu sandbox breakpoint fallback', () => { + it('recognizes only a Windows GPU child that died with STATUS_BREAKPOINT', () => { + const gpu = { type: 'GPU', reason: 'crashed', exitCode: STATUS_BREAKPOINT_EXIT } + expect(gpuSandboxBreakpoint('win32', gpu)).toBe(true) + expect(gpuSandboxBreakpoint('darwin', gpu)).toBe(false) + expect(gpuSandboxBreakpoint('win32', { ...gpu, reason: 'launch-failed' })).toBe(false) + expect(gpuSandboxBreakpoint('win32', { ...gpu, exitCode: 18 })).toBe(false) + expect(gpuSandboxBreakpoint('win32', { ...gpu, type: 'Utility' })).toBe(false) + }) + + it('appends the GPU sandbox switch once and keeps the rest of the command line', () => { + expect(gpuSandboxFallbackArguments(['--next-channel'])).toEqual(['--next-channel', DISABLE_GPU_SANDBOX_ARGUMENT]) + expect(gpuSandboxFallbackArguments([])).toEqual([DISABLE_GPU_SANDBOX_ARGUMENT]) + expect(gpuSandboxFallbackArguments([DISABLE_GPU_SANDBOX_ARGUMENT])).toBeUndefined() + expect(gpuSandboxFallbackArguments(['--next-channel', DISABLE_GPU_SANDBOX_ARGUMENT])).toBeUndefined() }) +})