Repository navigation
fix(startup): Windows never crashes resolving userData when roaming AppData is unavailable #25113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| import { existsSync, mkdtempSync, readFileSync } from 'node:fs' | ||
| import { tmpdir } from 'node:os' | ||
| import { join, win32 } from 'node:path' | ||
| import { afterEach, describe, expect, it } from 'vitest' | ||
| import { removeTreeSync } from '../../shared/windows-transient-lock-removal' | ||
| import { | ||
| deriveWindowsAppDataPath, | ||
| ensureWindowsAppDataPath, | ||
| type WindowsAppDataPathHost | ||
| } from './windows-app-data-path' | ||
|
|
||
| const ERROR_MESSAGE = /could not find the Windows roaming AppData folder/ | ||
|
|
||
| function createHost(nativeAppData: string | Error): WindowsAppDataPathHost & { | ||
| calls: string[] | ||
| paths: Map<string, string> | ||
| } { | ||
| const calls: string[] = [] | ||
| const paths = new Map<string, string>() | ||
| return { | ||
| calls, | ||
| paths, | ||
| getName: () => 'orca', | ||
| getPath: (name) => { | ||
| calls.push(`get:${name}`) | ||
| const overridden = paths.get(name) | ||
| if (overridden) { | ||
| return overridden | ||
| } | ||
| if (nativeAppData instanceof Error) { | ||
| throw nativeAppData | ||
| } | ||
| return nativeAppData | ||
| }, | ||
| setPath: (name, value) => { | ||
| calls.push(`set:${name}`) | ||
| paths.set(name, value) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const tempDirs: string[] = [] | ||
| afterEach(() => { | ||
| for (const dir of tempDirs.splice(0)) { | ||
| removeTreeSync(dir) | ||
| } | ||
| }) | ||
|
|
||
| describe('deriveWindowsAppDataPath', () => { | ||
| it('prefers APPDATA', () => { | ||
| expect( | ||
| deriveWindowsAppDataPath({ | ||
| APPDATA: 'D:\\Profiles\\me\\Roaming', | ||
| USERPROFILE: 'C:\\Users\\me' | ||
| }) | ||
| ).toBe('D:\\Profiles\\me\\Roaming') | ||
| }) | ||
|
|
||
| it('falls back to USERPROFILE\\AppData\\Roaming when APPDATA is unset or relative', () => { | ||
| expect(deriveWindowsAppDataPath({ USERPROFILE: 'C:\\Users\\me' })).toBe( | ||
| 'C:\\Users\\me\\AppData\\Roaming' | ||
| ) | ||
| expect(deriveWindowsAppDataPath({ APPDATA: 'Roaming', USERPROFILE: 'C:\\Users\\me' })).toBe( | ||
| 'C:\\Users\\me\\AppData\\Roaming' | ||
| ) | ||
| }) | ||
|
|
||
| it('throws a readable error when neither variable is usable', () => { | ||
| expect(() => deriveWindowsAppDataPath({})).toThrow(ERROR_MESSAGE) | ||
| expect(() => deriveWindowsAppDataPath({ APPDATA: ' ', USERPROFILE: '' })).toThrow(ERROR_MESSAGE) | ||
| }) | ||
| }) | ||
|
|
||
| describe('ensureWindowsAppDataPath', () => { | ||
| it('does nothing off Windows', () => { | ||
| const host = createHost(new Error('unused')) | ||
| ensureWindowsAppDataPath(host, {}, 'darwin') | ||
| expect(host.calls).toEqual([]) | ||
| }) | ||
|
|
||
| it('keeps the native appData and pins userData to the path Electron would derive', () => { | ||
| const host = createHost('C:\\Users\\me\\AppData\\Roaming') | ||
| ensureWindowsAppDataPath(host, { APPDATA: 'D:\\ignored' }, 'win32') | ||
| expect(host.calls).toEqual(['get:appData', 'set:userData']) | ||
| expect(host.paths.get('userData')).toBe('C:\\Users\\me\\AppData\\Roaming\\orca') | ||
| }) | ||
|
|
||
| it('creates and sets appData from the environment before userData when the lookup throws', () => { | ||
| // Why a real dir: the fallback must mkdir a folder that a profile-less session never created. | ||
| const root = mkdtempSync(join(tmpdir(), 'orca-appdata-')) | ||
| tempDirs.push(root) | ||
| const missingAppData = join(root, 'missing', 'Roaming') | ||
| const host = createHost(new Error('Failed to get appData path')) | ||
|
|
||
| ensureWindowsAppDataPath(host, { APPDATA: missingAppData }, 'win32') | ||
|
|
||
| expect(host.calls).toEqual(['get:appData', 'set:appData', 'set:userData']) | ||
| expect(host.paths.get('appData')).toBe(missingAppData) | ||
| expect(host.paths.get('userData')).toBe(win32.join(missingAppData, 'orca')) | ||
| expect(existsSync(missingAppData)).toBe(true) | ||
| }) | ||
|
|
||
| it('treats an empty native appData like a failed lookup', () => { | ||
| const host = createHost('') | ||
| expect(() => ensureWindowsAppDataPath(host, {}, 'win32')).toThrow(ERROR_MESSAGE) | ||
| expect(host.calls).toEqual(['get:appData']) | ||
| }) | ||
|
|
||
| it('fails with a readable error, setting nothing, when both variables are missing', () => { | ||
| const host = createHost(new Error('Failed to get appData path')) | ||
| expect(() => ensureWindowsAppDataPath(host, {}, 'win32')).toThrow(ERROR_MESSAGE) | ||
| expect(host.paths.size).toBe(0) | ||
| }) | ||
| }) | ||
|
|
||
| describe('startup ordering', () => { | ||
| it('pins Windows appData before any preflight step can resolve userData', () => { | ||
| const source = readFileSync( | ||
| join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), | ||
| 'utf8' | ||
| ) | ||
| const body = source.slice(source.indexOf('function initializeMainProcessPreflight(')) | ||
| const ensure = body.indexOf('ensureWindowsAppDataPath(app)') | ||
| expect(ensure).toBeGreaterThan(0) | ||
| for (const laterStep of [ | ||
| 'runProfileStateRecoveryPreflight()', | ||
| 'maybeRedirectCliLaunch(', | ||
| 'configureDevUserDataPath(', | ||
| 'configureOrcaUserDataPathEnv()', | ||
| 'startCrashpadCapture()' | ||
| ]) { | ||
| expect(body.indexOf(laterStep)).toBeGreaterThan(ensure) | ||
| } | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { mkdirSync } from 'node:fs' | ||
| import { win32 } from 'node:path' | ||
|
|
||
| export type WindowsAppDataPathHost = { | ||
| getPath(name: 'appData'): string | ||
| setPath(name: 'appData' | 'userData', value: string): void | ||
| getName(): string | ||
| } | ||
|
|
||
| /** Derives the roaming AppData folder from the environment when the known-folder lookup fails. */ | ||
| export function deriveWindowsAppDataPath(env: NodeJS.ProcessEnv): string { | ||
| const appData = env.APPDATA?.trim() | ||
| if (appData && win32.isAbsolute(appData)) { | ||
| return appData | ||
| } | ||
| const userProfile = env.USERPROFILE?.trim() | ||
| if (userProfile && win32.isAbsolute(userProfile)) { | ||
| return win32.join(userProfile, 'AppData', 'Roaming') | ||
| } | ||
| throw new Error( | ||
| 'Orca could not find the Windows roaming AppData folder: Windows did not report it, and neither APPDATA nor USERPROFILE is set.' | ||
| ) | ||
| } | ||
|
|
||
| function readNativeAppDataPath(host: WindowsAppDataPathHost): string | null { | ||
| try { | ||
| return host.getPath('appData') || null | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Pins appData and userData before anything resolves userData on Windows. | ||
| * | ||
| * Why: when Electron cannot resolve or create userData itself (no loaded profile, e.g. over SSH), it falls | ||
| * through to Chromium's provider, which dereferences null and crashes natively before crashpad connects. | ||
| */ | ||
| export function ensureWindowsAppDataPath( | ||
| host: WindowsAppDataPathHost, | ||
| env: NodeJS.ProcessEnv = process.env, | ||
| platform: NodeJS.Platform = process.platform | ||
| ): void { | ||
| if (platform !== 'win32') { | ||
| return | ||
| } | ||
| let appData = readNativeAppDataPath(host) | ||
| if (!appData) { | ||
| appData = deriveWindowsAppDataPath(env) | ||
| mkdirSync(appData, { recursive: true }) | ||
| host.setPath('appData', appData) | ||
| } | ||
| // Same value Electron's own provider computes; setting it skips that provider entirely. | ||
| host.setPath('userData', win32.join(appData, host.getName())) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This Technical details# `--user-data-dir` is silently overridden on Windows
## Affected sites
- `src/main/startup/windows-app-data-path.ts:54` — pins `userData` even when an explicit `--user-data-dir` is present.
- `src/main/orcad/electron-serve-browser-process.ts:115` — spawns the installed Orca executable with `--user-data-dir=${userDataPath}` (a `mkdtemp` temp dir).
- `src/main/orcad/electron-serve-browser-process.ts:128` — `readRuntimeMetadata(userDataPath)` polls that temp dir; the child writes `orca-runtime.json` under `app.getPath('userData')` via `configureOrcaUserDataPathEnv` (`src/main/startup/configure-process.ts:234`) and `writeRuntimeMetadata` (`src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts:150`).
- `src/main/orcad/orcad-browser-provider.ts:87-93,176-179` — the sidecar path is Windows-capable (`%LOCALAPPDATA%\Programs\Orca\Orca.exe`).
- `config/scripts/runtime-serve-terminal-smoke.mjs:194` — same switch, no env override.
## Evidence
- Electron applies `--user-data-dir` in `ElectronMainDelegate::PreSandboxStartup()` via `PathService::OverrideAndCreateIfNeeded(chrome::DIR_USER_DATA, …)` before the JS main script runs (https://github.com/electron/electron/blob/main/shell/app/electron_main_delegate.cc).
- `app.setPath('userData', x)` overrides the same key (https://github.com/electron/electron/blob/main/shell/browser/api/electron_api_app.cc). `appData` is a different key (`base::DIR_ROAMING_APP_DATA`), so `--user-data-dir` does not change `getPath('appData')`.
- Neither `ORCA_E2E_USER_DATA_DIR` nor `ORCA_DEV_USER_DATA_PATH` is set on this spawn (`electronServeEnvironment()` strips only `AGENT_BROWSER_*`), so `configureDevUserDataPath` does not restore it.
## Required outcome
- A launch that already carries an explicit `--user-data-dir` must keep resolving `userData` to that directory, while the profile-less `appData` hardening still applies.
## Suggested approach (optional)
- Detect `--user-data-dir` in argv and skip pinning `userData` in that case. Chromium's override already bypasses the crashing provider, so `getPath('userData')` is safe there; still pin/derive `appData` if that lookup fails.
## Open questions for the human (optional)
- Is `--user-data-dir` considered a supported launch contract, or only internal test/orcad tooling? The orcad browser sidecar relies on it in production. |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This call runs before
state.isServeModeis assigned (line 151). IfensureWindowsAppDataPaththrows,handleMainProcessPreflightFailurereadsstate.isServeMode === falseand, on Windows, routes todialog.showErrorBoxbeforeapp.whenReady()rather than the clean serveexit 1the PR describes. A normalorca servechild does not setORCA_BACKGROUND_LAUNCH(src/cli/runtime/launch.ts:119-166), soisBackgroundLaunch()is false and the dialog is not suppressed.Technical details