fix(windows): repair the install-dir package ACL that blanks the window (#17740)

* fix(windows): repair the install-dir package ACL that blanks the window

An install tree carrying an orphan AppContainer ACE (S-1-15-2-<x>) with no
ALL RESTRICTED APPLICATION PACKAGES grant denies Chromium's LPAC children read
on the shipped modules; they die at init with 0x80000003 and the window stays
blank forever (electron/electron#51761).

- Tighten the probe verdict to require the S-1-15-2-2 grant specifically: an
  ALL APPLICATION PACKAGES (S-1-15-2-1) ACE, the Program Files default, does
  not appear in an LPAC token and cannot satisfy the orphan.
- Drop BUILTIN from the English-locale heuristic (fr-FR/es-ES print it
  verbatim) so a localized icacls is correctly reported as un-name-checkable.
- Add an additive, marker-guarded icacls self-repair: an inheritable root
  grant plus a flagless (RX) /T pass, never /grant:r.
- Route the crash-loop dialog through a testable prompt module that names the
  permission cause, offers Copy Commands without dismissing itself, and keeps
  the graphics-driver hint.

The repair only runs on win32, off serve mode, and only on the exact probe
verdict that reproduced the crash.

* docs(windows): correct the install-tree ACL walk cost model

* fix(windows): keep the install-ACL poison gate at the reproduced shape

An orphan package ACE alongside the Program Files ALL APPLICATION
PACKAGES default launches clean on win32 10.0.26200 / Electron 43.4.1,
so requiring S-1-15-2-2 specifically declared poison on healthy installs
- and this branch acts on that verdict with a tree-wide icacls write and
the crash-recovery dialog's primary cause. hasRestrictedPackageGrant
stays reported for triage; only the verdict reverts.
This commit is contained in:
Neil
2026-08-31 21:15:19 -07:00
committed by GitHub
parent abe1d30881
commit 28373fcea7
11 changed files with 1221 additions and 85 deletions
+39 -26
View File
@@ -6,6 +6,7 @@ import os from 'node:os'
import {
app,
BrowserWindow,
clipboard,
dialog,
ipcMain,
nativeTheme,
@@ -230,6 +231,11 @@ import {
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe'
import {
describeInstallDirAclPoison,
startWindowsInstallDirAclRepairIfPoisoned
} from './startup/windows-install-dir-acl-recovery'
import { presentRendererRecoveryPrompt } from './window/renderer-recovery-prompt'
import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import { registerServeSignalHandlers } from './startup/serve-signal-handlers'
@@ -1539,7 +1545,15 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
})
// Why here: read-only, and the install DACL is the one thing a 0x80000003
// child death cannot tell us about itself. See electron/electron#51761.
probeWindowsInstallDirAcl({ isServeMode })
probeWindowsInstallDirAcl({
isServeMode,
onDone: (data) =>
startWindowsInstallDirAclRepairIfPoisoned(data, {
isServeMode,
userDataPath: app.getPath('userData'),
appVersion: app.getVersion()
})
})
}
const window = createMainWindow(store, {
@@ -1571,7 +1585,7 @@ function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): Brow
exitCode: details.exitCode ?? null,
recentRecoveryCount
})
void presentRendererRecoveryPrompt(recentRecoveryCount)
void showRendererRecoveryPrompt(recentRecoveryCount)
},
deferLoad: true,
...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}),
@@ -1861,30 +1875,29 @@ function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void {
}
// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface.
async function presentRendererRecoveryPrompt(recentRecoveryCount: number): Promise<void> {
if (isQuitting) {
return
}
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
const options = {
type: 'error' as const,
buttons: ['Reload', 'Quit'],
defaultId: 0,
cancelId: 1,
title: 'Orca keeps failing to load',
message: 'The app window crashed repeatedly and stopped reloading automatically.',
detail: `Orca tried to recover ${recentRecoveryCount} times in a row without success. This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.`
}
const { response } = window
? await dialog.showMessageBox(window, options)
: await dialog.showMessageBox(options)
if (response === 0 && mainWindow && !mainWindow.isDestroyed()) {
recordDurableCrashBreadcrumb('renderer_recovery_manual_retry')
loadMainWindow(mainWindow)
} else if (response === 1) {
isQuitting = true
app.quit()
}
async function showRendererRecoveryPrompt(recentRecoveryCount: number): Promise<void> {
await presentRendererRecoveryPrompt({
recentRecoveryCount,
isQuitting: () => isQuitting,
diagnose: describeInstallDirAclPoison,
showMessageBox: (options) => {
const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined
return window ? dialog.showMessageBox(window, options) : dialog.showMessageBox(options)
},
copyToClipboard: (text) => clipboard.writeText(text),
reload: () => {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
recordDurableCrashBreadcrumb('renderer_recovery_manual_retry')
// Why: leave the breaker open so a re-crash re-raises this prompt instead of resuming the auto-reload loop.
loadMainWindow(mainWindow)
},
quit: () => {
isQuitting = true
app.quit()
}
})
}
function getGpuFallbackEnvironment(): GpuFallbackEnvironment {
@@ -1,4 +1,3 @@
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import {
@@ -7,55 +6,26 @@ import {
WINDOWS_INSTALL_DIR_ACL_BREADCRUMB,
type WindowsInstallDirAclProbeOptions
} from './windows-install-dir-acl-probe'
import {
ALL_PACKAGES_ACE,
ENGLISH_BASELINE_ACES,
fakeIcaclsSpawn,
FRENCH_BASELINE_ACES,
FRENCH_RESTRICTED_PACKAGES_ACE,
icaclsDacl,
ORPHAN_PACKAGE_ACE,
RESTRICTED_PACKAGES_ACE
} from './windows-install-dir-acl.test-fixture'
const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca'
const SYSTEM_ACES = [
' NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)',
' BUILTIN\\Administrators:(I)(OI)(CI)(F)',
' awin\\neil:(I)(OI)(CI)(F)'
]
const ORPHAN = ORPHAN_PACKAGE_ACE
const RESTRICTED_GRANT = RESTRICTED_PACKAGES_ACE
/** Real icacls shape: the echoed path is glued onto the first principal. */
function dacl(target: string, firstAce: string, ...rest: string[]): string {
return [
`${target} ${firstAce}`,
...rest,
...SYSTEM_ACES,
'',
'Successfully processed 1 files'
].join('\r\n')
return icaclsDacl(target, [firstAce, ...rest])
}
const ORPHAN = 'S-1-15-2-999-999-999:(OI)(CI)(RX)'
const RESTRICTED_GRANT =
'APPLICATION PACKAGE AUTHORITY\\ALL RESTRICTED APPLICATION PACKAGES:(OI)(CI)(RX)'
function fakeSpawn(output: (target: string) => string | null): {
spawnFn: WindowsInstallDirAclProbeOptions['spawnFn']
calls: { file: string; args: string[] }[]
} {
const calls: { file: string; args: string[] }[] = []
const spawnFn = ((file: string, args: string[]) => {
calls.push({ file, args })
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
kill: () => void
}
child.stdout = new EventEmitter()
child.kill = () => undefined
const out = output(args[0])
setImmediate(() => {
if (out === null) {
child.emit('error', new Error('ENOENT'))
return
}
child.stdout.emit('data', Buffer.from(out, 'utf-8'))
child.emit('close', 0)
})
return child
}) as unknown as WindowsInstallDirAclProbeOptions['spawnFn']
return { spawnFn, calls }
}
const fakeSpawn = fakeIcaclsSpawn
function probe(options: WindowsInstallDirAclProbeOptions): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
@@ -84,9 +54,7 @@ describe('probeWindowsInstallDirAcl', () => {
it('reports a clean DACL as unpoisoned', async () => {
const data = await probe({
spawnFn: fakeSpawn((target) =>
[`${target} NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)`, ...SYSTEM_ACES.slice(1)].join('\r\n')
).spawnFn
spawnFn: fakeSpawn((target) => icaclsDacl(target, [], ENGLISH_BASELINE_ACES)).spawnFn
})
expect(data.name).toBe(WINDOWS_INSTALL_DIR_ACL_BREADCRUMB)
expect(data.status).toBe('ok')
@@ -102,13 +70,27 @@ describe('probeWindowsInstallDirAcl', () => {
expect(data.hasWellKnownPackageGrant).toBe(false)
})
it('clears the signature once a well-known package ACE grants access', async () => {
it('clears the signature once the restricted package ACE grants access', async () => {
const data = await probeWith(RESTRICTED_GRANT, ORPHAN)
expect(data.hasWellKnownPackageGrant).toBe(true)
expect(data.hasRestrictedPackageGrant).toBe(true)
expect(data.orphanPackageSidCount).toBe(1)
expect(data.matchesPoisonSignature).toBe(false)
})
// A Program Files install inherits ALL APPLICATION PACKAGES by default, and an
// orphan alongside it launched clean on win32 10.0.26200 / Electron 43.4.1 — so
// it is not the reproduced state, however useless -1 is to an LPAC token.
it.each([
['the localized-safe name form', ALL_PACKAGES_ACE],
['the raw SID form', 'S-1-15-2-1:(OI)(CI)(RX)']
])('clears the signature when only ALL APPLICATION PACKAGES grants (%s)', async (_l, ace) => {
const data = await probeWith(ace, ORPHAN)
expect(data.hasWellKnownPackageGrant).toBe(true)
expect(data.hasRestrictedPackageGrant).toBe(false)
expect(data.matchesPoisonSignature).toBe(false)
})
// The reproduced remedy was an additive *grant*; an ACE that grants nothing on
// the object cannot satisfy the orphan, so it must not clear the signature.
it.each([
@@ -119,6 +101,7 @@ describe('probeWindowsInstallDirAcl', () => {
])('does not let a %s well-known ACE satisfy an orphan', async (_label, ace) => {
const data = await probeWith(ace, ORPHAN)
expect(data.hasWellKnownPackageGrant).toBe(false)
expect(data.hasRestrictedPackageGrant).toBe(false)
expect(data.matchesPoisonSignature).toBe(true)
})
@@ -143,8 +126,10 @@ describe('probeWindowsInstallDirAcl', () => {
platform: 'win32',
installDir: INSTALL_DIR,
fileExists: () => false,
spawnFn: fakeSpawn(
(target) => `${target} ${ORPHAN}\r\n AUTORITE NT\\Systeme:(I)(F)`
// fr-FR install that already carries the restricted grant: the name check
// cannot see it, so the signature is a false positive the flag must expose.
spawnFn: fakeSpawn((target) =>
icaclsDacl(target, [ORPHAN, FRENCH_RESTRICTED_PACKAGES_ACE], FRENCH_BASELINE_ACES)
).spawnFn,
recordBreadcrumb: (_name, d) => {
resolve(d as Record<string, unknown>)
@@ -18,6 +18,12 @@ import { getIcaclsExePath } from '../win32-utils'
* S-1-15-2-2 clears it — see electron/electron#51761. This records whether a
* machine is in that state so the next crash report answers the question itself.
*
* The verdict deliberately accepts EITHER well-known grant: a tree carrying the
* orphan plus S-1-15-2-1 only (the Program Files default) launched clean on
* win32 10.0.26200 / Electron 43.4.1, so it is not the reproduced state and must
* not be treated as one. `hasRestrictedPackageGrant` is still reported, so a
* report can tell the two shapes apart if that ever stops holding.
*
* Diagnostic only: it never writes an ACL and never changes behavior.
*/
@@ -32,13 +38,18 @@ const PROBE_BUDGET_MS = 5_000
/** Raw S-1-15-2-* means icacls could not resolve it — locale-independent. */
const RAW_PACKAGE_SID = /\bS-1-15-2-[0-9-]+\b/i
const WELL_KNOWN_PACKAGE_SIDS = new Set(['s-1-15-2-1', 's-1-15-2-2'])
/** ALL RESTRICTED APPLICATION PACKAGES: the grant the reproduced remedy added. */
const RESTRICTED_PACKAGES_SID = 's-1-15-2-2'
const WELL_KNOWN_PACKAGE_SIDS = new Set(['s-1-15-2-1', RESTRICTED_PACKAGES_SID])
// icacls localizes these; the raw SID form is never printed for them. Orphan
// detection stays SID-form and locale-independent, but this check is not — so we
// report whether the output looked English at all, making a locale-induced
// false positive recognizable instead of silent.
const WELL_KNOWN_PACKAGE_NAMES = /ALL (RESTRICTED )?APPLICATION PACKAGES/i
const ENGLISH_PRINCIPAL = /\b(NT AUTHORITY|BUILTIN|APPLICATION PACKAGE AUTHORITY)\b/i
const RESTRICTED_PACKAGE_NAME = /ALL RESTRICTED APPLICATION PACKAGES/i
// Not BUILTIN: fr-FR and es-ES print it verbatim while localizing the package
// names, so it is evidence of nothing. SYSTEM's ACE is on every install tree.
const ENGLISH_PRINCIPAL = /\b(NT AUTHORITY|APPLICATION PACKAGE AUTHORITY)\b/i
// Why: an ACE that denies, or only propagates to children, grants nothing on this
// object — so it cannot satisfy an orphan the way the reproduced fix did.
const NON_GRANTING_FLAGS = /\((?:DENY|IO)\)/i
@@ -57,6 +68,7 @@ export type WindowsInstallDirAclProbeOptions = {
type AclFacts = {
orphanPackageSids: string[]
hasWellKnownPackageGrant: boolean
hasRestrictedPackageGrant: boolean
sawEnglishPrincipal: boolean
}
@@ -96,6 +108,7 @@ function readDacl(spawnFn: typeof spawn, target: string, deadlineMs: number): Pr
function collectAclFacts(daclOutput: string): AclFacts {
const orphanPackageSids: string[] = []
let hasWellKnownPackageGrant = false
let hasRestrictedPackageGrant = false
let sawEnglishPrincipal = false
for (const line of daclOutput.split(/\r?\n/)) {
if (ENGLISH_PRINCIPAL.test(line)) {
@@ -108,17 +121,28 @@ function collectAclFacts(daclOutput: string): AclFacts {
continue
}
const [, principal, flags] = ace
const sid = RAW_PACKAGE_SID.exec(principal)?.[0]
if (sid && !WELL_KNOWN_PACKAGE_SIDS.has(sid.toLowerCase())) {
orphanPackageSids.push(sid)
const rawSid = RAW_PACKAGE_SID.exec(principal)?.[0]
const sid = rawSid?.toLowerCase()
if (rawSid && !WELL_KNOWN_PACKAGE_SIDS.has(rawSid.toLowerCase())) {
orphanPackageSids.push(rawSid)
continue
}
const isWellKnown = sid !== undefined || WELL_KNOWN_PACKAGE_NAMES.test(principal)
if (isWellKnown && !NON_GRANTING_FLAGS.test(flags)) {
hasWellKnownPackageGrant = true
if (!isWellKnown || NON_GRANTING_FLAGS.test(flags)) {
continue
}
hasWellKnownPackageGrant = true
// Reported, never the verdict: narrows which grant is present for triage.
if (sid === RESTRICTED_PACKAGES_SID || (!sid && RESTRICTED_PACKAGE_NAME.test(principal))) {
hasRestrictedPackageGrant = true
}
}
return { orphanPackageSids, hasWellKnownPackageGrant, sawEnglishPrincipal }
return {
orphanPackageSids,
hasWellKnownPackageGrant,
hasRestrictedPackageGrant,
sawEnglishPrincipal
}
}
function resolveTargets(installDir: string, fileExists: (path: string) => boolean): string[] {
@@ -143,6 +167,7 @@ async function runProbe(options: WindowsInstallDirAclProbeOptions): Promise<void
const facts = outputs.map(collectAclFacts)
const orphans = [...new Set(facts.flatMap((f) => f.orphanPackageSids))]
const hasWellKnownPackageGrant = facts.some((f) => f.hasWellKnownPackageGrant)
const hasRestrictedPackageGrant = facts.some((f) => f.hasRestrictedPackageGrant)
// Why per target: a grant on the directory does not grant on the module file,
// and the reproduced failure is a per-file content read. Merging would let a
// grant on one target mask its absence on the other.
@@ -158,7 +183,10 @@ async function runProbe(options: WindowsInstallDirAclProbeOptions): Promise<void
// Capped: correlating the same orphan across reports is what would
// identify the tool that left it, which is the point of recording it.
orphanPackageSids: sanitizeCrashReportString(orphans.slice(0, 3).join(','), 200),
// The verdict rides on this one: either well-known grant satisfies the orphan.
hasWellKnownPackageGrant,
// Diagnostic only — the -1-only shape launches clean on real hardware.
hasRestrictedPackageGrant,
// False positives are possible on a non-English Windows, where the
// well-known ACE resolves to a localized name this cannot match.
wellKnownNameCheckReliable: facts.some((f) => f.sawEnglishPrincipal),
@@ -0,0 +1,187 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it } from 'vitest'
import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process'
import {
probeWindowsInstallDirAcl,
resetWindowsInstallDirAclProbeForTest
} from './windows-install-dir-acl-probe'
import {
describeInstallDirAclPoison,
resetWindowsInstallDirAclRecoveryForTest,
startWindowsInstallDirAclRepairIfPoisoned
} from './windows-install-dir-acl-recovery'
import { resetWindowsInstallDirAclRepairForTest } from './windows-install-dir-package-acl-repair'
import {
ALL_PACKAGES_ACE,
fakeIcaclsSpawn,
FRENCH_BASELINE_ACES,
FRENCH_RESTRICTED_PACKAGES_ACE,
icaclsDacl,
ORPHAN_PACKAGE_ACE,
RESTRICTED_PACKAGES_ACE
} from './windows-install-dir-acl.test-fixture'
const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca'
const APP_VERSION = '1.4.184'
/**
* Drives the production path: the real probe hands its verdict to the real gate,
* which decides whether icacls ever runs. Only the two process seams are faked.
*/
function probeThenRecover(
dacl: (target: string) => string,
options: { failRepair?: boolean } = {}
): Promise<ProcessSpec[]> {
const specs: ProcessSpec[] = []
const runProcessFn = async (spec: ProcessSpec): Promise<ProcessResult> => {
specs.push(spec)
return {
code: options.failRepair === true ? 5 : 0,
signal: null,
stdout: 'Successfully processed 81 files; Failed processing 0 files',
stderr: '',
timedOut: false
}
}
return new Promise((resolve) => {
probeWindowsInstallDirAcl({
platform: 'win32',
installDir: INSTALL_DIR,
fileExists: (path) => path.endsWith('ffmpeg.dll'),
spawnFn: fakeIcaclsSpawn(dacl).spawnFn,
recordBreadcrumb: () => undefined,
onDone: (data) => {
startWindowsInstallDirAclRepairIfPoisoned(data, {
platform: 'win32',
installDir: INSTALL_DIR,
appVersion: APP_VERSION,
userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')),
runProcessFn: runProcessFn as never,
recordBreadcrumb: () => undefined
})
// Longer than the repair's own setImmediate hop, so a repair that was
// started has always spawned by the time this resolves.
setTimeout(() => resolve(specs), 25)
}
})
})
}
describe('startWindowsInstallDirAclRepairIfPoisoned', () => {
beforeEach(() => {
resetWindowsInstallDirAclProbeForTest()
resetWindowsInstallDirAclRepairForTest()
resetWindowsInstallDirAclRecoveryForTest()
})
it('repairs when the probe sees an orphan package ACE and no well-known grant', async () => {
const specs = await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE]))
expect(specs.map((spec) => spec.args?.[2])).toEqual([
'*S-1-15-2-2:(OI)(CI)(RX)',
'*S-1-15-2-2:(RX)'
])
})
// Orphan + the Program Files ALL APPLICATION PACKAGES default launched clean on
// win32 10.0.26200 / Electron 43.4.1, so it earns neither an ACL write nor the
// accusing dialog copy.
it('leaves an install whose package grant is ALL APPLICATION PACKAGES alone', async () => {
const specs = await probeThenRecover((target) =>
icaclsDacl(target, [ORPHAN_PACKAGE_ACE, ALL_PACKAGES_ACE])
)
expect(specs).toHaveLength(0)
expect(describeInstallDirAclPoison()).toBeNull()
})
it('does not touch an install that already carries the restricted grant', async () => {
const specs = await probeThenRecover((target) =>
icaclsDacl(target, [ORPHAN_PACKAGE_ACE, RESTRICTED_PACKAGES_ACE])
)
expect(specs).toHaveLength(0)
expect(describeInstallDirAclPoison()).toBeNull()
})
// A localized icacls prints the grant under a name the probe cannot match, so
// the signature is unproven: neither icacls nor the accusing dialog copy.
it('does not act on a signature from a non-English icacls', async () => {
const specs = await probeThenRecover((target) =>
icaclsDacl(target, [ORPHAN_PACKAGE_ACE, FRENCH_RESTRICTED_PACKAGES_ACE], FRENCH_BASELINE_ACES)
)
expect(specs).toHaveLength(0)
expect(describeInstallDirAclPoison()).toBeNull()
})
it('does not act when the probe could not read the DACL', async () => {
const specs = await new Promise<ProcessSpec[]>((resolve) => {
const collected: ProcessSpec[] = []
probeWindowsInstallDirAcl({
platform: 'win32',
installDir: INSTALL_DIR,
fileExists: () => false,
spawnFn: fakeIcaclsSpawn(() => null).spawnFn,
recordBreadcrumb: () => undefined,
onDone: (data) => {
startWindowsInstallDirAclRepairIfPoisoned(data, {
platform: 'win32',
installDir: INSTALL_DIR,
appVersion: APP_VERSION,
userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')),
runProcessFn: (async (spec: ProcessSpec) => {
collected.push(spec)
throw new Error('unreachable')
}) as never,
recordBreadcrumb: () => undefined
})
setTimeout(() => resolve(collected), 25)
}
})
})
expect(specs).toHaveLength(0)
expect(describeInstallDirAclPoison()).toBeNull()
})
})
describe('describeInstallDirAclPoison', () => {
beforeEach(() => {
resetWindowsInstallDirAclProbeForTest()
resetWindowsInstallDirAclRepairForTest()
resetWindowsInstallDirAclRecoveryForTest()
})
it('offers the copyable commands, and drops them once the repair lands', async () => {
await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE]))
const repaired = describeInstallDirAclPoison()
expect(repaired?.detail).toContain('Orca repaired the permissions')
expect(repaired?.detail).not.toContain('Administrator Command Prompt')
expect(repaired?.commands).toEqual([
`icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(OI)(CI)(RX)"`,
`icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C`
])
})
it('walks a standard user through icacls when the repair could not write', async () => {
await probeThenRecover((target) => icaclsDacl(target, [ORPHAN_PACKAGE_ACE]), {
failRepair: true
})
const failed = describeInstallDirAclPoison()
expect(failed?.detail).toContain('needs an administrator')
expect(failed?.detail).toContain(`icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C`)
})
it('reports the repair as in flight before icacls has answered', () => {
startWindowsInstallDirAclRepairIfPoisoned(
{ status: 'ok', matchesPoisonSignature: true, wellKnownNameCheckReliable: true },
{
platform: 'win32',
installDir: INSTALL_DIR,
appVersion: APP_VERSION,
userDataPath: mkdtempSync(join(tmpdir(), 'orca-acl-recovery-')),
runProcessFn: (() => new Promise<never>(() => undefined)) as never,
recordBreadcrumb: () => undefined
}
)
expect(describeInstallDirAclPoison()?.detail).toContain('repairing the permissions now')
})
})
@@ -0,0 +1,77 @@
import { dirname } from 'node:path'
import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting'
import { logStartupMilestone } from './startup-diagnostics'
import {
buildInstallDirAclRepairCommands,
isInstallDirAclPoisonVerdict,
repairWindowsInstallDirPackageAcl,
type WindowsInstallDirAclRepairArgs,
type WindowsInstallDirAclRepairResult
} from './windows-install-dir-package-acl-repair'
/**
* Joins the read-only install-DACL probe to the repair, and keeps the verdict so
* the renderer-recovery dialog can say what is actually wrong instead of blaming
* the graphics driver. See `windows-install-dir-package-acl-repair.ts`.
*/
export type InstallDirAclPoisonDiagnosis = {
/** Dialog copy; ends with the commands when the user has to run them. */
detail: string
commands: string[]
}
export type WindowsInstallDirAclRecoveryOptions = Omit<WindowsInstallDirAclRepairArgs, 'onDone'>
type RepairStage = WindowsInstallDirAclRepairResult['mode'] | 'pending'
let poison: { installDir: string; stage: RepairStage } | null = null
export function resetWindowsInstallDirAclRecoveryForTest(): void {
poison = null
}
/** The probe's `onDone`: no-op unless the machine is in the reproduced state. */
export function startWindowsInstallDirAclRepairIfPoisoned(
data: CrashReportBreadcrumbData,
options: WindowsInstallDirAclRecoveryOptions
): void {
if (!isInstallDirAclPoisonVerdict(data)) {
return
}
const installDir = options.installDir ?? dirname(process.execPath)
poison = { installDir, stage: 'pending' }
repairWindowsInstallDirPackageAcl({
...options,
installDir,
onDone: (result) => {
poison = { installDir, stage: result.mode }
logStartupMilestone('install-dir-acl-repair-done', { mode: result.mode })
if (result.mode === 'failed') {
console.warn('[win32-acl] install dir package ACL repair failed:', result.reason)
}
}
})
}
const CAUSE =
"Windows permissions on Orca's install folder are blocking its own sandboxed processes from reading the files it shipped with."
// Why the exact commands: the window is blank, so the dialog is the only place a user can be told what to run.
export function describeInstallDirAclPoison(): InstallDirAclPoisonDiagnosis | null {
if (!poison) {
return null
}
const commands = buildInstallDirAclRepairCommands(poison.installDir)
if (poison.stage === 'repaired') {
return { detail: `${CAUSE}\n\nOrca repaired the permissions. Reload to use them.`, commands }
}
const status =
poison.stage === 'pending'
? 'Orca is repairing the permissions now.'
: 'Orca could not repair them, which usually means the folder needs an administrator.'
return {
detail: `${CAUSE} ${status}\n\nRun these in an Administrator Command Prompt, then relaunch Orca:\n\n${commands.join('\n')}`,
commands
}
}
@@ -0,0 +1,66 @@
import { EventEmitter } from 'node:events'
import type { spawn } from 'node:child_process'
/** Shared icacls doubles for the install-dir DACL probe, repair, and recovery tests. */
export const ORPHAN_PACKAGE_ACE = 'S-1-15-2-999-999-999:(OI)(CI)(RX)'
export const RESTRICTED_PACKAGES_ACE =
'APPLICATION PACKAGE AUTHORITY\\ALL RESTRICTED APPLICATION PACKAGES:(OI)(CI)(RX)'
/** The Program Files default: present on healthy installs, which launch clean. */
export const ALL_PACKAGES_ACE = 'APPLICATION PACKAGE AUTHORITY\\ALL APPLICATION PACKAGES:(RX)'
export const ENGLISH_BASELINE_ACES = [
'NT AUTHORITY\\SYSTEM:(I)(OI)(CI)(F)',
'BUILTIN\\Administrators:(I)(OI)(CI)(F)',
'awin\\neil:(I)(OI)(CI)(F)'
]
/** fr-FR icacls: no principal the English name check can recognize. */
export const FRENCH_BASELINE_ACES = [
'AUTORITE NT\\Systeme:(I)(OI)(CI)(F)',
'BUILTIN\\Administrateurs:(I)(OI)(CI)(F)'
]
export const FRENCH_RESTRICTED_PACKAGES_ACE =
"AUTORITE DE PACKAGE D'APPLICATION\\TOUS LES PACKAGES D'APPLICATION RESTREINTS:(RX)"
/** Real icacls shape: the echoed path is glued onto the first principal. */
export function icaclsDacl(
target: string,
aces: string[],
baseline: string[] = ENGLISH_BASELINE_ACES
): string {
const [first, ...rest] = [...aces, ...baseline]
return [
`${target} ${first}`,
...rest.map((ace) => ` ${ace}`),
'',
'Successfully processed 1 files'
].join('\r\n')
}
/** `null` output makes the spawn fail, as an unreadable target does. */
export function fakeIcaclsSpawn(output: (target: string) => string | null): {
spawnFn: typeof spawn
calls: { file: string; args: string[] }[]
} {
const calls: { file: string; args: string[] }[] = []
const spawnFn = ((file: string, args: string[]) => {
calls.push({ file, args })
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
kill: () => void
}
child.stdout = new EventEmitter()
child.kill = () => undefined
const out = output(args[0])
setImmediate(() => {
if (out === null) {
child.emit('error', new Error('ENOENT'))
return
}
child.stdout.emit('data', Buffer.from(out, 'utf-8'))
child.emit('close', 0)
})
return child
}) as unknown as typeof spawn
return { spawnFn, calls }
}
@@ -0,0 +1,303 @@
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ProcessResult, ProcessSpec } from '../../shared/child-process/run-process'
import type { CrashReportBreadcrumbData } from '../../shared/crash-reporting'
import {
buildInstallDirAclRepairCommands,
isInstallDirAclPoisonVerdict,
repairWindowsInstallDirPackageAcl,
resetWindowsInstallDirAclRepairForTest,
WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB,
WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE,
WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION,
type WindowsInstallDirAclRepairResult
} from './windows-install-dir-package-acl-repair'
const INSTALL_DIR = 'C:\\Users\\neil\\AppData\\Local\\Programs\\orca'
const APP_VERSION = '1.4.184'
/** icacls' real success summary; the /T pass prints one per tree. */
function icaclsOutput(processed: number, failed: number): string {
return `Successfully processed ${processed} files; Failed processing ${failed} files`
}
type Runner = (spec: ProcessSpec) => Promise<ProcessResult>
function fakeRunner(reply: (spec: ProcessSpec) => Partial<ProcessResult> = () => ({})): {
run: Runner
specs: ProcessSpec[]
} {
const specs: ProcessSpec[] = []
const run: Runner = async (spec) => {
specs.push(spec)
return {
code: 0,
signal: null,
stdout: icaclsOutput(81, 0),
stderr: '',
timedOut: false,
...reply(spec)
}
}
return { run, specs }
}
function userDataDir(): string {
return mkdtempSync(join(tmpdir(), 'orca-acl-repair-'))
}
function repair(
overrides: {
userDataPath?: string
appVersion?: string
installDir?: string
platform?: NodeJS.Platform
isServeMode?: boolean
run?: Runner
} = {}
): Promise<{
result: WindowsInstallDirAclRepairResult
data: CrashReportBreadcrumbData
}> {
const { run, ...rest } = overrides
return new Promise((resolve, reject) => {
let data: CrashReportBreadcrumbData = {}
repairWindowsInstallDirPackageAcl({
platform: 'win32',
installDir: INSTALL_DIR,
appVersion: APP_VERSION,
...rest,
userDataPath: rest.userDataPath ?? userDataDir(),
runProcessFn: (run ?? fakeRunner().run) as never,
recordBreadcrumb: (name, breadcrumb) => {
expect(name).toBe(WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB)
data = breadcrumb ?? {}
return undefined
},
onDone: (result) => resolve({ result, data })
})
setTimeout(() => reject(new Error('repair never settled')), 2_000).unref?.()
})
}
describe('repairWindowsInstallDirPackageAcl', () => {
beforeEach(() => {
resetWindowsInstallDirAclRepairForTest()
})
it('grants the flagless (RX) on the recursive pass, never (OI)(CI)(RX)', async () => {
// The whole fix: icacls silently no-ops an inheritance-flagged grant against a
// FILE — it exits 0 and reports zero failures while writing no ACE, so the
// shipped modules stay unreadable and every renderer still dies at init.
const { run, specs } = fakeRunner()
await repair({ run })
const treePass = specs.find((spec) => spec.args?.includes('/T'))
expect(treePass?.args).toEqual([INSTALL_DIR, '/grant', '*S-1-15-2-2:(RX)', '/T', '/C'])
expect(treePass?.args).not.toContain('*S-1-15-2-2:(OI)(CI)(RX)')
})
it('grants the inheritable (OI)(CI)(RX) on the root, without /T', async () => {
const { run, specs } = fakeRunner()
await repair({ run })
const rootPass = specs.find((spec) => !spec.args?.includes('/T'))
expect(rootPass?.args).toEqual([INSTALL_DIR, '/grant', '*S-1-15-2-2:(OI)(CI)(RX)'])
expect(specs).toHaveLength(2)
})
it('is additive on every pass so SYSTEM, Administrators and the orphan survive', async () => {
const { run, specs } = fakeRunner()
await repair({ run })
for (const spec of specs) {
expect(spec.args).toContain('/grant')
expect(spec.args).not.toContain('/grant:r')
}
})
it('reports the repair and records an ok breadcrumb', async () => {
const { result, data } = await repair()
expect(result).toEqual({ mode: 'repaired' })
expect(data.status).toBe('ok')
expect(data.failedFileCount).toBe(0)
})
it('runs once and then never spawns again for the same install and version', async () => {
const userDataPath = userDataDir()
const first = fakeRunner()
await repair({ userDataPath, run: first.run })
expect(first.specs).toHaveLength(2)
resetWindowsInstallDirAclRepairForTest()
const second = fakeRunner()
const { result, data } = await repair({ userDataPath, run: second.run })
expect(second.specs).toHaveLength(0)
expect(result).toEqual({ mode: 'marker-hit' })
expect(data.reason).toBe('marker-hit')
})
it('re-runs after an update and after a reinstall to another directory', async () => {
const userDataPath = userDataDir()
await repair({ userDataPath })
resetWindowsInstallDirAclRepairForTest()
const updated = fakeRunner()
expect(
(await repair({ userDataPath, appVersion: '1.4.185', run: updated.run })).result
).toEqual({ mode: 'repaired' })
expect(updated.specs).toHaveLength(2)
resetWindowsInstallDirAclRepairForTest()
const moved = fakeRunner()
expect(
(
await repair({
userDataPath,
installDir: 'D:\\Program Files\\Orca',
run: moved.run
})
).result
).toEqual({ mode: 'repaired' })
expect(moved.specs).toHaveLength(2)
})
it('re-runs when the marker is from an older scheme or is corrupt', async () => {
const userDataPath = userDataDir()
const markerFile = join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE)
writeFileSync(
markerFile,
JSON.stringify({
schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION - 1,
installDir: INSTALL_DIR,
appVersion: APP_VERSION
})
)
const stale = fakeRunner()
await repair({ userDataPath, run: stale.run })
expect(stale.specs).toHaveLength(2)
resetWindowsInstallDirAclRepairForTest()
writeFileSync(markerFile, '{ not json')
const corrupt = fakeRunner()
await repair({ userDataPath, run: corrupt.run })
expect(corrupt.specs).toHaveLength(2)
})
it('records the failed-file count when a standard user cannot write the ACL', async () => {
// Program Files: /C keeps going, icacls reports the losses and exits non-zero.
const { run, specs } = fakeRunner((spec) =>
spec.args?.includes('/T')
? {
code: 1332,
stdout: icaclsOutput(0, 81),
stderr: 'Access is denied.'
}
: { code: 5, stdout: '', stderr: 'Access is denied.' }
)
const { result, data } = await repair({ run })
expect(result.mode).toBe('failed')
expect(data.status).toBe('failed')
expect(data.failedFileCount).toBe(81)
// No retry loop: exactly the two passes, then it gives up for this version.
expect(specs).toHaveLength(2)
})
it('still runs the per-file pass when the root grant fails', async () => {
const { run, specs } = fakeRunner((spec) =>
spec.args?.includes('/T') ? {} : { code: 5, stderr: 'Access is denied.' }
)
const { result } = await repair({ run })
expect(specs).toHaveLength(2)
expect(result.mode).toBe('failed')
})
it('does not throw when the runner rejects, and still records a breadcrumb', async () => {
const run: Runner = () => Promise.reject(new Error('spawn EPERM'))
const { result, data } = await repair({ run })
expect(result.mode).toBe('failed')
expect(data.status).toBe('failed')
expect(String(data.reason)).toContain('spawn EPERM')
})
it('marks the attempt so a hopeless install does not re-spawn icacls every launch', async () => {
const userDataPath = userDataDir()
await repair({ userDataPath, run: fakeRunner(() => ({ code: 5 })).run })
const marker = JSON.parse(
readFileSync(join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE), 'utf-8')
) as { outcome: string }
expect(marker.outcome).toBe('failed')
})
it('is a no-op off win32 and in serve mode', async () => {
const off = fakeRunner()
repairWindowsInstallDirPackageAcl({
platform: 'darwin',
installDir: INSTALL_DIR,
appVersion: APP_VERSION,
userDataPath: userDataDir(),
runProcessFn: off.run as never,
recordBreadcrumb: vi.fn(),
onDone: () => expect.unreachable('no-op must not settle')
})
resetWindowsInstallDirAclRepairForTest()
const serve = fakeRunner()
const recordServe = vi.fn()
repairWindowsInstallDirPackageAcl({
platform: 'win32',
isServeMode: true,
installDir: INSTALL_DIR,
appVersion: APP_VERSION,
userDataPath: userDataDir(),
runProcessFn: serve.run as never,
recordBreadcrumb: recordServe,
onDone: () => expect.unreachable('no-op must not settle')
})
await new Promise((resolve) => setTimeout(resolve, 20))
expect(off.specs).toHaveLength(0)
expect(serve.specs).toHaveLength(0)
expect(recordServe).not.toHaveBeenCalled()
})
it('offers commands that match what the repair itself runs', () => {
expect(buildInstallDirAclRepairCommands(INSTALL_DIR)).toEqual([
`icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(OI)(CI)(RX)"`,
`icacls "${INSTALL_DIR}" /grant "*S-1-15-2-2:(RX)" /T /C`
])
})
})
describe('isInstallDirAclPoisonVerdict', () => {
it('only accepts the probe verdict that reproduced the crash', () => {
expect(
isInstallDirAclPoisonVerdict({
status: 'ok',
matchesPoisonSignature: true,
wellKnownNameCheckReliable: true
})
).toBe(true)
expect(
isInstallDirAclPoisonVerdict({
status: 'ok',
matchesPoisonSignature: false
})
).toBe(false)
expect(isInstallDirAclPoisonVerdict({ status: 'failed', reason: 'unreadable' })).toBe(false)
})
// A localized icacls hides the well-known grants behind translated names, so the
// signature there is unproven: repairing and blaming the install would be wrong.
it('refuses a signature the probe could not name-check', () => {
expect(
isInstallDirAclPoisonVerdict({
status: 'ok',
matchesPoisonSignature: true,
wellKnownNameCheckReliable: false
})
).toBe(false)
})
})
@@ -0,0 +1,276 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import {
sanitizeCrashReportString,
type CrashReportBreadcrumbData
} from '../../shared/crash-reporting'
import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb'
import { getIcaclsExePath } from '../win32-utils'
/**
* Self-repair for the install-directory package ACL that
* `windows-install-dir-acl-probe.ts` detects (electron/electron#51761).
*
* When the install tree carries an orphan AppContainer ACE (S-1-15-2-<x>) and no
* well-known package grant at all, Chromium's LPAC children are denied
* read on the shipped modules and die at init with 0x80000003. Reproduced on
* win32 10.0.26200 / Electron 43.4.1: the GPU child dies six times and the
* browser FATALs, or — once the GPU fallback engages `--in-process-gpu` — the
* renderer dies on every load and the window stays blank forever.
*
* Two additive grants clear it. Both are required:
* - the root grant is inheritable so files written later (an update) inherit it;
* - the /T pass must use the FLAGLESS `(RX)`. `icacls <file> /grant
* "*S-1-15-2-2:(OI)(CI)(RX)"` exits 0 and reports "Failed processing 0 files"
* while writing no ACE at all — a silent no-op. A directory grant alone does
* not fix a shipped module that carries its own explicit DACL, so a /T pass
* that silently no-ops leaves the app just as dead as before.
*
* Never `/grant:r`: the install tree's SYSTEM/Administrators/user ACEs must
* survive, and so must the orphan (removing an ACE is not ours to do).
*/
export const WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB = 'windows_install_dir_acl_repair'
export const WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE = 'windows-install-dir-acl-repair.json'
export const WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION = 1
/** `*`-prefixed so icacls reads it as a SID on every locale. */
const RESTRICTED_APP_PACKAGES_SID = '*S-1-15-2-2'
/** Root only: inheritable, so files added by a later update inherit the grant. */
export const INSTALL_DIR_ROOT_GRANT = `${RESTRICTED_APP_PACKAGES_SID}:(OI)(CI)(RX)`
/** Existing entries: flagless, the only form icacls actually writes onto a file. */
export const INSTALL_DIR_TREE_GRANT = `${RESTRICTED_APP_PACKAGES_SID}:(RX)`
const ROOT_GRANT_TIMEOUT_MS = 15_000
// A packaged Orca install tree is ~3.2k entries (a bare Electron dist is ~80, which
// is NOT the shape that ships: app.asar.unpacked and node_modules dominate). At the
// ~2.2ms/entry this repo measured for a /T walk in windows-user-data-acl.ts, that is
// ~5-7s of per-file DACL writes, each intercepted by Defender's filter driver. The
// cap covers a slow or contended volume on top of that, not a tree this size alone.
const TREE_GRANT_TIMEOUT_MS = 120_000
/** icacls localizes this; an unparsed summary reports the count as unknown. */
const FAILED_PROCESSING = /Failed processing (\d+) files?/i
export type WindowsInstallDirAclRepairResult =
| { mode: 'marker-hit' }
| { mode: 'repaired' }
| { mode: 'failed'; reason: string; failedFileCount: number | null }
export type WindowsInstallDirAclRepairOptions = {
installDir?: string
platform?: NodeJS.Platform
isServeMode?: boolean
/** Test seams. */
runProcessFn?: typeof runProcess
recordBreadcrumb?: typeof recordDurableCrashBreadcrumb
onDone?: (result: WindowsInstallDirAclRepairResult) => void
}
export type WindowsInstallDirAclRepairArgs = WindowsInstallDirAclRepairOptions & {
/** Marker home; the install dir itself is exactly what we may not be able to write. */
userDataPath: string
/** Part of the marker key so an update re-runs against the new files. */
appVersion: string
}
type RepairMarker = {
schemeVersion: number
installDir: string
appVersion: string
attemptedAt: number
outcome: string
}
/**
* The probe's verdict is the only trigger: an orphan package ACE with no
* well-known package grant to satisfy it. A localized icacls prints those grants
* under translated names the probe cannot match, so an unreliable name check is
* not evidence of poison — acting on it would spawn icacls and tell a user with a
* healthy install that their permissions are broken.
*/
export function isInstallDirAclPoisonVerdict(data: CrashReportBreadcrumbData): boolean {
return data.matchesPoisonSignature === true && data.wellKnownNameCheckReliable !== false
}
/** The commands to hand a user whose account cannot write the install ACL. */
export function buildInstallDirAclRepairCommands(installDir: string): string[] {
return [
`icacls "${installDir}" /grant "${INSTALL_DIR_ROOT_GRANT}"`,
`icacls "${installDir}" /grant "${INSTALL_DIR_TREE_GRANT}" /T /C`
]
}
function markerPath(userDataPath: string): string {
return join(userDataPath, WINDOWS_INSTALL_DIR_ACL_REPAIR_MARKER_FILE)
}
function hasMarkerFor(args: WindowsInstallDirAclRepairArgs): boolean {
try {
const parsed = JSON.parse(readFileSync(markerPath(args.userDataPath), 'utf-8')) as
| Partial<RepairMarker>
| undefined
return (
parsed?.schemeVersion === WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION &&
parsed.installDir === args.installDir &&
parsed.appVersion === args.appVersion
)
} catch {
return false // missing or corrupt -> attempt again
}
}
// Why write it on failure too: a standard-user Program Files install can never
// win, and re-spawning icacls on every launch forever buys nothing. Reinstall or
// update changes the key and retries.
function writeMarker(args: WindowsInstallDirAclRepairArgs, outcome: string): void {
const marker: RepairMarker = {
schemeVersion: WINDOWS_INSTALL_DIR_ACL_REPAIR_SCHEME_VERSION,
installDir: args.installDir ?? '',
appVersion: args.appVersion,
attemptedAt: Date.now(),
outcome
}
if (!existsSync(args.userDataPath)) {
mkdirSync(args.userDataPath, { recursive: true })
}
writeFileSync(markerPath(args.userDataPath), JSON.stringify(marker))
}
type GrantOutcome = {
ok: boolean
failedFileCount: number | null
reason?: string
}
async function runGrant(
runner: typeof runProcess,
installDir: string,
grant: string,
extraArgs: readonly string[],
timeoutMs: number
): Promise<GrantOutcome> {
try {
const result = await runner({
program: getIcaclsExePath(),
args: [installDir, '/grant', grant, ...extraArgs],
timeoutMs
})
const summary = FAILED_PROCESSING.exec(`${result.stdout}\n${result.stderr}`)
const failedFileCount = summary ? Number(summary[1]) : null
if (result.timedOut) {
return { ok: false, failedFileCount, reason: 'timeout' }
}
if (result.code !== 0) {
return { ok: false, failedFileCount, reason: `exit ${result.code}` }
}
if (failedFileCount !== null && failedFileCount > 0) {
return { ok: false, failedFileCount, reason: 'failed-files' }
}
return { ok: true, failedFileCount }
} catch (error) {
return {
ok: false,
failedFileCount: null,
reason: sanitizeCrashReportString(String(error), 200)
}
}
}
async function runRepair(args: WindowsInstallDirAclRepairArgs): Promise<void> {
const record = args.recordBreadcrumb ?? recordDurableCrashBreadcrumb
const installDir = args.installDir ?? dirname(process.execPath)
const resolved: WindowsInstallDirAclRepairArgs = { ...args, installDir }
let result: WindowsInstallDirAclRepairResult
let data: CrashReportBreadcrumbData
try {
if (hasMarkerFor(resolved)) {
result = { mode: 'marker-hit' }
data = { status: 'skipped', reason: 'marker-hit' }
} else {
const runner = args.runProcessFn ?? runProcess
const root = await runGrant(
runner,
installDir,
INSTALL_DIR_ROOT_GRANT,
[],
ROOT_GRANT_TIMEOUT_MS
)
// Unconditional: the root grant failing does not make the per-file pass —
// the one that actually unblocks the shipped modules — any less worth trying.
const tree = await runGrant(
runner,
installDir,
INSTALL_DIR_TREE_GRANT,
['/T', '/C'],
TREE_GRANT_TIMEOUT_MS
)
const failedFileCount = tree.failedFileCount ?? root.failedFileCount
if (root.ok && tree.ok) {
result = { mode: 'repaired' }
data = { status: 'ok', failedFileCount: failedFileCount ?? -1 }
} else {
const reason = [
root.reason && `root: ${root.reason}`,
tree.reason && `tree: ${tree.reason}`
]
.filter(Boolean)
.join('; ')
result = { mode: 'failed', reason, failedFileCount }
data = {
status: 'failed',
reason: sanitizeCrashReportString(reason, 200),
// -1 means icacls printed no parsable summary (a localized Windows).
failedFileCount: failedFileCount ?? -1
}
}
try {
writeMarker(resolved, result.mode)
} catch (error) {
data = {
...data,
markerWriteFailed: sanitizeCrashReportString(String(error), 200)
}
}
}
} catch (error) {
result = { mode: 'failed', reason: String(error), failedFileCount: null }
data = {
status: 'failed',
reason: sanitizeCrashReportString(`repair: ${String(error)}`, 200)
}
}
record(WINDOWS_INSTALL_DIR_ACL_REPAIR_BREADCRUMB, data)
args.onDone?.(result)
}
// Why once per process: the DACL cannot usefully change mid-session, and
// openMainWindow re-runs on re-activation.
let repairStarted = false
export function resetWindowsInstallDirAclRepairForTest(): void {
repairStarted = false
}
/**
* Fire-and-forget; returns before any spawn. Call only when the probe reported
* `matchesPoisonSignature`. win32 only, exempt in serve mode, and it must never
* throw into window creation.
*/
export function repairWindowsInstallDirPackageAcl(args: WindowsInstallDirAclRepairArgs): void {
if ((args.platform ?? process.platform) !== 'win32' || args.isServeMode === true) {
return
}
if (repairStarted) {
return
}
repairStarted = true
try {
setImmediate(() => {
void runRepair(args).catch(() => undefined)
})
} catch {
// Nothing left to report to that would not throw again.
}
}
@@ -21,7 +21,7 @@ vi.mock('../browser/browser-client-page-renderer-runtime', async () => {
}
})
import { createMainWindow } from './createMainWindow'
import { createMainWindow, loadMainWindow } from './createMainWindow'
import { ipcMain } from 'electron'
import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification'
import {
@@ -593,6 +593,40 @@ describe('createMainWindow', () => {
consoleError.mockRestore()
})
it('serves a manual retry while the breaker stays open for the next crash', () => {
vi.useFakeTimers()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const onRendererRecoveryExhausted = vi.fn()
const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness()
createMainWindow(null, { onRendererRecoveryExhausted })
const details = { reason: 'crashed', exitCode: 5 } as Electron.RenderProcessGoneDetails
const driveCrashCycle = (): void => {
windowHandlers['render-process-gone']?.({} as never, details)
vi.advanceTimersByTime(250)
}
driveCrashCycle()
driveCrashCycle()
driveCrashCycle()
driveCrashCycle()
expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(1)
// 1 initial load + 3 recoveries; the 4th crash was refused.
expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(4)
// The recovery prompt's Reload button goes straight to loadMainWindow, which the breaker never gates.
loadMainWindow(browserWindowInstance as unknown as Electron.BrowserWindow)
expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(5)
// Still-poisoned machine: the next crash re-raises the prompt immediately instead of re-arming auto-reloads.
driveCrashCycle()
expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(5)
expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(2)
consoleError.mockRestore()
})
it('bounds renderer launch-failed recovery with the crash-loop breaker', () => {
vi.useFakeTimers()
@@ -0,0 +1,112 @@
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery'
import {
presentRendererRecoveryPrompt,
type RendererRecoveryPromptDeps
} from './renderer-recovery-prompt'
const POISON: InstallDirAclPoisonDiagnosis = {
detail: "Windows permissions on Orca's install folder are blocking its own sandboxed processes.",
commands: ['icacls "C:\\Orca" /grant "*S-1-15-2-2:(OI)(CI)(RX)"', 'icacls "C:\\Orca" /grant b']
}
function harness(overrides: Partial<RendererRecoveryPromptDeps> & { responses?: number[] } = {}): {
run: () => Promise<void>
shown: MessageBoxOptions[]
copied: string[]
reload: ReturnType<typeof vi.fn>
quit: ReturnType<typeof vi.fn>
} {
const { responses = [0], ...rest } = overrides
const shown: MessageBoxOptions[] = []
const copied: string[] = []
const reload = vi.fn()
const quit = vi.fn()
const deps: RendererRecoveryPromptDeps = {
recentRecoveryCount: 4,
isQuitting: () => false,
diagnose: () => null,
showMessageBox: async (options: MessageBoxOptions): Promise<MessageBoxReturnValue> => {
shown.push(options)
return {
response: responses[Math.min(shown.length - 1, responses.length - 1)],
checkboxChecked: false
}
},
copyToClipboard: (text) => copied.push(text),
reload,
quit,
...rest
}
return { run: () => presentRendererRecoveryPrompt(deps), shown, copied, reload, quit }
}
describe('presentRendererRecoveryPrompt', () => {
it('offers reload and quit with the generic cause when nothing is diagnosed', async () => {
const { run, shown, reload, quit } = harness({ responses: [0] })
await run()
expect(shown).toHaveLength(1)
expect(shown[0].buttons).toEqual(['Reload', 'Quit'])
expect(shown[0].cancelId).toBe(1)
expect(shown[0].detail).toContain('graphics-driver or installation problem')
expect(reload).toHaveBeenCalledOnce()
expect(quit).not.toHaveBeenCalled()
})
it('quits on the last button', async () => {
const { run, reload, quit } = harness({ responses: [1] })
await run()
expect(quit).toHaveBeenCalledOnce()
expect(reload).not.toHaveBeenCalled()
})
it('names the install-permission cause and keeps the driver hint', async () => {
const { run, shown } = harness({ diagnose: () => POISON, responses: [0] })
await run()
expect(shown[0].buttons).toEqual(['Reload', 'Copy Commands', 'Quit'])
expect(shown[0].cancelId).toBe(2)
expect(shown[0].detail).toContain(POISON.detail)
expect(shown[0].detail).toContain('graphics driver')
})
// The window is blank, so dismissing the dialog to copy would leave no way back.
it('keeps the dialog up after copying the commands, then still reloads', async () => {
const { run, shown, copied, reload, quit } = harness({
diagnose: () => POISON,
responses: [1, 1, 0]
})
await run()
expect(copied).toEqual([POISON.commands.join('\r\n'), POISON.commands.join('\r\n')])
expect(shown).toHaveLength(3)
expect(reload).toHaveBeenCalledOnce()
expect(quit).not.toHaveBeenCalled()
})
it('quits on the third button once the diagnosis adds one', async () => {
const { run, quit, copied } = harness({ diagnose: () => POISON, responses: [2] })
await run()
expect(quit).toHaveBeenCalledOnce()
expect(copied).toEqual([])
})
it('shows nothing once the app is already quitting', async () => {
const { run, shown } = harness({ isQuitting: () => true })
await run()
expect(shown).toEqual([])
})
// The repair lands asynchronously, so a prompt raised while it ran must pick up
// the settled copy on the next pass rather than keep saying "repairing now".
it('re-reads the diagnosis on every pass', async () => {
const details = ['repairing now', 'repaired']
let pass = 0
const { run, shown } = harness({
diagnose: () => ({ detail: details[Math.min(pass++, 1)], commands: POISON.commands }),
responses: [1, 0]
})
await run()
expect(shown[0].detail).toContain('repairing now')
expect(shown[1].detail).toContain('repaired')
})
})
@@ -0,0 +1,55 @@
import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
import type { InstallDirAclPoisonDiagnosis } from '../startup/windows-install-dir-acl-recovery'
/**
* The dialog shown when the renderer crash-loop breaker opens: the window is
* blank by then, so this is the only retry/quit surface the user has.
*/
const GENERIC_DETAIL =
'This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.'
// Why keep it alongside the ACL diagnosis: the probe cannot name-check every
// locale, so a driver crash on a healthy install must not lose its only hint.
const DRIVER_FALLBACK = 'If that does not help, the cause is usually a graphics driver.'
export type RendererRecoveryPromptDeps = {
recentRecoveryCount: number
isQuitting: () => boolean
diagnose: () => InstallDirAclPoisonDiagnosis | null
showMessageBox: (options: MessageBoxOptions) => Promise<MessageBoxReturnValue>
copyToClipboard: (text: string) => void
reload: () => void
quit: () => void
}
export async function presentRendererRecoveryPrompt(
deps: RendererRecoveryPromptDeps
): Promise<void> {
// Why a loop: copying the commands must not dismiss the only surface offering them.
while (!deps.isQuitting()) {
const diagnosis = deps.diagnose()
const buttons = diagnosis ? ['Reload', 'Copy Commands', 'Quit'] : ['Reload', 'Quit']
const { response } = await deps.showMessageBox({
type: 'error',
buttons,
defaultId: 0,
cancelId: buttons.length - 1,
title: 'Orca keeps failing to load',
message: 'The app window crashed repeatedly and stopped reloading automatically.',
detail: `Orca tried to recover ${deps.recentRecoveryCount} times in a row without success.\n\n${
diagnosis ? `${diagnosis.detail}\n\n${DRIVER_FALLBACK}` : GENERIC_DETAIL
}`
})
const choice = buttons[response]
if (choice === 'Copy Commands' && diagnosis) {
deps.copyToClipboard(diagnosis.commands.join('\r\n'))
continue
}
if (choice === 'Reload') {
deps.reload()
} else if (choice === 'Quit') {
deps.quit()
}
return
}
}