Skip to content

fix(windows): make the install-dir ACL repair rescue the launch it runs in - #18361

Open
nwparker wants to merge 4 commits into
mainfrom
nwparker/crash-windows-acl-family
Open

fix(windows): make the install-dir ACL repair rescue the launch it runs in#18361
nwparker wants to merge 4 commits into
mainfrom
nwparker/crash-windows-acl-family

Conversation

@nwparker

@nwparker nwparker commented Sep 3, 2026

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​922 $\color{#cf222e}{\Huge{\mathbf{−}}}$​5 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​917
Prod 10 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​399 $\color{#cf222e}{\Huge{\mathbf{−}}}$​36 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​363

What

Three crash clusters — G1-windows-acl, G8-misc-crashed and G2-killed — are one bug, not three. Nine field diagnostic bundles (eight distinct reporters; the two 1.4.194 G1 bundles share a session) all carry the same install-directory DACL poison signature, all on Windows only (win32 10.0.19045 / 10.0.26100 / 10.0.26200), on Orca 1.4.194, 1.4.195 and 1.4.196, and all end in renderer / crashed / exit code -2147483645 (0x80000003).

The root cause is an orphan S-1-15-2-* LPAC package ACE on the install tree with no S-1-15-2-1/-2 grant to satisfy it — electron/electron#51761. Every sandboxed child dies reading Orca's own shipped modules. The read-only probe that detects it (#15107) and the repair that fixes it (#17740) both already exist. Neither rescues the launch it runs in:

  • src/main/startup/windows-install-dir-acl-probe.ts:229 — the probe is setImmediate-deferred and spawns icacls, so its verdict lands seconds in.
  • src/main/startup/main-window-controller.ts:80 vs :96 — the probe is dispatched, then createMainWindow synchronously spawns the renderer that the verdict was supposed to save.

Everything downstream then misreads the result. handleGpuChildCrash sees the ACL-induced GPU child deaths as a bad driver, latches --in-process-gpu, and the resulting software-rendered launches file into misc instead of windows-acl.

Four changes:

  1. Persist the verdict, gate the window on it. New src/main/startup/windows-install-dir-acl-poison-marker.ts writes a tiny synchronous windows-install-dir-acl-poison.json in userData keyed on installDir+appVersion (same shape as the existing gpu-fallback-marker.ts). It is written the moment the probe reports poison, before the repair runs, so a launch killed mid-repair still leaves state. New repairKnownPoisonedInstallDirBeforeWindow (windows-install-dir-acl-recovery.ts:198) reads it synchronously and, on a hit, awaits the real repair bounded at 20s — wired at src/main/startup/main-process-runtime-launch.ts:299, ahead of both desktop-window paths. A healthy machine pays one absent-file readFileSync.
  2. Stop the GPU fallback misattributing the ACL deaths. gpu-lifecycle.ts:167 still records every GPU crash unconditionally; only the engagement consults the install-DACL verdict (gpu-lifecycle.ts:173), and it waits for the verdict rather than acting on the suspicion.
  3. Un-latch safe graphics on repair. A successful repair clears an unconfirmed gpu-fallback-marker, so a repaired machine stops launching software-rendered for the rest of that build.
  4. Bounded retry budget on the repair marker. windows-install-dir-package-acl-repair.ts previously wrote its marker on failure and matched it regardless of outcome, so one transient failure (Defender-locked file, timeout, contended volume) pinned the machine to marker-hit — repair permanently skipped — for the life of that version. Now MAX_REPAIR_ATTEMPTS = 3, backwards-compatible (scheme-1 markers read attempts as 0, so already-pinned machines retry).

Fix evidence

The field data

All nine poisoned bundles, and how many DACL verdicts each machine recorded across its retained launches:

bundle                                  ver      os            probes  True  False  Unreadable
1.4.194_06f8fd09_NoGitHubidentity       1.4.194  10.0.26200       19    19      0          0
1.4.194_c8b94d6a_NoGitHubidentity       1.4.194  10.0.26200       28    28      0          0
1.4.196_a8ff553a_NoGitHubidentity       1.4.196  10.0.19045       14    13      1          0
1.4.194_bbd33262_YG-68686               1.4.194  10.0.26100        6     3      0          3
1.4.194_32fc963a_drop42                 1.4.194  10.0.19045       15    15      0          0
1.4.194_3d3fd5f2_ramadanis6690          1.4.194  10.0.26200       30    30      0          0
1.4.194_5e521eba_NoGitHubidentity       1.4.194  10.0.26100       13    13      0          0
1.4.194_79f85ff2_whtjdgns2121-dot       1.4.194  10.0.26200       14    14      0          0
1.4.195_693bfd75_NoGitHubidentity       1.4.195  10.0.26200       10    10      0          0
total probe verdicts recorded: 149

145 of 149 verdicts are poison=true. What those same nine bundles recorded downstream:

TOTALS across the 9 bundles:
   186  renderer_recovery_circuit_breaker_open
   112  renderer_recovery_manual_retry
    31  gpu_crash_hardware
    11  gpu_fallback_safe_graphics_kept
     1  windows_install_dir_acl_repair

186 blank-window give-ups, 112 manual Retry clicks by users, every one of the nine machines latched safe graphics — and one repair breadcrumb in the entire corpus. (The repair only exists in 1.4.196+; the one bundle on that version is the one that has it. That is the honest framing — the repair is not "never firing", it is firing too late and only on the newest build.)

The ordering, on the crashing launch, ms since mainProcessStartedAt:

### 1.4.194_32fc963a_drop42
  +   688ms  main_process_lifecycle_started
  +   824ms  windows_install_dir_acl {"orphanPackageSids": "S-1-15-2-2129520423-...", "matchesPoisonSignature": true}
  +  1365ms  renderer_recovery_reload
  +  1671ms  renderer_recovery_reload
  +  1945ms  renderer_recovery_reload
  +  2226ms  renderer_recovery_circuit_breaker_open {"reason": "crashed", "exitCode": -2147483645}

### 1.4.196_a8ff553a_NoGitHubidentity
  +  6346ms  main_process_lifecycle_started
  +  6607ms  process_gone_suppressed {"processType": "GPU", "reason": "crashed", "exitCode": -2147483645}
  +  6610ms  gpu_crash_hardware
  +  9553ms  windows_install_dir_acl {"matchesPoisonSignature": true}
  +  9567ms  process_gone_sibling_attribution
  +  9567ms  process_gone_suppressed {"processType": "GPU", "reason": "crashed", "exitCode": -2147483645}
  +  9813ms  renderer_recovery_reload
  + 26441ms  main_process_lifecycle_started
  + 27991ms  renderer_recovery_reload
  + 28010ms  windows_install_dir_acl {"matchesPoisonSignature": true}
  + 28311ms  renderer_recovery_reload
  + 28679ms  renderer_recovery_reload
  + 28988ms  renderer_recovery_circuit_breaker_open {"reason": "crashed", "exitCode": -2147483645}
  + 36782ms  windows_install_dir_acl_repair

On 1.4.196 the repair completes at +36.8s, thirty seconds after the first GPU child died and eight seconds after the recovery circuit breaker had already given up. That is the defect this PR fixes.

Regression tests: RED without each production hunk, GREEN with

Each production hunk reverted individually, then restored. Command, from /tmp/fix-windows-acl-family:

./node_modules/.bin/vitest run --config config/vitest.config.ts \
  src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts \
  src/main/startup/windows-install-dir-acl-recovery.test.ts \
  src/main/startup/windows-install-dir-acl-startup-wiring.test.ts \
  src/main/startup/windows-install-dir-package-acl-repair.test.ts \
  src/main/window/focus-existing-window.test.ts
########## REVERT A: gpu-lifecycle consults the install-DACL verdict ##########
     × keeps counting crashes that land while the probe verdict is outstanding 48ms
     × withholds safe graphics while the install DACL is the suspect, but keeps the evidence 19ms
     × withholds safe graphics when the outstanding verdict comes back poisoned 102ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
 Test Files  1 failed | 4 passed (5)
      Tests  3 failed | 59 passed (62)

########## REVERT B: pre-window gate wiring in main-process-runtime-launch ##########
     × awaits the pre-window gate before any window creation 3ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected -1 to be greater than or equal to 0
 Test Files  1 failed | 4 passed (5)
      Tests  1 failed | 61 passed (62)

########## REVERT C: persisted poison verdict (marker written when the probe reports poison) ##########
     × repairs a launch that a previous one recorded as poisoned, before returning 4ms
     × gives up on its budget rather than holding the window open forever 1ms
     × retires the marker when a later probe reports the install clean 0ms
     × keeps the marker when the probe could not read the DACL 1ms
     × retires a verdict the gate could no longer act on 1ms
     × is false on a healthy machine and clears once the gate returns 1ms
     × returns immediately when the once-per-process repair already ran 2ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 7 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected false to be true // Object.is equality
AssertionError: expected 'not-marked' to be 'timeout' // Object.is equality
AssertionError: expected false to be true // Object.is equality
AssertionError: expected false to be true // Object.is equality
AssertionError: expected 'not-marked' to be 'marker-hit' // Object.is equality
AssertionError: expected 'not-marked' to be 'repaired' // Object.is equality
AssertionError: expected 'not-marked' to be 'skipped' // Object.is equality
 Test Files  1 failed | 4 passed (5)
      Tests  7 failed | 55 passed (62)

########## REVERT D: retry budget on a failed repair (outcome-agnostic pin) ##########
     × retries a failed repair on later launches, then stops once the budget is spent 29ms
     × stops retrying immediately once a repair has succeeded 19ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected 'marker-hit' to be 'failed' // Object.is equality
AssertionError: expected { mode: 'marker-hit', …(1) } to deeply equal { mode: 'repaired' }
 Test Files  1 failed | 4 passed (5)
      Tests  2 failed | 60 passed (62)

########## REVERT E: only a dispatched probe arms the grace window ##########
     × arms the probe grace window only for a dispatched probe 5ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected 'const probeDispatched = probeWindowsI…' to contain 'if (probeDispatched) {'
 Test Files  1 failed | 4 passed (5)
      Tests  1 failed | 61 passed (62)

########## REVERT F: a clean probe reading retires the in-memory verdict ##########
     × outranks a repair verdict that lands after it 55ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected true to be false // Object.is equality
 Test Files  1 failed | 4 passed (5)
      Tests  1 failed | 61 passed (62)

########## RESTORED: all production hunks back ##########
 Test Files  5 passed (5)
      Tests  62 passed (62)

The GPU guard is the widest-blast-radius hunk, so it is also pinned against an inverted polarity — round 1 rejected an earlier source-grep test that stayed green when the guard was flipped. Flipping if (!isInstallDirAclSuspect()) to if (isInstallDirAclSuspect()) in gpu-lifecycle.ts:135:

--- guard polarity inverted; running the guard suite ---
     × withholds safe graphics while the install DACL is the suspect, but keeps the evidence 241ms
     × withholds safe graphics when the outstanding verdict comes back poisoned 148ms
     × holds an unconfirmed safe-graphics marker on disk across the wait 221ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 3 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
AssertionError: expected undefined to be false // Object.is equality
 Test Files  1 failed (1)
      Tests  3 failed | 3 passed (6)

Full suites, typecheck, lint

$ ./node_modules/.bin/vitest run --config config/vitest.config.ts \
    src/main/startup/ src/main/window/ src/main/crash-reporting/ src/shared/child-process/
 Test Files  121 passed | 4 skipped (125)
      Tests  1154 passed | 35 skipped (1189)

$ git status --short
(clean)

$ NODE_OPTIONS=--max-old-space-size=8192 ./node_modules/.bin/tsc -p config/tsconfig.node.json --noEmit; echo EXIT=$?
EXIT=0

$ ./node_modules/.bin/oxlint src/main/ src/shared/; echo EXIT=$?
EXIT=0

$ node config/scripts/check-max-lines-ratchet.mjs
max-lines ratchet OK — 12 grandfathered suppression(s), no new bypasses.

The 121 passing files include the child-process-import-boundary ratchet (no new direct node:child_process import) and the desktop-startup-ordering assertions.

Live real-icacls run on Windows — and what is NOT proven

src/main/startup/windows-install-dir-acl-repair.win32.test.ts is a real-binary integration test (describe.skip off win32, following the windows-command-line.win32.test.ts precedent) and is now in the pr.yml win32 allowlist (.github/workflows/pr.yml:793), so the Windows CI job runs it against this HEAD. It was run manually on a real Windows host (win32, node v24.18.0, real icacls.exe) at commit 3b528bd3fd:

$ ./node_modules/.bin/vitest run --config config/vitest.config.ts \
    src/main/startup/windows-install-dir-acl-repair.win32.test.ts
 RUN  v4.1.5 C:/Users/neil/aclfam
stdout | ... > repairs the tree before the window, and the grant lands on the module file
[live-acl] blocking repair repaired in 28ms
 ✓ src/main/startup/windows-install-dir-acl-repair.win32.test.ts (2 tests) 193ms
 Test Files  1 passed (1)
      Tests  2 passed (2)

That run confirmed the documented trap against the real binary: icacls <file> /grant "*S-1-15-2-2:(OI)(CI)(RX)" exits 0, prints Failed processing 0 files, and writes nothing to the file — which is why the recursive pass must use the flagless (RX) form. It also confirmed the real probe reports matchesPoisonSignature=true before and false after, within the same blocking call, on a tree whose inheritance was disabled.

Repair latency measured on the same host, on a realistically sized tree:

$ icacls "C:\Users\neil\acl-latency" /grant "*S-1-15-2-2:(OI)(CI)(RX)"
$ icacls "C:\Users\neil\acl-latency" /grant "*S-1-15-2-2:(RX)" /T /C
Successfully processed 3241 files; Failed processing 0 files
ROOT_MS=160 TREE_MS=548 TOTAL_MS=708

Not proven, stated plainly:

  • That live run is from commit 3b528bd3fd. The .win32.test.ts file itself is byte-identical at HEAD, but windows-install-dir-acl-recovery.ts (+126/-...) and windows-install-dir-package-acl-repair.ts (+29/-...) changed afterwards in the two review-round commits. The Windows host was not reachable to re-run at HEAD (ssh: connect to host awin port 22: Operation timed out). The win32 CI job on this PR is what re-proves it against HEAD — treat this PR as unmerged until that job is green.
  • Repair duration on a real poisoned user install is not measured. The only measurement is 708ms on a 3241-entry synthetic tree with a warm cache. The field bundles record repair completion but not duration, so the 20s budget is a design choice, not a measured p99.
  • No end-to-end run of a poisoned real install through the packaged app. The gate's ordering is pinned by source assertions in windows-install-dir-acl-startup-wiring.test.ts plus the behavioural focus-existing-window tests, not by a packaged launch.

ELI5

On some Windows machines, the folder Orca is installed into ends up with a broken permission entry. It is not Orca's doing — Windows or an installer leaves it behind. The effect is that Orca's own helper processes are not allowed to read Orca's own files. So Orca starts, opens a window, and the window is blank. Click Retry and it goes blank again. Users in these reports clicked Retry 112 times.

Orca already knew how to fix this — it runs a Windows permissions command on itself. The problem was timing: it only started checking after it had already opened the window, so by the time it knew what was wrong and fixed it, the window had already died three times and given up. On one report the fix finished 37 seconds into a launch that had already surrendered at 29 seconds. To make it worse, Orca also mistook these deaths for a broken graphics driver, permanently switched itself to slow software rendering, and then filed the crash under the wrong category so nobody could see the pattern.

This change makes Orca remember "this install folder was broken last time". On the next start it fixes the permissions before opening any window, so the window that opens is one that works. It also stops Orca blaming the graphics driver for something that is not the graphics driver, and undoes the slow-rendering switch once the real problem is fixed.

Trade-offs

Real costs, all Windows-only.

  1. Up to 20s of blocked startup on an already-marked machine. If userData carries a windows-install-dir-acl-poison.json matching this installDir+appVersion, window creation waits on the icacls repair (BLOCKING_REPAIR_BUDGET_MS, windows-install-dir-acl-recovery.ts). On timeout the repair keeps running in the background and the window opens anyway. Measured 708ms on a warm 3241-entry tree; a real 600MB+ install with Defender cold will be slower and is not measured. What it replaces on those same machines is today's behaviour: a blank window, three renderer deaths, the circuit breaker, and a manual Retry. Healthy machines pay one absent-file readFileSync.
  2. A second-instance reopen is dropped, not queued, while the gate is in flight. A 20s blank startup invites a second double-click, and icacls is actively rewriting the per-file DACLs a fresh renderer would read — so focusExistingMainWindow returns 'pending' (focus-existing-window.ts:148, same semantics as the existing !app.isReady() case) and that click produces nothing. The gated launch opens the window. On a machine where the gate times out at 20s, a user who double-clicked at 5s sees nothing extra from that click.
  3. GPU safe-graphics engagement is deferred, and on a poisoned install withheld, on win32. Crashes are always counted (gpu-lifecycle.ts:167) — a real driver burst is never erased from the rolling window. But engagement waits for the DACL verdict, bounded at 15s from probe dispatch. A user with a genuinely bad driver and a genuinely poisoned install will not be offered safe graphics this session; that is intended, because safe graphics does not rescue a poisoned tree and --in-process-gpu destroys the sibling-death evidence. A user with a bad driver on a healthy install sees engagement delayed by at most the probe's answer time, not skipped.
  4. A failed repair now re-spawns icacls up to 3 times (across 3 launches) instead of once. A hopeless standard-user Program Files install pays two extra icacls attempts before pinning permanently. That is the price of not bricking a machine on one transient Defender lock — which is what the code did before this PR.
  5. A successful repair clears an unconfirmed safe-graphics marker. A userConfirmed: true marker ("keep safe graphics", an explicit user choice) is left alone.
  6. New userData file windows-install-dir-acl-poison.json, written only on a positive poison verdict, win32 only. Unknown to older builds, harmless if left behind, self-retiring on a clean probe.

Not affected: macOS, Linux, serve mode (explicitly exempt), SSH execution hosts (this is entirely local main-process startup — no RPC, no stream frame, no wire change), folder workspaces (no worktree assumptions anywhere).

Adversarial review

3 round(s), and the honest answer is that it did not converge clean.

Round 1 — 5 blocking, all fixed (commit 70c525847ee):

  • The GPU-lifecycle guard was covered only by a source grep that stayed green with the guard's polarity inverted. Replaced with gpu-lifecycle-install-dir-acl-guard.test.ts, which drives the real handleGpuChildCrash; the inversion evidence above is the proof.
  • A clean probe verdict retired the on-disk marker but not the in-memory verdict, so a machine just proven healthy kept suppressing safe graphics and kept the dialog accusing the install folder — permanently, since an unreadable-DACL probe deliberately keeps the marker. A positive clean reading now latches installDirReadClean, which outranks any later repair result.
  • noteWindowsInstallDirAclProbePending() ran on every openMainWindow while the probe is once-per-process, so every tray/second-instance reopen armed a 15s window on healthy machines. Only a dispatched probe arms it now (probeWindowsInstallDirAcl returns whether it dispatched).
  • The pre-window ordering guarantee was defeatable by focusExistingMainWindow and had no test file. Added the canOpenWindow seam and windows-install-dir-acl-startup-wiring.test.ts.
  • The real-icacls test was absent from the pr.yml win32 allowlist, so it ran nowhere. Added.

Round 2 — 2 blocking, both fixed (commit 00ab2f57f55):

  • The guard sat before recordGpuCrash, so a suspected crash was discarded rather than deferred — and the suspect window is armed on every win32 launch, including hosts whose DACL is clean. The reviewer measured 0.8–1.7s suppression windows on clean hosts, squarely inside the 2.1–6.2s bad-driver bursts this repo already pins in gpu-crash-fallback-field-sessions.test.ts. Fixed: the crash is always recorded; only the engagement consults the verdict, and it waits for it.
  • The round-1 evidence block quoted commits, a test name and pass counts that no longer existed at HEAD. Re-run; the evidence in this PR is regenerated at HEAD 00ab2f57f55.

Rebutted, not applied (and worth a reviewer's disagreement):

  • "Await the verdict before persisting any safe-graphics marker." Not applied. Chromium aborts the whole browser process on the 6th GPU crash, ~1.3s after the 3rd — less than the probe takes to answer. So the unconfirmed marker is written up front and withdrawn if the verdict comes back poisoned; a machine killed mid-wait still comes back software-rendered.
  • "Gate should be conditioned on app.isPackaged." Not applied. A dev launch only carries the poison marker if a dev launch actually probed that tree and found the signature — in which case the dev renderer is dying the same way and the repair is exactly what is wanted.
  • "Fold the poison marker into the repair marker's outcome." Not applied. They have different lifetimes: the repair marker is a retry budget that is never cleared; the poison marker is cleared by a successful repair and by a clean probe. A 'pending' outcome written pre-attempt would burn the retry budget, so three launches killed mid-repair would permanently disable a repair that never once completed.

Round 3 — not clean, and not addressed. Round 3 re-reviewed HEAD (00ab2f57f55) and did not sign off. Its findings were truncated out of the handoff this PR was written from, so I cannot restate them, and no commit on this branch responds to them. I am not going to invent a summary of a review I do not have. A reviewer should treat this section as incomplete and re-run an adversarial pass against HEAD before merging.

Follow-ups

  1. Re-run the real-icacls verification at HEAD. The manual Windows run is from 3b528bd3fd; both modules it exercises changed afterwards. The pr.yml win32 job covers this automatically now, but the result must be checked green before merge, and the ordering assertion (repairKnownPoisonedInstallDirBeforeWindow returns before any window exists) is still only pinned by source assertions, not by a packaged launch.
  2. Recover round 3's findings and act on them. See above.
  3. Record repair duration in the windows_install_dir_acl_repair breadcrumb. It currently carries status and failedFileCount only, so the 20s budget cannot be validated against the real population. Adding durationMs would let the next release tell us whether 20s is generous or tight on real installs.
  4. No user-visible progress during the gate. Up to 20s with no window and no splash. A minimal "Repairing Orca's install permissions…" indicator would remove the double-click that trade-off 2 exists to absorb. Out of scope here because it needs a window, which is the one thing the gate is holding back.
  5. Nothing tells the user why a repair the gate could not complete failed. describeInstallDirAclPoison() hands over the icacls commands, but only through the renderer-recovery dialog — which needs a renderer that survives. A standard-user Program Files install that exhausts its 3 attempts has no path to that copy.
  6. gpu_fallback_withheld_install_dir_acl is a new breadcrumb with no dashboard. Worth adding to whatever monitors the GPU fallback so a regression in the suppression window is visible in aggregate rather than one bundle at a time.

…not after

The install-dir LPAC ACL poison (electron/electron#51761) still costs every
affected machine at least one crash: the probe that detects it is
setImmediate-deferred and answers 0.9-3.0s in, while createMainWindow runs
synchronously in the same frame and its renderer dies at init 48-1373ms later.

- Persist the poison verdict the moment the probe reports it, and await the
  repair (bounded at 20s) before any window is created on a launch that already
  carries the marker.
- Do not engage the GPU safe-graphics fallback while the install-dir ACL verdict
  is poisoned or still outstanding. Safe graphics does not rescue a poisoned
  tree, and --in-process-gpu removes the GPU child, erasing the sibling-death
  evidence that identifies the shape (4 field reports landed in 'misc' this way).
- Clear the safe-graphics marker once the repair lands, so a repaired machine
  stops launching software-rendered for the rest of that build.
- Give the repair marker a bounded retry budget: it was written on failure and
  matched regardless of outcome, so one transient failure pinned a machine to
  'marker-hit' for the life of that version.
Adversarial review round 1. Five blocking findings, all addressed.

1. gpu-lifecycle guard had only a source grep (green with the polarity
   inverted). The stated justification -- that gpu-lifecycle's import graph
   cannot be driven in-process -- was wrong: mocking `electron` plus
   `@electron-toolkit/utils` imports it fine. Replaced with
   gpu-lifecycle-install-dir-acl-guard.test.ts, which drives the real
   handleGpuChildCrash against a stub tracker. All four cases go red when the
   guard is flipped to `if (!isInstallDirAclSuspect())`.

2. A clean probe verdict retired the on-disk marker but not the in-memory
   `poison` verdict, so a machine the probe just proved healthy kept
   suppressing the GPU safe-graphics fallback and kept the dialog accusing the
   install folder -- permanently, since a `status:'failed'` probe deliberately
   keeps the marker. A positive clean reading now latches `installDirReadClean`,
   drops the verdict, and outranks a repair result that lands after it (a
   'failed' from a repair with nothing left to fix must not re-accuse).
   'repaired' is kept: it is not a contradiction and it is what tells the user
   to reload.

3. `noteWindowsInstallDirAclProbePending()` ran on every `openMainWindow` while
   the probe is once-per-process, so every tray/second-instance reopen armed a
   15s window in which `recordGpuCrash` was never called at all -- on healthy
   machines. `probeWindowsInstallDirAcl` now reports whether THIS call
   dispatched, and only a dispatch arms the grace window.

4. The pre-window ordering guarantee was defeatable and untested.
   `focusExistingMainWindow` opens a window whenever there is none and the app
   is ready -- true for the whole 20s gate, which is exactly when a user
   double-clicks the shortcut again. Added a `canOpenWindow` seam (same
   'pending' semantics as the existing `!app.isReady()` case) wired to
   `isBlockingInstallDirAclRepairInFlight()`, plus
   windows-install-dir-acl-startup-wiring.test.ts pinning the await ahead of
   both window-creation paths and both new call sites.

5. windows-install-dir-acl-repair.win32.test.ts was absent from the pr.yml
   win32 allowlist, so it ran nowhere. Added.

Also from the non-blocking list:
- The repair no longer clears a `userConfirmed: true` safe-graphics marker;
  "keep safe graphics" is a user choice, not Orca's automatic latch.
- `repairWindowsInstallDirPackageAcl` now reports its dispatch too, so a second
  entry into the gate resolves immediately instead of eating the full 20s
  budget waiting on an `onDone` that is never coming.
- The gate is wrapped in try/catch/finally, matching the contract the probe
  documents as mandatory for anything upstream of window creation.

Rebutted, not applied:
- "Gate should be conditioned on app.isPackaged." A dev launch only carries the
  poison marker if a dev launch actually probed that tree and found the
  signature, in which case the dev renderer is dying the same way and the
  repair is exactly what is needed. The adjacent `isPackaged` check guards a
  packaged-only early-window optimisation, not a correctness boundary.
- "Fold the poison marker into the repair marker's `outcome`." They answer
  different questions with different lifetimes. The repair marker is a retry
  budget (`attempts >= 3` disables the repair for that version) and is never
  cleared; the poison marker is cleared by a successful repair and by a clean
  probe. A `'pending'` outcome written before the attempt would bump `attempts`,
  so three launches killed mid-repair would permanently disable a repair that
  never once ran icacls to completion.
…t is pending

Adversarial review round 2. Both blocking findings addressed.

1. handleGpuChildCrash early-returned on isInstallDirAclSuspect() BEFORE
   recordGpuCrash, so the crash left no trace in the 30s rolling window. The
   suspect window is armed on every win32 non-serve launch, and the field
   bundles put it at 0.8-1.7s after main_window_created on hosts whose DACL is
   clean (matchesPoisonSignature=false) -- squarely inside the 2.1-6.2s
   bad-driver bursts this repo already pinned in
   gpu-crash-fallback-field-sessions.test.ts. A healthy machine with a failing
   driver could lose an entire coalesced burst and never engage safe graphics.

   The crash is now always recorded; only the engagement consults the verdict,
   and it waits for the verdict rather than acting on the suspicion
   (waitForInstallDirAclVerdict, resolved by the probe's onDone or by the
   existing 15s grace, whichever lands first).

   Deviation from the review's suggested shape, deliberately: awaiting the
   verdict before persisting anything reintroduces the exact race
   gpu-fallback-engagement.ts documents -- Chromium aborts the whole browser
   process on the 6th GPU crash, ~1.3s after the 3rd, which is less than the
   probe takes to answer. So the unconfirmed marker is written up front and
   withdrawn if the verdict comes back poisoned. A machine killed mid-wait
   still comes back software-rendered, and its marker is unconfirmed, which is
   the state the repair's own clear already retires.

   gpu-lifecycle-install-dir-acl-guard.test.ts now drives the real
   GpuCrashFallbackTracker and the real engagement path (the restart prompt
   firing is the signal) instead of a stub tracker, and covers the case the
   previous suite could not express: a burst that lands entirely inside the
   pending window still engages once the probe reports clean. Four reverts go
   red -- restoring the pre-record guard (2 tests), dropping the wait, dropping
   the post-wait re-check, and dropping the pre-wait marker write (2 tests).

2. The round-1 evidence block quoted commits, a test name and pass counts that
   no longer exist, and its real-icacls Windows run predated the commit that
   rewrote the gate. Re-run at this commit; counts and the live-Windows result
   are restated in the handoff rather than carried forward.

Also from the non-blocking list:
- 'marker-hit' conflated "already repaired" with "retry budget spent", because
  hasMarkerFor matches outcome === 'repaired' too. The result now carries
  alreadyRepaired, and the recovery maps that to stage 'repaired' -- so a launch
  killed between a successful repair and its marker clear no longer tells the
  user the folder needs an administrator, no longer latches
  isInstallDirAclSuspect() for the session, and does retire the poison marker.

Not applied, with reasoning:
- "clearGpuFallbackMarker narrowed to userConfirmed === false leaves the target
  population software-rendered after a repair." The summary was overstated and
  is corrected, but the narrowing stands: a userConfirmed marker now requires a
  clean DACL verdict, because the restart prompt that writes it is exactly what
  the gate above withholds while the install is a suspect. The population this
  family targets can no longer reach confirmMarker while poisoned.
- "writeInstallDirAclPoisonMarker re-stamps on a budget-exhausted machine
  forever." True, but on that machine the tree really is still poisoned and the
  gate resolves immediately ('skipped', no icacls spawn, no 20s wait), so the
  marker is telling the truth. Retiring it would be wrong; only a clean probe
  reading should.
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 22c8c7d5-0bb1-42e1-8fc7-68ae48bb3c98

📥 Commits

Reviewing files that changed from the base of the PR and between 94fcbe1 and 00ab2f5.

📒 Files selected for processing (16)
  • .github/workflows/pr.yml
  • src/main/startup/gpu-lifecycle-install-dir-acl-guard.test.ts
  • src/main/startup/gpu-lifecycle.ts
  • src/main/startup/main-process-runtime-launch.ts
  • src/main/startup/main-window-actions.ts
  • src/main/startup/main-window-controller.ts
  • src/main/startup/windows-install-dir-acl-poison-marker.ts
  • src/main/startup/windows-install-dir-acl-probe.ts
  • src/main/startup/windows-install-dir-acl-recovery.test.ts
  • src/main/startup/windows-install-dir-acl-recovery.ts
  • src/main/startup/windows-install-dir-acl-repair.win32.test.ts
  • src/main/startup/windows-install-dir-acl-startup-wiring.test.ts
  • src/main/startup/windows-install-dir-package-acl-repair.test.ts
  • src/main/startup/windows-install-dir-package-acl-repair.ts
  • src/main/window/focus-existing-window.test.ts
  • src/main/window/focus-existing-window.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.


📝 Walkthrough

Walkthrough

The changes add persistent Windows install-directory ACL poison markers and bounded repair retries. Startup now repairs known poisoned directories before window creation and blocks reopen actions during repair. ACL probe dispatch reports whether a probe started and tracks a verdict grace period. GPU crash fallback waits for the ACL verdict and suppresses fallback when the install DACL remains suspect. Tests cover lifecycle behavior, startup wiring, retry handling, real Windows ACL repair, and CI execution.

Merge Risk: ⚪ Minimal · up to 00ab2

No concrete merge-blocking risk is established for the current Windows ACL recovery changes.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is technically detailed and covers the problem, implementation, rationale, trade-offs, testing evidence, and follow-ups. However, it omits required template items, including a Linked I… Add a valid Linked Issue entry using Fixes #.... Add the required Visual Proof section with N/A and a brief reason if no visual or interaction change exists. Complete or explicitly mark as not applicable the AI Disclosure, Review, Agent…
Docstring Coverage ⚠️ Warning Docstring coverage is 45.95% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 15 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: repairing poisoned Windows install-directory ACLs early enough to rescue the current launch.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is technically detailed and covers the problem, implementation, rationale, trade-offs, testing evidence, and follow-ups. However, it omits required template items, including a Linked Issue, Visual Proof or an exact N/A statement, AI Disclosure, Agent skill upstream boundary, Notes, and completed checklist confirmations.

Resolution

Add a valid Linked Issue entry using Fixes #.... Add the required Visual Proof section with N/A and a brief reason if no visual or interaction change exists. Complete or explicitly mark as not applicable the AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist sections. Keep the existing technical evidence and testing results.

Full details: Docstring Coverage

Explanation

Docstring coverage is 45.95% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 37 functions across 15 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run was the fresh adversarial pass the PR body asks for (round 3's findings were lost in the handoff). I read the complete diff end-to-end and traced the full verdict/repair state machine, the GPU-fallback guard, and the second-instance reopen path.

  • Persisted poison verdict gates the windowwindows-install-dir-acl-poison-marker.ts writes a synchronous installDir+appVersion-keyed marker the moment the probe reports poison; repairKnownPoisonedInstallDirBeforeWindow reads it and awaits the 20s-bounded repair before any window, wired at main-process-runtime-launch.ts:299.
  • GPU fallback guard no longer misattributesgpu-lifecycle.ts always records the crash and only the engagement consults isInstallDirAclSuspect(), waiting for the verdict rather than discarding the crash.
  • Un-latch safe graphics on repair — a successful repair clears an unconfirmed gpu-fallback-marker, leaving userConfirmed markers alone.
  • Retry budget replaces outcome-agnostic pinMAX_REPAIR_ATTEMPTS = 3 with backwards-compatible attempts handling, so one transient Defender lock no longer bricks a machine for the life of a version.

Notes from tracing the concurrency, all of which check out: the pre-window gate runs after app.isReady() (via initializeMainProcessReady), so the canOpenWindow seam in focusExistingMainWindow is load-bearing — a second-instance reopen during the blocking repair is dropped ('pending'), not queued, and !app.isReady() alone would not cover it. The marker-hit/alreadyRepaired distinction correctly maps a completed repair to repaired (clears the poison marker) while an exhausted budget holds the verdict. A clean probe outranks a later repair verdict via installDirReadClean, and an unreadable DACL clears the in-memory suspect but keeps the on-disk marker, matching the stated invariant.

The regression tests are real: the guard suite drives the actual handleGpuChildCrash/tracker (with an inverted-polarity rejection), and the real-icacls .win32.test.ts is now in the pr.yml win32 allowlist. The only caveats are the author's own open follow-ups (re-verify the live icacls run at HEAD via the win32 CI job, add durationMs to the repair breadcrumb, and a progress indicator for the gate) — none of these block merge.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant