Fix wsl cli rc4 migration (#8471)

* fix(cli): migrate managed WSL launchers to native exe

* refactor(cli): reconcile managed WSL registrations

* fix(cli): harden WSL registration reconciliation

* fix(cli): serialize and harden WSL CLI registration lifecycle

- Cap per-distro concurrent repairs and add a filesystem lock so two
  Orca installs can't race the same distro's launcher/bridge files
- Skip re-adopting a legacy managed launcher when its bridge is user-
  owned, avoiding a doomed install retried on every startup
- Track per-distro reconciliation against launcher target + app
  version so unchanged registrations aren't re-probed each boot
- Replace command install with a single atomic rename instead of a
  move-based backup, closing a window where no orca-ide exists
- Remove the registry invalidate-on-persist-failure path now that
  writes are atomic and repair is disk-authoritative
- Add brief negative caching to WSL distro discovery to bound
  wsl.exe respawns on persistently broken hosts
- Extract keyed-promise-queue as a shared serialization primitive
This commit is contained in:
Jinjing
2026-07-12 23:19:15 -07:00
committed by GitHub
parent ec7cc2df56
commit 34a9df7795
19 changed files with 2156 additions and 41 deletions
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue'
describe('runKeyedSerializedOperation', () => {
it('propagates rejections to the caller but never through the stored tail', async () => {
const queues = new Map<string, Promise<void>>()
const failing = runKeyedSerializedOperation(queues, 'key', async () => {
throw new Error('write failed')
})
// Why: awaiting the tail is how reads barrier on writes; an unrelated
// failed write must not abort the reader (startup candidate discovery).
const tail = getKeyedSerializedQueueTail(queues, 'key')
await expect(failing).rejects.toThrow('write failed')
await expect(tail).resolves.toBeUndefined()
await expect(runKeyedSerializedOperation(queues, 'key', async () => 'recovered')).resolves.toBe(
'recovered'
)
})
it('serializes operations per key and clears settled queues', async () => {
const queues = new Map<string, Promise<void>>()
const events: string[] = []
let release!: () => void
const first = runKeyedSerializedOperation(queues, 'a', async () => {
events.push('first-start')
await new Promise<void>((resolve) => {
release = resolve
})
events.push('first-end')
})
const second = runKeyedSerializedOperation(queues, 'a', async () => {
events.push('second')
})
const other = runKeyedSerializedOperation(queues, 'b', async () => {
events.push('other')
})
await other
expect(events).toEqual(['first-start', 'other'])
release()
await Promise.all([first, second])
expect(events).toEqual(['first-start', 'other', 'first-end', 'second'])
await expect(getKeyedSerializedQueueTail(queues, 'a')).resolves.toBeUndefined()
expect(queues.size).toBe(0)
})
})
+33
View File
@@ -0,0 +1,33 @@
/**
* Serializes async operations per key. The stored queue tail never rejects, so
* callers may await it (e.g. read-after-write barriers) without inheriting an
* unrelated operation's failure; rejections still propagate to the enqueuer.
*/
export function runKeyedSerializedOperation<T>(
queues: Map<string, Promise<void>>,
key: string,
operation: () => Promise<T>
): Promise<T> {
const previous = queues.get(key) ?? Promise.resolve()
const current = previous.then(operation)
const queued = current.then(
() => undefined,
() => undefined
)
queues.set(key, queued)
const clear = (): void => {
if (queues.get(key) === queued) {
queues.delete(key)
}
}
queued.then(clear, clear)
return current
}
export function getKeyedSerializedQueueTail(
queues: Map<string, Promise<void>>,
key: string
): Promise<void> {
return queues.get(key) ?? Promise.resolve()
}
+458 -17
View File
@@ -1,22 +1,28 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
execFile: execFileMock
}))
import { WslCliInstaller, _internals } from './wsl-cli-installer'
import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation'
function makeHostStatus(
launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
) {
return {
platform: 'win32',
commandName: 'orca',
commandPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin',
commandPath: launcherPath,
pathDirectory: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin',
pathConfigured: true,
launcherPath,
installMethod: 'wrapper',
@@ -28,13 +34,51 @@ function makeHostStatus(
} satisfies CliInstallStatus
}
function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin = true) {
// Frozen from v1.4.138-rc.2: the upgrade regression only reproduces when the
// persisted managed script still names the pre-native Windows batch launcher.
const PRE_RC4_MANAGED_WSL_LAUNCHER = `#!/usr/bin/env bash
set -euo pipefail
# Orca managed WSL CLI launcher
# ORCA_WIN_LAUNCHER_B64=QzpcUHJvZ3JhbSBGaWxlc1xPcmNhXHJlc291cmNlc1xiaW5cb3JjYS5jbWQ=
ORCA_WIN_LAUNCHER='C:\\Program Files\\Orca\\resources\\bin\\orca.cmd'
ORCA_BRIDGE_PS1='/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
if command -v powershell.exe >/dev/null 2>&1; then
ORCA_POWERSHELL=powershell.exe
elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then
ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe
else
echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2
exit 1
fi
ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
`
function createWslRunner(
initialFile: string | null = null,
pathIncludesLocalBin = true,
options: {
initialBridge?: string | null
initialLegacyFile?: string | null
failInstall?: boolean
interopReady?: boolean
} = {}
) {
const commandPath = '/home/alice/.local/bin/orca-ide'
const legacyCommandPath = '/home/alice/.local/bin/orca'
const bridgePath = '/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
const files = new Map<string, string>()
if (initialFile !== null) {
files.set(commandPath, initialFile)
files.set(bridgePath, _internals.buildWslBridgeScript())
}
if (
options.initialBridge !== null &&
(initialFile !== null || options.initialBridge !== undefined)
) {
files.set(bridgePath, options.initialBridge ?? _internals.buildWslBridgeScript())
}
if (options.initialLegacyFile) {
files.set(legacyCommandPath, options.initialLegacyFile)
}
const calls: string[] = []
const runner = vi.fn(async (_distro: string, command: string) => {
@@ -46,6 +90,15 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
return pathIncludesLocalBin ? 'yes' : 'no'
}
if (command.includes('cat > "$command_tmp"')) {
if (options.failInstall) {
throw new Error('simulated replacement failure')
}
if (
files.has(bridgePath) &&
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
) {
throw new Error('__ORCA_CONFLICT__')
}
const launcher =
command.match(/cat > "\$command_tmp" <<'ORCA_WSL_CLI'\n([\s\S]*)\nORCA_WSL_CLI/)?.[1] ?? ''
const bridge =
@@ -54,20 +107,31 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
)?.[1] ?? ''
files.set(commandPath, launcher)
files.set(bridgePath, bridge)
if (files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')) {
files.delete(legacyCommandPath)
}
return ''
}
if (command.includes('command -v powershell.exe')) {
return 'yes'
return options.interopReady === false ? 'no' : 'yes'
}
if (command.includes('rm -f')) {
if (command.includes(`rm -f '${commandPath}'`)) {
if (
files.has(bridgePath) &&
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
) {
throw new Error('__ORCA_CONFLICT__')
}
files.delete(commandPath)
files.delete(bridgePath)
}
if (
files.has(bridgePath) &&
!files.get(bridgePath)?.includes('# Orca managed WSL CLI PowerShell bridge')
command.includes(legacyCommandPath) &&
files.get(legacyCommandPath)?.includes('# Orca managed WSL CLI launcher')
) {
throw new Error('__ORCA_CONFLICT__')
files.delete(legacyCommandPath)
}
files.delete(commandPath)
files.delete(bridgePath)
return ''
}
if (command.includes('cat ')) {
@@ -77,6 +141,9 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
if (command.includes(bridgePath)) {
return files.get(bridgePath) ?? '__ORCA_MISSING__'
}
if (command.includes(legacyCommandPath)) {
return files.get(legacyCommandPath) ?? '__ORCA_MISSING__'
}
}
throw new Error(`Unexpected WSL command: ${command}`)
})
@@ -84,7 +151,8 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
runner,
calls,
getBridge: () => files.get(bridgePath) ?? null,
getFile: () => files.get(commandPath) ?? null
getFile: () => files.get(commandPath) ?? null,
getLegacyFile: () => files.get(legacyCommandPath) ?? null
}
}
@@ -116,11 +184,11 @@ describe('WslCliInstaller', () => {
expect(installed).toMatchObject({
state: 'installed',
pathConfigured: true,
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
expect(wsl.getFile()).toBe(
_internals.buildWslLauncher(
'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd',
'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
)
@@ -128,6 +196,7 @@ describe('WslCliInstaller', () => {
const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"'))
expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'")
expect(installCommand).toContain('rm -f "$legacy_command_path"')
expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]')
})
it('derives the shared WSL bridge path for current and legacy command names', () => {
@@ -291,15 +360,387 @@ describe('WslCliInstaller', () => {
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'stale',
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
await expect(installer.install()).resolves.toMatchObject({
state: 'installed',
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.exe'
})
})
it('repairs the frozen pre-rc4 registration so orchestration send/reply reach native rc4', async () => {
const nativeLauncher = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe'
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: wsl.runner
})
const orchestrationCalls = [
['orchestration', 'send', '--type', 'heartbeat'],
['orchestration', 'send', '--type', 'worker_done'],
['orchestration', 'reply', '--message', 'line one\nline two']
]
const simulateRc4Launch = (args: string[]): number => {
const target = _internals.parseManagedLauncherTarget(wsl.getFile() ?? '')
return target?.toLowerCase().endsWith('orca.cmd') &&
args[0] === 'orchestration' &&
(args[1] === 'send' || args[1] === 'reply')
? 2
: 0
}
expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([2, 2, 2])
await expect(
reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
registry: {
getCandidates: async () => ['Ubuntu'],
recordObservations: async () => undefined
},
createInstaller: () => installer
})
).resolves.toEqual([
{ distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }
])
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'installed',
currentTarget: nativeLauncher
})
expect(orchestrationCalls.map(simulateRc4Launch)).toEqual([0, 0, 0])
})
it('leaves unmanaged WSL commands and conflicting bridges untouched during automatic repair', async () => {
const unmanaged = '#!/usr/bin/env bash\necho user-owned\n'
const wsl = createWslRunner(unmanaged)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
status: { state: 'conflict' }
})
expect(wsl.getFile()).toBe(unmanaged)
expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false)
})
it('repairs a managed launcher whose bridge is missing, but preserves a conflicting bridge', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const missingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: null
})
const missingBridgeInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: missingBridge.runner
})
await expect(missingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: true,
status: { state: 'installed' }
})
expect(missingBridge.getBridge()).toBe(_internals.buildWslBridgeScript())
const staleBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: '# Orca managed WSL CLI PowerShell bridge\nWrite-Output "stale"\n'
})
const staleBridgeInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: staleBridge.runner
})
await expect(staleBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: true,
status: { state: 'installed' }
})
expect(staleBridge.getBridge()).toBe(_internals.buildWslBridgeScript())
const conflictingBridge = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: 'Write-Output "user-owned bridge"\n'
})
const conflictingBridgeInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: conflictingBridge.runner
})
// Why: a stale launcher with a user-owned bridge must surface as a
// non-throwing conflict, not retry a doomed install on every startup.
await expect(conflictingBridgeInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
managed: true,
status: { state: 'conflict' }
})
expect(conflictingBridge.getBridge()).toBe('Write-Output "user-owned bridge"\n')
expect(conflictingBridge.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
expect(
conflictingBridge.calls.some((command) => command.includes('cat > "$command_tmp"'))
).toBe(false)
})
it('retains command ownership when only the bridge conflicts', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const currentLauncher = _internals.buildWslLauncher(
nativeLauncher,
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
const wsl = createWslRunner(currentLauncher, true, {
initialBridge: 'Write-Output "user-owned bridge"\n'
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
managed: true,
status: { state: 'conflict' }
})
expect(wsl.getBridge()).toBe('Write-Output "user-owned bridge"\n')
expect(wsl.getFile()).toBe(currentLauncher)
})
it('moves a legacy-only managed registration to orca-ide without touching unmanaged names', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const managedLegacy = createWslRunner(null, true, {
initialBridge: _internals.buildWslBridgeScript(),
initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: managedLegacy.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: true,
status: { state: 'installed', currentTarget: nativeLauncher }
})
expect(managedLegacy.getLegacyFile()).toBeNull()
const unmanagedLegacy = createWslRunner(null, true, {
initialLegacyFile: '#!/bin/sh\necho user-owned\n'
})
const unmanagedInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: unmanagedLegacy.runner
})
await expect(unmanagedInstaller.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
status: { state: 'not_installed' }
})
expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n')
})
it('does not adopt a legacy-managed registration when the bridge is user-owned', async () => {
const wsl = createWslRunner(null, true, {
initialBridge: 'Write-Output "user-owned bridge"\n',
initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
// Why: adoption would fail install()'s bridge guard on every startup;
// repair must report blocked-but-managed instead of a doomed install.
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
managed: true,
status: { state: 'not_installed' }
})
expect(wsl.getLegacyFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
expect(wsl.calls.some((command) => command.includes('cat > "$command_tmp"'))).toBe(false)
})
it('removes the managed legacy launcher on removal so reconciliation cannot re-adopt it', async () => {
const nativeLauncher = 'C:\\Orca\\resources\\bin\\orca.exe'
const managedLegacy = createWslRunner(null, true, {
initialBridge: _internals.buildWslBridgeScript(),
initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER
})
const managedLegacyInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: managedLegacy.runner
})
await expect(managedLegacyInstaller.remove()).resolves.toMatchObject({
state: 'not_installed'
})
expect(managedLegacy.getLegacyFile()).toBeNull()
const unmanagedLegacy = createWslRunner(null, true, {
initialLegacyFile: '#!/bin/sh\necho user-owned\n'
})
const unmanagedInstaller2 = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: unmanagedLegacy.runner
})
await expect(unmanagedInstaller2.remove()).resolves.toMatchObject({
state: 'not_installed'
})
expect(unmanagedLegacy.getLegacyFile()).toBe('#!/bin/sh\necho user-owned\n')
const installedWithLegacy = createWslRunner(
_internals.buildWslLauncher(
nativeLauncher,
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
),
true,
{ initialLegacyFile: PRE_RC4_MANAGED_WSL_LAUNCHER }
)
const installedInstaller = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: installedWithLegacy.runner
})
await expect(installedInstaller.remove()).resolves.toMatchObject({ state: 'not_installed' })
expect(installedWithLegacy.getFile()).toBeNull()
expect(installedWithLegacy.getLegacyFile()).toBeNull()
})
it('keeps the pre-rc4 files on a transactional replacement failure', async () => {
const bridge = _internals.buildWslBridgeScript()
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, {
initialBridge: bridge,
failInstall: true
})
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: {
getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe')
},
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).rejects.toThrow(
'simulated replacement failure'
)
expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
expect(wsl.getBridge()).toBe(bridge)
const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"'))
expect(installCommand).toContain('rollback() {')
expect(installCommand).toContain('set +e')
expect(installCommand).toContain('bridge_backup="${bridge_tmp}.backup"')
expect(installCommand).toContain('cp -p')
expect(installCommand).toContain('elif [ "$bridge_touched" -eq 1 ]')
expect(installCommand).toContain('committed=1')
expect(installCommand).toContain('flock -x -w 30 9')
// Why: the command replace must stay one atomic rename; a mv-based backup
// would leave a window where a concurrent shell finds no orca-ide at all.
expect(installCommand).not.toContain('command_backup')
expect(installCommand).not.toContain(`mv -f '/home/alice/.local/bin/orca-ide'`)
})
it.skipIf(process.platform === 'win32')(
'rolls both files back when the command replacement fails after the bridge move',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-rollback-'))
const home = join(root, 'home with spaces')
const commandPath = join(home, '.local', 'bin', 'orca-ide')
const bridgePath = join(home, '.local', 'share', 'orca', 'orca-wsl-bridge.ps1')
const bridge = _internals.buildWslBridgeScript()
await mkdir(join(home, '.local', 'bin'), { recursive: true })
await mkdir(join(home, '.local', 'share', 'orca'), { recursive: true })
await writeFile(commandPath, PRE_RC4_MANAGED_WSL_LAUNCHER, 'utf8')
await writeFile(bridgePath, bridge, 'utf8')
const runner = async (_distro: string, command: string): Promise<string> => {
if (command.includes('printf %s "$HOME"')) {
return home
}
if (command.includes('cat > "$command_tmp"')) {
const executableCommand = command
.split('\n')
.map((line) => (line.startsWith('mv -f "$command_tmp" ') ? 'exit 71' : line))
.join('\n')
return execFileSync('bash', ['-c', executableCommand], { encoding: 'utf8' })
}
if (command.includes('command -v powershell.exe')) {
return 'yes'
}
if (command.includes('case ":$PATH:"')) {
return 'yes'
}
return execFileSync('bash', ['-c', command], { encoding: 'utf8' })
}
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: {
getStatus: async () => makeHostStatus('C:\\Program Files\\Orca\\resources\\bin\\orca.exe')
},
wslRunner: runner
})
try {
await expect(installer.repairManagedRegistration()).rejects.toThrow()
await expect(readFile(commandPath, 'utf8')).resolves.toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
await expect(readFile(bridgePath, 'utf8')).resolves.toBe(bridge)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
it('skips automatic repair when WSL interop is unavailable', async () => {
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER, true, { interopReady: false })
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({
changed: false,
status: { state: 'unsupported' }
})
expect(wsl.getFile()).toBe(PRE_RC4_MANAGED_WSL_LAUNCHER)
})
it('is idempotent after repairing an old managed registration', async () => {
const nativeLauncher = 'D:\\Custom Orca\\resources\\bin\\orca.exe'
const wsl = createWslRunner(PRE_RC4_MANAGED_WSL_LAUNCHER)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus(nativeLauncher) },
wslRunner: wsl.runner
})
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: true })
await expect(installer.repairManagedRegistration()).resolves.toMatchObject({ changed: false })
expect(wsl.calls.filter((command) => command.includes('cat > "$command_tmp"'))).toHaveLength(1)
expect(wsl.getFile()).toContain("ORCA_WIN_LAUNCHER='D:\\Custom Orca\\resources\\bin\\orca.exe'")
})
it('settles when wsl.exe never reports completion', async () => {
vi.useFakeTimers()
const killMock = vi.fn()
+112 -12
View File
@@ -5,6 +5,8 @@ import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getDefaultWslDistro } from '../wsl'
import { CliInstaller } from './cli-installer'
import {
buildManagedLegacyRemoveCommand,
buildRegistrationLockPrelude,
buildSafeRemoveCommand,
buildSafeReplaceGuard,
buildWslBridgeScript,
@@ -38,6 +40,12 @@ type WslCliInstallerOptions = {
wslRunner?: (distro: string, command: string) => Promise<string>
}
export type ManagedWslCliRepairResult = {
changed: boolean
managed: boolean
status: CliInstallStatus
}
export class WslCliInstaller {
private readonly platform: NodeJS.Platform
private readonly distro: string | null
@@ -119,21 +127,80 @@ export class WslCliInstaller {
})
}
// Why: a stale managed launcher is only repairable when its bridge is
// ours too; reporting conflict here keeps repair from a doomed install
// whose bridge guard would fail on every startup.
const bridgeConflict = managed && (await this.isBridgeConflict(ready.distro, ready.bridgePath))
return this.buildStatus({
distro: ready.distro,
commandPath: ready.commandPath,
launcherPath: ready.launcherPath,
state: managed ? 'stale' : 'conflict',
state: managed && !bridgeConflict ? 'stale' : 'conflict',
currentTarget,
pathConfigured: ready.pathConfigured,
detail: managed
? `${ready.commandPath} points to a different Orca launcher.`
: `${ready.commandPath} exists but is not managed by Orca.`
detail: !managed
? `${ready.commandPath} exists but is not managed by Orca.`
: bridgeConflict
? `${ready.bridgePath} exists but is not managed by Orca.`
: `${ready.commandPath} points to a different Orca launcher.`
})
}
async install(): Promise<CliInstallStatus> {
private async isBridgeConflict(distro: string, bridgePath: string): Promise<boolean> {
const bridgeContent = await this.readCommandFile(distro, bridgePath)
if (bridgeContent === null) {
return false
}
return bridgeContent === 'not_file' || !bridgeContent.includes(BRIDGE_MANAGED_MARKER)
}
async repairManagedRegistration(): Promise<ManagedWslCliRepairResult> {
const status = await this.getStatus()
if (!status.supported) {
return { changed: false, managed: false, status }
}
if (status.state === 'conflict') {
// Why: a user-owned bridge conflicts with repair, but the launcher is
// still Orca-managed and must remain registered for future reconciliation.
return { changed: false, managed: status.currentTarget !== null, status }
}
if (status.state === 'stale') {
return { changed: true, managed: true, status: await this.install(status) }
}
const legacyCommandPath = status.commandPath
? `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
: null
if (!legacyCommandPath || !this.distro) {
return { changed: false, managed: status.state === 'installed', status }
}
const legacyContent = await this.readCommandFile(this.distro, legacyCommandPath)
const legacyManaged =
typeof legacyContent === 'string' && legacyContent.includes(MANAGED_MARKER)
if (!legacyManaged) {
return { changed: false, managed: status.state === 'installed', status }
}
if (
status.commandPath &&
(await this.isBridgeConflict(this.distro, getBridgePathFromCommandPath(status.commandPath)))
) {
// Why: adopting the legacy command would fail install()'s bridge guard
// forever; stay registered so reconciliation retries after an update.
return { changed: false, managed: true, status }
}
// Why: a legacy-only managed command proves the user opted into WSL CLI
// registration; install the current name before removing that owned script.
return { changed: true, managed: true, status: await this.install(status) }
}
async install(precomputedStatus?: CliInstallStatus): Promise<CliInstallStatus> {
// Why: repair passes its fresh probe; re-probing here would double every
// WSL round trip on the startup reconciliation path.
const status = precomputedStatus ?? (await this.getStatus())
if (!status.supported || !status.commandPath || !status.launcherPath) {
throw new Error(status.detail ?? 'WSL CLI registration is unavailable.')
}
@@ -141,20 +208,36 @@ export class WslCliInstaller {
throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`)
}
// Why: the launcher and PowerShell bridge are one registration; the
// command replacement stays a single atomic rename (never missing for a
// concurrent shell) while a bridge copy enables rollback of the pair.
await this.run(
this.distro as string,
[
'set -euo pipefail',
`mkdir -p ${quoteShell(status.pathDirectory as string)}`,
`mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`,
buildRegistrationLockPrelude(status.commandPath),
`command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`,
`bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
`legacy_command_path=${quoteShell(
`${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
)}`,
'bridge_tmp="${bridge_path}.tmp.$$"',
'cleanup() { rm -f "$command_tmp" "$bridge_tmp"; }',
'trap cleanup EXIT',
'bridge_backup="${bridge_tmp}.backup"',
'bridge_had_original=0',
'bridge_touched=0',
'committed=0',
'rollback() {',
' result=$?',
' set +e',
' if [ "$committed" -ne 1 ]; then',
` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`,
' fi',
' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"',
' exit "$result"',
'}',
'trap rollback EXIT',
buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(
getBridgePathFromCommandPath(status.commandPath),
@@ -173,11 +256,15 @@ export class WslCliInstaller {
getBridgePathFromCommandPath(status.commandPath),
BRIDGE_MANAGED_MARKER
),
// Why: the command was renamed to avoid GNOME Orca; remove only the
// old Orca-managed WSL wrapper so unmanaged `orca` commands survive.
`if [ -f "$legacy_command_path" ] && grep -Fq ${quoteShell(MANAGED_MARKER)} "$legacy_command_path"; then rm -f "$legacy_command_path"; fi`,
`if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`,
`mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`,
'bridge_touched=1',
`mv -f "$command_tmp" ${quoteShell(status.commandPath)}`,
'committed=1',
'rm -f "$bridge_backup"',
// Why: the command was renamed to avoid GNOME Orca; remove only the
// old Orca-managed WSL wrapper after the replacement has committed.
buildManagedLegacyRemoveCommand('"$legacy_command_path"'),
'trap - EXIT'
].join('\n')
)
@@ -189,14 +276,26 @@ export class WslCliInstaller {
if (!status.supported || !status.commandPath) {
return status
}
const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}`
if (status.state === 'not_installed') {
// Why: a managed legacy `orca` left behind would later be re-adopted by
// startup reconciliation as opt-in proof, silently undoing this removal.
await this.run(
this.distro as string,
['set -euo pipefail', buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))].join(
'\n'
)
)
return status
}
if (status.state === 'conflict') {
throw new Error(`Refusing to remove non-Orca command at ${status.commandPath}.`)
}
await this.run(this.distro as string, buildSafeRemoveCommand(status.commandPath))
await this.run(
this.distro as string,
buildSafeRemoveCommand(status.commandPath, legacyCommandPath)
)
return this.getStatus()
}
@@ -411,5 +510,6 @@ export const _internals = {
buildEncodedWslBashCommand,
buildWslBridgeScript,
buildWslLauncher,
getBridgePathFromCommandPath
getBridgePathFromCommandPath,
parseManagedLauncherTarget
}
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
describe('runSerializedWslCliRegistrationOperation', () => {
it('serializes operations for distro names with different casing', async () => {
let releaseFirst!: () => void
const events: string[] = []
const first = runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
events.push('first-start')
await new Promise<void>((resolve) => {
releaseFirst = resolve
})
events.push('first-end')
})
await vi.waitFor(() => expect(events).toEqual(['first-start']))
const second = runSerializedWslCliRegistrationOperation(' ubuntu ', async () => {
events.push('second')
})
await Promise.resolve()
expect(events).toEqual(['first-start'])
releaseFirst()
await Promise.all([first, second])
expect(events).toEqual(['first-start', 'first-end', 'second'])
})
it('allows different distros to progress independently', async () => {
let releaseUbuntu!: () => void
const events: string[] = []
const ubuntu = runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
events.push('ubuntu-start')
await new Promise<void>((resolve) => {
releaseUbuntu = resolve
})
})
const debian = runSerializedWslCliRegistrationOperation('Debian', async () => {
events.push('debian')
})
await debian
expect(events).toEqual(['ubuntu-start', 'debian'])
releaseUbuntu()
await ubuntu
})
it('releases the queue after an operation fails', async () => {
await expect(
runSerializedWslCliRegistrationOperation('Ubuntu', async () => {
throw new Error('interop failed')
})
).rejects.toThrow('interop failed')
await expect(
runSerializedWslCliRegistrationOperation('ubuntu', async () => 'recovered')
).resolves.toBe('recovered')
})
})
@@ -0,0 +1,23 @@
import { runKeyedSerializedOperation } from './keyed-promise-queue'
const operationQueues = new Map<string, Promise<void>>()
/**
* Canonical key for a WSL distro name. The operation queue and the
* registration registry must agree on this to serialize against each other.
*/
export function normalizeWslDistroKey(distro: string): string {
return distro.trim().toLowerCase()
}
/**
* Serializes registration reads and mutations for one WSL distro.
*/
export function runSerializedWslCliRegistrationOperation<T>(
distro: string,
operation: () => Promise<T>
): Promise<T> {
// Why: startup repair continues in the background and can otherwise undo a
// concurrent Settings install/remove or overwrite its ownership metadata.
return runKeyedSerializedOperation(operationQueues, normalizeWslDistroKey(distro), operation)
}
@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from 'vitest'
import { reconcileManagedWslCliRegistrations } from './wsl-cli-registration-reconciliation'
import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
describe('reconcileManagedWslCliRegistrations', () => {
it('repairs known and newly discovered registrations, then records ownership', async () => {
const registry = {
getCandidates: vi.fn(async () => ['Ubuntu', 'Debian']),
recordObservations: vi.fn(async () => undefined)
}
const repairUbuntu = vi.fn(async () => ({
changed: true,
managed: true,
status: { state: 'installed' as const }
}))
const repairDebian = vi.fn(async () => ({
changed: false,
managed: false,
status: { state: 'not_installed' as const }
}))
const results = await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu', 'Debian', 'Fedora'],
registry,
createInstaller: (distro) => {
if (distro === 'Ubuntu') {
return { repairManagedRegistration: repairUbuntu }
}
return { repairManagedRegistration: repairDebian }
}
})
expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu', 'Debian', 'Fedora'], {
currentTarget: null,
appVersion: ''
})
expect(registry.recordObservations).toHaveBeenCalledTimes(2)
expect(registry.recordObservations).toHaveBeenCalledWith([
{ distro: 'Ubuntu', inspected: true, managed: true }
])
expect(registry.recordObservations).toHaveBeenCalledWith([
{ distro: 'Debian', inspected: true, managed: false, reconciled: null }
])
expect(results).toEqual([
{ distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true },
{ distro: 'Debian', outcome: 'unchanged', state: 'not_installed', managed: false }
])
})
it('passes the host launcher target through and records reconciliations against it', async () => {
const registry = {
getCandidates: vi.fn(async () => ['Ubuntu']),
recordObservations: vi.fn(async () => undefined)
}
await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
appVersion: '1.4.138',
listDistros: async () => ['Ubuntu'],
getHostLauncherTarget: async () => 'C:\\Orca\\resources\\bin\\orca.exe',
registry,
createInstaller: () => ({
repairManagedRegistration: async () => ({
changed: true,
managed: true,
status: { state: 'installed' as const }
})
})
})
expect(registry.getCandidates).toHaveBeenCalledWith(['Ubuntu'], {
currentTarget: 'C:\\Orca\\resources\\bin\\orca.exe',
appVersion: '1.4.138'
})
expect(registry.recordObservations).toHaveBeenCalledWith([
{
distro: 'Ubuntu',
inspected: true,
managed: true,
reconciled: { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' }
}
])
})
it('records unsupported distros without changing ownership and skips failed ones', async () => {
const registry = {
getCandidates: vi.fn(async () => ['Broken Distro', 'No Interop']),
recordObservations: vi.fn(async () => undefined)
}
const results = await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Broken Distro', 'No Interop'],
registry,
createInstaller: (distro) => ({
repairManagedRegistration: async () => {
if (distro === 'Broken Distro') {
throw new Error('WSL interop failed')
}
return {
changed: false,
managed: false,
status: { state: 'unsupported' as const }
}
}
})
})
expect(results).toEqual([
{ distro: 'Broken Distro', outcome: 'failed', error: 'WSL interop failed' },
{ distro: 'No Interop', outcome: 'unchanged', state: 'unsupported', managed: false }
])
expect(registry.recordObservations).toHaveBeenCalledTimes(1)
// Why: unsupported gets a TTL-stamped inspection (managed: null) so an
// interop-off distro is not re-probed and VM-booted on every startup.
expect(registry.recordObservations).toHaveBeenCalledWith([
{ distro: 'No Interop', inspected: true, managed: null, reconciled: null }
])
})
it('keeps a successful repair result when observation recording fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const results = await reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
registry: {
getCandidates: async () => ['Ubuntu'],
recordObservations: async () => {
throw new Error('ENOSPC')
}
},
createInstaller: () => ({
repairManagedRegistration: async () => ({
changed: true,
managed: true,
status: { state: 'installed' as const }
})
})
})
expect(results).toEqual([
{ distro: 'Ubuntu', outcome: 'repaired', state: 'installed', managed: true }
])
expect(warn).toHaveBeenCalledOnce()
} finally {
warn.mockRestore()
}
})
it.each([
{ platform: 'darwin' as const, isPackaged: true },
{ platform: 'linux' as const, isPackaged: true },
{ platform: 'win32' as const, isPackaged: false }
])('does not inspect local, SSH, or development hosts for $platform', async (host) => {
const listDistros = vi.fn(async () => ['Ubuntu'])
await expect(
reconcileManagedWslCliRegistrations({
...host,
userDataPath: '/user-data',
listDistros
})
).resolves.toEqual([])
expect(listDistros).not.toHaveBeenCalled()
})
it('lets a Settings removal win over a late startup repair for the same distro', async () => {
const events: string[] = []
let repairStarted!: () => void
let finishRepair!: () => void
const started = new Promise<void>((resolve) => {
repairStarted = resolve
})
const registry = {
getCandidates: vi.fn(async () => ['Ubuntu']),
recordObservations: vi.fn(async () => {
events.push('repair-observed')
})
}
const reconciliation = reconcileManagedWslCliRegistrations({
platform: 'win32',
isPackaged: true,
userDataPath: '/user-data',
listDistros: async () => ['Ubuntu'],
registry,
createInstaller: () => ({
repairManagedRegistration: async () => {
events.push('repair-started')
repairStarted()
await new Promise<void>((resolve) => {
finishRepair = resolve
})
events.push('repair-finished')
return {
changed: true,
managed: true,
status: { state: 'installed' as const }
}
}
})
})
await started
const removal = runSerializedWslCliRegistrationOperation('ubuntu', async () => {
events.push('settings-remove')
})
await Promise.resolve()
expect(events).toEqual(['repair-started'])
finishRepair()
await Promise.all([reconciliation, removal])
expect(events).toEqual([
'repair-started',
'repair-finished',
'repair-observed',
'settings-remove'
])
})
})
@@ -0,0 +1,175 @@
import type { CliInstallState, CliInstallStatus } from '../../shared/cli-install-types'
import { listWslDistrosAsync } from '../wsl'
import { CliInstaller } from './cli-installer'
import {
getWslCliRegistrationCandidates,
recordWslCliRegistrationObservations,
type WslCliRegistrationObservation
} from './wsl-cli-registration-registry'
import { WslCliInstaller } from './wsl-cli-installer'
import { runSerializedWslCliRegistrationOperation } from './wsl-cli-registration-operation'
// Why: candidate distros can each boot a stopped WSL VM; a small cap staggers
// those boots instead of spiking RAM/CPU for every distro at once at startup.
const MAX_CONCURRENT_DISTRO_REPAIRS = 2
type ManagedWslCliInstaller = {
repairManagedRegistration: () => Promise<{
changed: boolean
managed: boolean
status: { state: CliInstallState }
}>
}
type WslCliRegistrationCandidateContext = {
currentTarget?: string | null
appVersion?: string | null
}
type WslCliRegistrationRegistry = {
getCandidates: (
availableDistros: string[],
context?: WslCliRegistrationCandidateContext
) => Promise<string[]>
recordObservations: (observations: WslCliRegistrationObservation[]) => Promise<void>
}
type WslCliRegistrationReconciliationOptions = {
platform?: NodeJS.Platform
isPackaged: boolean
userDataPath: string
appVersion?: string
listDistros?: () => Promise<string[]>
createInstaller?: (distro: string) => ManagedWslCliInstaller
getHostLauncherTarget?: () => Promise<string | null>
registry?: WslCliRegistrationRegistry
}
export type WslCliRegistrationReconciliationResult =
| {
distro: string
outcome: 'repaired' | 'unchanged'
state: CliInstallState
managed: boolean
}
| {
distro: string
outcome: 'failed'
error: string
}
export async function reconcileManagedWslCliRegistrations(
options: WslCliRegistrationReconciliationOptions
): Promise<WslCliRegistrationReconciliationResult[]> {
const platform = options.platform ?? process.platform
if (platform !== 'win32' || !options.isPackaged) {
return []
}
const registry =
options.registry ??
({
getCandidates: (availableDistros, context) =>
getWslCliRegistrationCandidates(options.userDataPath, availableDistros, context ?? {}),
recordObservations: (observations) =>
recordWslCliRegistrationObservations(options.userDataPath, observations)
} satisfies WslCliRegistrationRegistry)
let createInstaller = options.createInstaller
let getHostLauncherTarget = options.getHostLauncherTarget
if (!createInstaller) {
const hostInstaller = new CliInstaller()
let hostStatus: Promise<CliInstallStatus> | null = null
// Why: every distro must target this app install; share one Windows PATH /
// launcher probe instead of spawning a PowerShell probe per distro. A
// rejected probe is evicted so one transient failure cannot poison the run.
const getHostStatus = (): Promise<CliInstallStatus> =>
(hostStatus ??= hostInstaller.getStatus().catch((error) => {
hostStatus = null
throw error
}))
createInstaller = (distro: string) =>
new WslCliInstaller({
distro,
hostInstaller: { getStatus: getHostStatus }
})
getHostLauncherTarget ??= () => getHostStatus().then((status) => status.launcherPath)
}
const availableDistros = await (options.listDistros ?? listWslDistrosAsync)()
const currentTarget = getHostLauncherTarget
? await getHostLauncherTarget().catch(() => null)
: null
const appVersion = options.appVersion ?? ''
const distros = await registry.getCandidates(availableDistros, { currentTarget, appVersion })
if (distros.length === 0) {
return []
}
const reconcileDistro = async (
distro: string
): Promise<WslCliRegistrationReconciliationResult> => {
let repair: Awaited<ReturnType<ManagedWslCliInstaller['repairManagedRegistration']>>
try {
repair = await createInstaller(distro).repairManagedRegistration()
} catch (error) {
return {
distro,
outcome: 'failed',
error: error instanceof Error ? error.message : String(error)
}
}
const result: WslCliRegistrationReconciliationResult = {
distro,
outcome: repair.changed ? 'repaired' : 'unchanged',
state: repair.status.state,
managed: repair.managed
}
const observation: WslCliRegistrationObservation =
repair.status.state === 'unsupported'
? // Why: managed-ness is unknowable without interop; stamp the
// inspection (negative TTL) without changing registration ownership.
{ distro, inspected: true, managed: null, reconciled: null }
: repair.managed
? {
distro,
inspected: true,
managed: true,
...(currentTarget ? { reconciled: { target: currentTarget, appVersion } } : {})
}
: { distro, inspected: true, managed: false, reconciled: null }
try {
// Why: ownership metadata must commit before a concurrent Settings
// operation can mutate this distro, or stale startup state can win.
await registry.recordObservations([observation])
} catch (error) {
// Why: the repair already succeeded on disk; an advisory bookkeeping
// failure must not reclassify it, mirroring the Settings IPC contract.
console.warn(
`[wsl-cli] Failed to record ${distro} registration observation:`,
error instanceof Error ? error.message : String(error)
)
}
return result
}
const results: WslCliRegistrationReconciliationResult[] = Array.from({
length: distros.length
})
let nextIndex = 0
await Promise.all(
Array.from({ length: Math.min(MAX_CONCURRENT_DISTRO_REPAIRS, distros.length) }, async () => {
for (;;) {
const index = nextIndex++
if (index >= distros.length) {
return
}
const distro = distros[index]
results[index] = await runSerializedWslCliRegistrationOperation(distro, () =>
reconcileDistro(distro)
)
}
})
)
return results
}
@@ -0,0 +1,233 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
getWslCliRegistrationCandidates,
recordWslCliRegistrationObservations,
recordWslCliRegistrationRemoved
} from './wsl-cli-registration-registry'
describe('WSL CLI registration registry', () => {
let userDataPath: string
beforeEach(async () => {
userDataPath = await mkdtemp(join(tmpdir(), 'orca-wsl-cli-registry-'))
})
afterEach(async () => {
await rm(userDataPath, { recursive: true, force: true })
})
it('discovers each distro once while continuing to reconcile managed registrations', async () => {
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
).resolves.toEqual(['Ubuntu', 'Debian'])
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: false },
{ distro: 'Debian', inspected: true, managed: true }
])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['ubuntu', 'Debian', 'Fedora'])
).resolves.toEqual(['Debian', 'Fedora'])
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as Record<string, unknown>
expect(state).toMatchObject({
schemaVersion: 2,
registeredDistros: ['Debian'],
inspectionTimes: {
ubuntu: expect.any(Number),
debian: expect.any(Number)
}
})
})
it('skips a registered distro already reconciled by this build against this launcher', async () => {
const reconciled = { target: 'C:\\Orca\\resources\\bin\\orca.exe', appVersion: '1.4.138' }
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true, reconciled }
])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: reconciled.target,
appVersion: reconciled.appVersion
})
).resolves.toEqual([])
// A launcher move or app update re-probes the registered distro.
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: 'D:\\Elsewhere\\orca.exe',
appVersion: reconciled.appVersion
})
).resolves.toEqual(['Ubuntu'])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: reconciled.target,
appVersion: '1.4.139'
})
).resolves.toEqual(['Ubuntu'])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
currentTarget: reconciled.target,
appVersion: reconciled.appVersion,
now: Date.now() + 365 * 24 * 60 * 60 * 1_000
})
).resolves.toEqual([])
})
it('records unsupported inspections without changing registration ownership', async () => {
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true }
])
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Ubuntu', inspected: true, managed: null, reconciled: null }],
{ now: 1_000 }
)
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as { registeredDistros: string[] }
expect(state.registeredDistros).toEqual(['Ubuntu'])
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Fedora', inspected: true, managed: null }],
{ now: 1_000 }
)
// Unregistered unsupported distros gain the negative-inspection TTL.
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Fedora'], {
now: 2_000,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual([])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Fedora'], {
now: 12_000,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual(['Fedora'])
})
it('serializes concurrent registry updates without losing a distro', async () => {
const updates = Promise.all([
recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true }
]),
recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Debian', inspected: true, managed: true }
])
])
// Reads join the write queue, so startup cannot observe a half-updated registry.
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
).resolves.toEqual(['Ubuntu', 'Debian'])
await updates
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as { registeredDistros: string[] }
expect(state.registeredDistros).toEqual(['Ubuntu', 'Debian'])
})
it('rediscovers available distros when the registry is corrupt', async () => {
await mkdir(userDataPath, { recursive: true })
await writeFile(join(userDataPath, 'wsl-cli-registrations.json'), '{broken', 'utf8')
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'])
).resolves.toEqual(['Ubuntu', 'Debian'])
})
it('stops reconciling a registration removed through Settings', async () => {
await recordWslCliRegistrationObservations(userDataPath, [
{ distro: 'Ubuntu', inspected: true, managed: true }
])
await recordWslCliRegistrationRemoved(userDataPath, 'ubuntu')
await expect(getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'])).resolves.toEqual([])
})
it('periodically re-inspects a negative entry so restored distros are discovered', async () => {
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Ubuntu', inspected: true, managed: false }],
{ now: 1_000 }
)
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
now: 1_001,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual([])
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
now: 11_001,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual(['Ubuntu'])
})
it('rediscovers negative entries after the system clock moves backward', async () => {
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Ubuntu', inspected: true, managed: false }],
{ now: 100_000 }
)
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu'], {
now: 1_000,
negativeInspectionTtlMs: 10_000
})
).resolves.toEqual(['Ubuntu'])
})
it('safely rediscovers schema-v1 negative entries with no inspection time', async () => {
await writeFile(
join(userDataPath, 'wsl-cli-registrations.json'),
JSON.stringify({
schemaVersion: 1,
registeredDistros: ['Debian'],
inspectedDistros: ['Ubuntu', 'Debian']
}),
'utf8'
)
await expect(
getWslCliRegistrationCandidates(userDataPath, ['Ubuntu', 'Debian'], {
now: 1,
negativeInspectionTtlMs: 1_000_000
})
).resolves.toEqual(['Ubuntu', 'Debian'])
})
it('caps inspection bookkeeping while always keeping registered distros', async () => {
const observations = Array.from({ length: 70 }, (_, index) => ({
distro: `Distro${index}`,
inspected: true,
managed: false as const
}))
for (const [index, observation] of observations.entries()) {
await recordWslCliRegistrationObservations(userDataPath, [observation], { now: index })
}
await recordWslCliRegistrationObservations(
userDataPath,
[{ distro: 'Managed Oldest', inspected: true, managed: true }],
{ now: 0 }
)
const state = JSON.parse(
await readFile(join(userDataPath, 'wsl-cli-registrations.json'), 'utf8')
) as { registeredDistros: string[]; inspectionTimes: Record<string, number> }
expect(state.registeredDistros).toEqual(['Managed Oldest'])
expect(Object.keys(state.inspectionTimes).length).toBeLessThanOrEqual(65)
expect(state.inspectionTimes['managed oldest']).toBe(0)
})
})
@@ -0,0 +1,279 @@
import { mkdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getKeyedSerializedQueueTail, runKeyedSerializedOperation } from './keyed-promise-queue'
import { normalizeWslDistroKey } from './wsl-cli-registration-operation'
const REGISTRY_FILE_NAME = 'wsl-cli-registrations.json'
const REGISTRY_SCHEMA_VERSION = 2
const DEFAULT_NEGATIVE_INSPECTION_TTL_MS = 7 * 24 * 60 * 60 * 1_000
// Why: the registry is advisory; cap per-distro bookkeeping so hosts that
// cycle many uniquely named distros cannot grow the file without bound.
const MAX_INSPECTION_ENTRIES = 64
type WslCliRegistrationReconciliation = {
target: string
appVersion: string
}
type WslCliRegistrationRegistryState = {
schemaVersion: 2
registeredDistros: string[]
inspectionTimes: Record<string, number>
reconciliations: Record<string, WslCliRegistrationReconciliation>
}
type WslCliRegistrationRegistryTiming = {
now?: number
negativeInspectionTtlMs?: number
// Why: a registered distro already reconciled against this exact launcher
// by this exact app build has nothing to repair; skipping it avoids booting
// its VM on every startup while still re-probing after each app update.
currentTarget?: string | null
appVersion?: string | null
}
export type WslCliRegistrationObservation = {
distro: string
inspected: boolean
// Why: null records an inspection without changing registration ownership —
// used for 'unsupported' probes where managed-ness could not be determined.
managed: boolean | null
reconciled?: WslCliRegistrationReconciliation | null
}
const writeQueues = new Map<string, Promise<void>>()
function emptyState(): WslCliRegistrationRegistryState {
return {
schemaVersion: REGISTRY_SCHEMA_VERSION,
registeredDistros: [],
inspectionTimes: {},
reconciliations: {}
}
}
function uniqueDistros(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
const seen = new Set<string>()
const distros: string[] = []
for (const entry of value) {
if (typeof entry !== 'string' || !entry.trim()) {
continue
}
const distro = entry.trim()
const key = normalizeWslDistroKey(distro)
if (!seen.has(key)) {
seen.add(key)
distros.push(distro)
}
}
return distros
}
function parseReconciliations(value: unknown): Record<string, WslCliRegistrationReconciliation> {
if (!value || typeof value !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(value).filter(
(entry): entry is [string, WslCliRegistrationReconciliation] =>
!!entry[1] &&
typeof entry[1] === 'object' &&
typeof (entry[1] as WslCliRegistrationReconciliation).target === 'string' &&
typeof (entry[1] as WslCliRegistrationReconciliation).appVersion === 'string'
)
)
}
function parseState(content: string): WslCliRegistrationRegistryState {
try {
const parsed = JSON.parse(content) as Record<string, unknown>
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== REGISTRY_SCHEMA_VERSION) {
return emptyState()
}
const inspectionTimes =
parsed.inspectionTimes && typeof parsed.inspectionTimes === 'object'
? Object.fromEntries(
Object.entries(parsed.inspectionTimes).filter(
(entry): entry is [string, number] =>
typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0
)
)
: {}
return {
schemaVersion: REGISTRY_SCHEMA_VERSION,
registeredDistros: uniqueDistros(parsed.registeredDistros),
inspectionTimes,
reconciliations: parseReconciliations(parsed.reconciliations)
}
} catch {
// Why: a corrupt advisory registry must trigger safe rediscovery rather
// than preventing managed registrations from receiving future updates.
return emptyState()
}
}
function isMissingError(error: unknown): boolean {
return (error as NodeJS.ErrnoException)?.code === 'ENOENT'
}
function getRegistryPath(userDataPath: string): string {
return join(userDataPath, REGISTRY_FILE_NAME)
}
async function readState(userDataPath: string): Promise<WslCliRegistrationRegistryState> {
try {
return parseState(await readFile(getRegistryPath(userDataPath), 'utf8'))
} catch (error) {
if (isMissingError(error)) {
return emptyState()
}
throw error
}
}
function upsertDistro(distros: string[], distro: string): string[] {
const key = normalizeWslDistroKey(distro)
const existingIndex = distros.findIndex((entry) => normalizeWslDistroKey(entry) === key)
if (existingIndex < 0) {
return [...distros, distro.trim()]
}
return distros.map((entry, index) => (index === existingIndex ? distro.trim() : entry))
}
function removeDistro(distros: string[], distro: string): string[] {
const key = normalizeWslDistroKey(distro)
return distros.filter((entry) => normalizeWslDistroKey(entry) !== key)
}
async function writeState(
userDataPath: string,
state: WslCliRegistrationRegistryState
): Promise<void> {
await mkdir(userDataPath, { recursive: true })
// Why: userData writes on Windows can hit Chromium's Protected-DACL EPERM;
// writeFileAtomically carries the ACL-repair retry a plain rename lacks.
writeFileAtomically(getRegistryPath(userDataPath), `${JSON.stringify(state, null, 2)}\n`)
}
function capInspectionEntries(
state: WslCliRegistrationRegistryState
): WslCliRegistrationRegistryState {
const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey))
const entries = Object.entries(state.inspectionTimes)
const inspectionTimes =
entries.length <= MAX_INSPECTION_ENTRIES
? state.inspectionTimes
: Object.fromEntries(
entries
.sort((a, b) => b[1] - a[1])
.filter((entry, index) => index < MAX_INSPECTION_ENTRIES || registered.has(entry[0]))
)
const reconciliations = Object.fromEntries(
Object.entries(state.reconciliations).filter(([key]) => registered.has(key))
)
return { ...state, inspectionTimes, reconciliations }
}
function updateState(
userDataPath: string,
update: (state: WslCliRegistrationRegistryState) => WslCliRegistrationRegistryState
): Promise<void> {
return runKeyedSerializedOperation(writeQueues, getRegistryPath(userDataPath), async () => {
await writeState(userDataPath, capInspectionEntries(update(await readState(userDataPath))))
})
}
export async function getWslCliRegistrationCandidates(
userDataPath: string,
availableDistros: string[],
timing: WslCliRegistrationRegistryTiming = {}
): Promise<string[]> {
// Why: the stored queue tail never rejects, so a failed concurrent write
// cannot abort candidate discovery; reads still see fully applied updates.
await getKeyedSerializedQueueTail(writeQueues, getRegistryPath(userDataPath))
const state = await readState(userDataPath)
const registered = new Set(state.registeredDistros.map(normalizeWslDistroKey))
const now = timing.now ?? Date.now()
const negativeInspectionTtlMs =
timing.negativeInspectionTtlMs ?? DEFAULT_NEGATIVE_INSPECTION_TTL_MS
return uniqueDistros(availableDistros).filter((distro) => {
const key = normalizeWslDistroKey(distro)
if (registered.has(key)) {
const reconciliation = state.reconciliations[key]
return !(
reconciliation &&
timing.currentTarget &&
reconciliation.target === timing.currentTarget &&
reconciliation.appVersion === (timing.appVersion ?? '')
)
}
const inspectedAt = state.inspectionTimes[key]
return (
inspectedAt === undefined || inspectedAt > now || now - inspectedAt >= negativeInspectionTtlMs
)
})
}
export function recordWslCliRegistrationObservations(
userDataPath: string,
observations: WslCliRegistrationObservation[],
timing: Pick<WslCliRegistrationRegistryTiming, 'now'> = {}
): Promise<void> {
const effective = observations.filter(
(observation) => observation.inspected && observation.distro.trim()
)
if (effective.length === 0) {
return Promise.resolve()
}
return updateState(userDataPath, (state) => {
let registeredDistros = state.registeredDistros
let inspectionTimes = state.inspectionTimes
let reconciliations = state.reconciliations
const now = timing.now ?? Date.now()
for (const observation of effective) {
const key = normalizeWslDistroKey(observation.distro)
inspectionTimes = { ...inspectionTimes, [key]: now }
if (observation.managed === true) {
registeredDistros = upsertDistro(registeredDistros, observation.distro)
} else if (observation.managed === false) {
registeredDistros = removeDistro(registeredDistros, observation.distro)
}
if (observation.reconciled !== undefined) {
if (observation.reconciled === null) {
const { [key]: _removed, ...rest } = reconciliations
reconciliations = rest
} else {
reconciliations = { ...reconciliations, [key]: observation.reconciled }
}
}
}
return {
schemaVersion: REGISTRY_SCHEMA_VERSION,
registeredDistros,
inspectionTimes,
reconciliations
}
})
}
export function recordWslCliRegistrationInstalled(
userDataPath: string,
distro: string
): Promise<void> {
return recordWslCliRegistrationObservations(userDataPath, [
{ distro, inspected: true, managed: true }
])
}
export function recordWslCliRegistrationRemoved(
userDataPath: string,
distro: string
): Promise<void> {
return recordWslCliRegistrationObservations(userDataPath, [
{ distro, inspected: true, managed: false, reconciled: null }
])
}
+24 -2
View File
@@ -71,13 +71,35 @@ export function buildSafeReplaceGuard(path: string, managedMarker: string): stri
].join('\n')
}
export function buildSafeRemoveCommand(commandPath: string): string {
export function buildRegistrationLockPrelude(commandPath: string): string {
const lockDir = getPosixDirname(getBridgePathFromCommandPath(commandPath))
// Why: the per-distro queue only serializes one Orca process; flock covers
// a second install (e.g. stable + nightly) mutating the same distro files.
return [
`if command -v flock >/dev/null 2>&1 && mkdir -p ${quoteShell(lockDir)} 2>/dev/null; then`,
` exec 9>${quoteShell(`${lockDir}/.orca-wsl-cli.lock`)}`,
' flock -x -w 30 9',
'fi'
].join('\n')
}
export function buildManagedLegacyRemoveCommand(quotedLegacyCommandPath: string): string {
// Why: remove only the Orca-managed pre-rename wrapper; user-owned `orca`
// commands and symlinks must survive.
return `if [ ! -L ${quotedLegacyCommandPath} ] && [ -f ${quotedLegacyCommandPath} ] && grep -Fq ${quoteShell(MANAGED_MARKER)} ${quotedLegacyCommandPath}; then rm -f ${quotedLegacyCommandPath}; fi`
}
export function buildSafeRemoveCommand(commandPath: string, legacyCommandPath?: string): string {
const bridgePath = getBridgePathFromCommandPath(commandPath)
return [
'set -euo pipefail',
buildRegistrationLockPrelude(commandPath),
buildSafeReplaceGuard(commandPath, MANAGED_MARKER),
buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER),
`rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`
`rm -f ${quoteShell(commandPath)} ${quoteShell(bridgePath)}`,
// Why: leaving a managed legacy `orca` behind lets startup reconciliation
// re-adopt it as opt-in proof and silently undo this removal.
...(legacyCommandPath ? [buildManagedLegacyRemoveCommand(quoteShell(legacyCommandPath))] : [])
].join('\n')
}
+38 -1
View File
@@ -85,6 +85,7 @@ import {
import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect'
import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect'
import { startFirstWindowStartupServices } from './startup/first-window-startup-services'
import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier'
import { getDevInstanceIdentity } from './startup/dev-instance-identity'
import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path'
import {
@@ -187,6 +188,7 @@ import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation'
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
let mainWindow: BrowserWindow | null = null
@@ -225,6 +227,8 @@ let keybindings: KeybindingService | null = null
const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
// Why: GPU child crashes clustered right after launch indicate a broken driver;
// track them so Orca can move this build onto software rendering.
const gpuLaunchTimeMs = Date.now()
@@ -610,7 +614,9 @@ if (hasSingleInstanceLock) {
}
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
await firstWindowStartupServicesReady
// Why: window rendering and local RPC startup stay independent, but restored
// WSL terminals get a bounded chance to receive launcher repairs first.
await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady])
})
ipcMain.handle(
@@ -1602,6 +1608,34 @@ app.whenReady().then(async () => {
electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId)
app.setName(devInstanceIdentity.name)
// Why: managed WSL launchers live outside the Windows app bundle, so keep
// their launcher and bridge contract synchronized across app updates.
managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({
isPackaged: app.isPackaged,
userDataPath: getCanonicalUserDataPath(),
appVersion: app.getVersion()
})
.then((results) => {
for (const result of results) {
if (result.outcome === 'failed') {
console.warn(
`[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}`
)
} else if (result.outcome === 'repaired') {
console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`)
}
}
})
.catch((error) => {
console.warn(
'[wsl-cli] Managed registration reconciliation discovery failed:',
error instanceof Error ? error.message : String(error)
)
})
managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier(
managedWslCliReconciliationReady
)
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
logStartupMilestone('store-loaded')
@@ -2056,6 +2090,9 @@ app.whenReady().then(async () => {
}
if (serveOptions) {
// Why: headless serve has no renderer startup barrier, so settle managed
// WSL command reconciliation before exposing its runtime transport.
await managedWslCliReconciliationReady
await startServeAgentHookServer()
registerHeadlessPtyRuntime(
runtime,
+148
View File
@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
ipcHandleMock,
wslInstallerMock,
recordInstalledMock,
recordRemovedMock,
getDefaultWslDistroMock
} = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => unknown>(),
ipcHandleMock: vi.fn(),
wslInstallerMock: vi.fn(),
recordInstalledMock: vi.fn(),
recordRemovedMock: vi.fn(),
getDefaultWslDistroMock: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: ipcHandleMock } }))
vi.mock('../cli/cli-installer', () => ({ CliInstaller: vi.fn() }))
vi.mock('../cli/wsl-cli-installer', () => ({ WslCliInstaller: wslInstallerMock }))
vi.mock('../cli/wsl-cli-registration-registry', () => ({
recordWslCliRegistrationInstalled: recordInstalledMock,
recordWslCliRegistrationRemoved: recordRemovedMock
}))
vi.mock('../persistence', () => ({ getCanonicalUserDataPath: () => '/canonical-user-data' }))
vi.mock('../startup/hydrate-shell-path', () => ({
hydrateShellPath: vi.fn(async () => ({ ok: false })),
mergePathSegments: vi.fn()
}))
vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock }))
import { registerCliHandlers } from './cli'
type WslHandler = (event: unknown, args?: { distro?: string | null }) => Promise<{ state: string }>
function getWslHandler(channel: string): WslHandler {
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`Missing IPC handler: ${channel}`)
}
return handler as WslHandler
}
describe('WSL CLI registration IPC', () => {
beforeEach(() => {
handlers.clear()
ipcHandleMock.mockReset()
ipcHandleMock.mockImplementation(
(channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
}
)
wslInstallerMock.mockReset()
recordInstalledMock.mockReset().mockResolvedValue(undefined)
recordRemovedMock.mockReset().mockResolvedValue(undefined)
getDefaultWslDistroMock.mockReset().mockReturnValue('Ubuntu')
registerCliHandlers()
})
it('records a successful explicit-distro installation', async () => {
const install = vi.fn(async () => ({ state: 'installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { install }
})
await expect(getWslHandler('cli:installWsl')({}, { distro: ' Debian ' })).resolves.toEqual({
state: 'installed'
})
expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Debian' })
expect(recordInstalledMock).toHaveBeenCalledWith('/canonical-user-data', 'Debian')
})
it('records removal from the resolved default distro', async () => {
const remove = vi.fn(async () => ({ state: 'not_installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { remove }
})
await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' })
expect(wslInstallerMock).toHaveBeenCalledWith({ distro: 'Ubuntu' })
expect(recordRemovedMock).toHaveBeenCalledWith('/canonical-user-data', 'Ubuntu')
})
it('does not claim ownership when installation is not confirmed', async () => {
const install = vi.fn(async () => ({ state: 'unsupported' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { install }
})
await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'unsupported' })
expect(recordInstalledMock).not.toHaveBeenCalled()
})
it('keeps successful installation successful when advisory registry persistence fails', async () => {
const install = vi.fn(async () => ({ state: 'installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { install }
})
recordInstalledMock.mockRejectedValueOnce(new Error('ENOSPC'))
await expect(getWslHandler('cli:installWsl')({})).resolves.toEqual({ state: 'installed' })
})
it('keeps successful removal successful when advisory registry persistence fails', async () => {
const remove = vi.fn(async () => ({ state: 'not_installed' }))
wslInstallerMock.mockImplementation(function MockWslCliInstaller() {
return { remove }
})
recordRemovedMock.mockRejectedValueOnce(new Error('EACCES'))
await expect(getWslHandler('cli:removeWsl')({})).resolves.toEqual({ state: 'not_installed' })
})
it('serializes a concurrent removal after installation and ownership persistence', async () => {
let finishInstall!: () => void
const install = vi.fn(
() =>
new Promise<{ state: 'installed' }>((resolve) => {
finishInstall = () => resolve({ state: 'installed' })
})
)
const remove = vi.fn(async () => ({ state: 'not_installed' as const }))
wslInstallerMock
.mockImplementationOnce(function MockInstallWslCliInstaller() {
return { install }
})
.mockImplementationOnce(function MockRemoveWslCliInstaller() {
return { remove }
})
const installation = getWslHandler('cli:installWsl')({}, { distro: 'Ubuntu' })
await vi.waitFor(() => expect(install).toHaveBeenCalledOnce())
const removal = getWslHandler('cli:removeWsl')({}, { distro: 'ubuntu' })
await Promise.resolve()
expect(remove).not.toHaveBeenCalled()
finishInstall()
await expect(installation).resolves.toEqual({ state: 'installed' })
await expect(removal).resolves.toEqual({ state: 'not_installed' })
expect(recordInstalledMock.mock.invocationCallOrder[0]).toBeLessThan(
remove.mock.invocationCallOrder[0]
)
expect(remove.mock.invocationCallOrder[0]).toBeLessThan(
recordRemovedMock.mock.invocationCallOrder[0]
)
})
})
+62 -3
View File
@@ -1,13 +1,49 @@
import { ipcMain } from 'electron'
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { CliInstaller } from '../cli/cli-installer'
import {
recordWslCliRegistrationInstalled,
recordWslCliRegistrationRemoved
} from '../cli/wsl-cli-registration-registry'
import { WslCliInstaller } from '../cli/wsl-cli-installer'
import { runSerializedWslCliRegistrationOperation } from '../cli/wsl-cli-registration-operation'
import { getCanonicalUserDataPath } from '../persistence'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
import { getDefaultWslDistro } from '../wsl'
function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined {
return args?.distro?.trim() || undefined
}
function resolveWslCliDistro(args?: { distro?: string | null }): string | null {
return normalizeWslCliDistro(args) ?? getDefaultWslDistro()
}
function runWslCliRegistrationOperation<T>(
distro: string | null,
operation: () => Promise<T>
): Promise<T> {
return distro ? runSerializedWslCliRegistrationOperation(distro, operation) : operation()
}
async function persistWslCliRegistration(
operation: () => Promise<void>,
action: 'install' | 'remove'
): Promise<void> {
try {
await operation()
} catch (error) {
// Why: the WSL file operation already succeeded; advisory metadata must
// not turn that success into a false Settings failure. The atomic write
// left the prior registry intact, and repair is disk-authoritative, so a
// stale entry self-corrects on the next startup probe.
console.warn(
`[wsl-cli] Failed to persist ${action} registration metadata:`,
error instanceof Error ? error.message : String(error)
)
}
}
async function hydrateLocalShellPathForCli(force = false): Promise<void> {
if (process.platform === 'win32') {
return
@@ -39,21 +75,44 @@ export function registerCliHandlers(): void {
ipcMain.handle(
'cli:getWslInstallStatus',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).getStatus()
// Why: status is a read-only probe; queuing it behind a long-running
// repair/install would hang the Settings spinner for its duration, and
// Settings re-polls, so a rare transient read self-corrects.
return new WslCliInstaller({ distro: resolveWslCliDistro(args) }).getStatus()
}
)
ipcMain.handle(
'cli:installWsl',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).install()
const distro = resolveWslCliDistro(args)
return runWslCliRegistrationOperation(distro, async () => {
const status = await new WslCliInstaller({ distro }).install()
if (distro && status.state === 'installed') {
await persistWslCliRegistration(
() => recordWslCliRegistrationInstalled(getCanonicalUserDataPath(), distro),
'install'
)
}
return status
})
}
)
ipcMain.handle(
'cli:removeWsl',
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).remove()
const distro = resolveWslCliDistro(args)
return runWslCliRegistrationOperation(distro, async () => {
const status = await new WslCliInstaller({ distro }).remove()
if (distro && status.state === 'not_installed') {
await persistWslCliRegistration(
() => recordWslCliRegistrationRemoved(getCanonicalUserDataPath(), distro),
'remove'
)
}
return status
})
}
)
}
@@ -24,6 +24,31 @@ describe('desktop startup ordering', () => {
expect(Math.max(rpcStartIndex, legacyRpcStartIndex)).toBeGreaterThanOrEqual(0)
})
it('shows the desktop window without waiting for WSL registration reconciliation', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'")
const barrierEnd = source.indexOf("ipcMain.handle(\n 'app:startupDiagnostic'", barrierStart)
const barrier = source.slice(barrierStart, barrierEnd)
const reconciliationStart = source.indexOf(
'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations('
)
const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart)
const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart)
const serveEnd = source.indexOf('return', serveReady)
const desktopWindowStart = source.indexOf('Promise.resolve(openMainWindow())')
const serveStartup = source.slice(serveStart, serveEnd)
const desktopStartup = source.slice(serveEnd, desktopWindowStart)
expect(reconciliationStart).toBeGreaterThanOrEqual(0)
expect(serveStart).toBeGreaterThan(reconciliationStart)
expect(serveEnd).toBeGreaterThan(serveStart)
expect(desktopWindowStart).toBeGreaterThan(reconciliationStart)
expect(serveStartup).toContain('await managedWslCliReconciliationReady')
expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady')
expect(barrier).toContain('managedWslCliStartupBarrierReady')
expect(barrier).not.toContain('managedWslCliReconciliationReady')
})
it('does not run the rate-limit quota fetch before the first window can show results', () => {
const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
const attachIndex = source.indexOf('rateLimits.attach(window)')
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest'
import {
WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS,
createWslCliReconciliationStartupBarrier
} from './wsl-cli-reconciliation-startup-barrier'
describe('createWslCliReconciliationStartupBarrier', () => {
it('resolves as soon as reconciliation finishes', async () => {
vi.useFakeTimers()
let resolveReconciliation!: () => void
try {
const reconciliation = new Promise<void>((resolve) => {
resolveReconciliation = resolve
})
const barrier = createWslCliReconciliationStartupBarrier(reconciliation)
let barrierSettled = false
void barrier.then(() => {
barrierSettled = true
})
await vi.advanceTimersByTimeAsync(1)
expect(barrierSettled).toBe(false)
resolveReconciliation()
await expect(barrier).resolves.toBeUndefined()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('fails open when reconciliation exceeds the startup budget', async () => {
vi.useFakeTimers()
let resolveReconciliation!: () => void
try {
const reconciliation = new Promise<void>((resolve) => {
resolveReconciliation = resolve
})
const barrier = createWslCliReconciliationStartupBarrier(reconciliation)
let barrierSettled = false
void barrier.then(() => {
barrierSettled = true
})
await vi.advanceTimersByTimeAsync(WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS - 1)
expect(barrierSettled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await expect(barrier).resolves.toBeUndefined()
resolveReconciliation()
await reconciliation
} finally {
vi.useRealTimers()
}
})
it('leaves reconciliation running after the startup budget expires', async () => {
vi.useFakeTimers()
let resolveWork!: () => void
let completed = false
try {
const work = new Promise<void>((resolve) => {
resolveWork = resolve
}).then(() => {
completed = true
})
const barrier = createWslCliReconciliationStartupBarrier(work, { timeoutMs: 10 })
await vi.advanceTimersByTimeAsync(10)
await expect(barrier).resolves.toBeUndefined()
expect(completed).toBe(false)
resolveWork()
await work
expect(completed).toBe(true)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,33 @@
export const WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS = 2_000
type WslCliReconciliationStartupBarrierOptions = {
timeoutMs?: number
}
/**
* Briefly gates restored terminals while managed WSL registrations reconcile.
*/
export function createWslCliReconciliationStartupBarrier(
reconciliation: Promise<unknown>,
options: WslCliReconciliationStartupBarrierOptions = {}
): Promise<void> {
const timeoutMs = options.timeoutMs ?? WSL_CLI_RECONCILIATION_STARTUP_BUDGET_MS
let timeout: ReturnType<typeof setTimeout> | null = null
const settled = reconciliation
.then(() => undefined)
.catch(() => undefined)
.finally(() => {
if (timeout) {
clearTimeout(timeout)
}
})
// Why: reconciliation may outlive a slow or unavailable WSL distro; restored
// terminals should wait briefly without turning WSL discovery into an app hang.
return Promise.race([
settled,
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs)
})
])
}
+77 -2
View File
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as childProcess from 'node:child_process'
const { execFileSyncMock } = vi.hoisted(() => ({
const { execFileMock, execFileSyncMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn()
}))
@@ -9,11 +10,21 @@ vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof childProcess>()
return {
...actual,
execFile: execFileMock,
execFileSync: execFileSyncMock
}
})
import { toLinuxPath, toWindowsWslPath, parseWslPath, wslUncDirectoryExists } from './wsl'
import {
_resetWslCachesForTests,
getCachedWslDistros,
listWslDistros,
listWslDistrosAsync,
parseWslPath,
toLinuxPath,
toWindowsWslPath,
wslUncDirectoryExists
} from './wsl'
function withPlatform<T>(value: NodeJS.Platform, fn: () => T): T {
const original = process.platform
@@ -25,6 +36,70 @@ function withPlatform<T>(value: NodeJS.Platform, fn: () => T): T {
}
}
async function withPlatformAsync<T>(value: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value })
try {
return await fn()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
describe('WSL distro discovery cache', () => {
afterEach(() => {
execFileMock.mockReset()
execFileSyncMock.mockReset()
_resetWslCachesForTests()
})
it('retries asynchronous discovery after a transient wsl.exe failure', async () => {
vi.useFakeTimers()
execFileMock
.mockImplementationOnce((_command, _args, _options, callback) => {
callback(new Error('transient failure'), '')
})
.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'Ubuntu\n')
})
try {
await withPlatformAsync('win32', async () => {
await expect(listWslDistrosAsync()).resolves.toEqual([])
expect(getCachedWslDistros()).toBeNull()
// Brief negative caching bounds the wsl.exe spawn rate between retries.
await expect(listWslDistrosAsync()).resolves.toEqual([])
expect(execFileMock).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(15_000)
await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu'])
})
} finally {
vi.useRealTimers()
}
})
it('retries synchronous discovery after a transient wsl.exe failure', () => {
vi.useFakeTimers()
execFileSyncMock.mockImplementationOnce(() => {
throw new Error('transient failure')
})
execFileSyncMock.mockReturnValueOnce('Ubuntu\n')
try {
withPlatform('win32', () => {
expect(listWslDistros()).toEqual([])
expect(getCachedWslDistros()).toBeNull()
expect(listWslDistros()).toEqual([])
expect(execFileSyncMock).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(15_000)
expect(listWslDistros()).toEqual(['Ubuntu'])
})
} finally {
vi.useRealTimers()
}
})
})
describe('wsl path helpers', () => {
it('parses WSL UNC paths on Windows', () => {
const originalPlatform = process.platform
+19 -4
View File
@@ -113,6 +113,12 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string {
const wslHomeCache = new Map<string, string>()
let wslDistroCache: string[] | null = null
// Why: a wsl.exe failure must stay retryable (a transient error would
// otherwise hide every distro until restart), but repeated failures cannot
// re-spawn a blocking wsl.exe on every caller; brief negative caching bounds
// the spawn rate on machines where WSL is absent or persistently broken.
const WSL_DISTRO_LIST_FAILURE_TTL_MS = 15_000
let wslDistroListFailedUntilMs = 0
function normalizeWslListOutput(output: string): string[] {
// Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through
@@ -138,6 +144,10 @@ export function listWslDistros(): string[] {
return wslDistroCache
}
if (Date.now() < wslDistroListFailedUntilMs) {
return []
}
try {
const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
encoding: 'utf-8',
@@ -147,8 +157,8 @@ export function listWslDistros(): string[] {
wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
return wslDistroCache
} catch {
wslDistroCache = []
return wslDistroCache
wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS
return []
}
}
@@ -162,13 +172,17 @@ export async function listWslDistrosAsync(): Promise<string[]> {
return wslDistroCache
}
if (Date.now() < wslDistroListFailedUntilMs) {
return []
}
try {
const output = await execFileUtf8('wsl.exe', ['--list', '--quiet'])
wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
return wslDistroCache
} catch {
wslDistroCache = []
return wslDistroCache
wslDistroListFailedUntilMs = Date.now() + WSL_DISTRO_LIST_FAILURE_TTL_MS
return []
}
}
@@ -279,6 +293,7 @@ export function getCachedWslAvailability(): boolean | null {
export function _resetWslCachesForTests(): void {
wslHomeCache.clear()
wslDistroCache = null
wslDistroListFailedUntilMs = 0
wslAvailableCache = null
}