mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user