diff --git a/config/scripts/wsl-hook-relay-reattach-benchmark.mjs b/config/scripts/wsl-hook-relay-reattach-benchmark.mjs index b87c413eeb3..778d9a81f14 100644 --- a/config/scripts/wsl-hook-relay-reattach-benchmark.mjs +++ b/config/scripts/wsl-hook-relay-reattach-benchmark.mjs @@ -310,6 +310,9 @@ async function main() { const { ensureWslHookRelayForReattach } = await jiti.import( '../../src/main/agent-hooks/wsl-hook-relay-reattach.ts' ) + const { reconcileWslHookRelaysOnStartup } = await jiti.import( + '../../src/main/agent-hooks/wsl-hook-relay-startup-reconciliation.ts' + ) const { codexHookService } = await jiti.import('../../src/main/codex/hook-service.ts') const { MANAGED_AGENT_HOOK_TARGETS } = await jiti.import( '../../src/shared/managed-agent-hook-targets.ts' @@ -457,6 +460,38 @@ async function main() { delivered = 0 manager = await createManager('after-restart-token') + const startupDistros = [] + const startupStartedAt = performance.now() + await reconcileWslHookRelaysOnStartup({ + platform: 'win32', + listLiveProcesses: async () => [ + { + id: survivingPty.id, + cwd: guestHome, + title: 'shell', + wslDistro: distro + } + ], + ensureForDistro: async (startupDistro) => { + startupDistros.push(startupDistro) + await manager.ensureForDistroReady(startupDistro) + } + }) + const startupRelayReadyMs = performance.now() - startupStartedAt + if (startupDistros.length !== 1 || startupDistros[0] !== distro) { + throw new Error( + `Startup inventory did not reconcile '${distro}': [${startupDistros.join(', ')}]` + ) + } + const refreshedEndpoint = await waitFor('startup relay endpoint rewrite', async () => { + const contents = await readGuestFile(distro, endpointPath) + const parsed = contents ? parseEndpoint(contents) : null + return parsed && parsed.token === 'after-restart-token' ? parsed : null + }) + + const immediateHook = await invokeHook(distro, scriptPath, endpointPath) + await waitFor('immediate startup hook delivery', async () => (delivered >= 1 ? true : null)) + // Reattach the surviving WSL PTY through main's real spawn path — no direct helper call. // Why delta: only the reattach phase should record ensureForDistro; length alone can false- // green if an earlier phase already refreshed (or if a future hooks-on change reintroduces it). @@ -473,12 +508,8 @@ async function main() { `PTY reattach did not refresh the relay for '${distro}': before=${refreshesBeforeReattach} all=[${relayRefreshes.join(', ')}]` ) } - const refreshedEndpoint = await waitFor('reattached relay endpoint rewrite', async () => { - const contents = await readGuestFile(distro, endpointPath) - const parsed = contents ? parseEndpoint(contents) : null - return parsed && parsed.token === 'after-restart-token' ? parsed : null - }) + delivered = 0 await invokeHook(distro, scriptPath, endpointPath) await waitFor('refreshed hook warmup delivery', async () => (delivered >= 1 ? true : null)) delivered = 0 @@ -495,8 +526,11 @@ async function main() { const result = { distro, endpointPath, - endpointPortAfterReattach: refreshedEndpoint.port, + endpointPortAfterStartup: refreshedEndpoint.port, reattachedPtyId: reattachedPty.id, + startupDistros, + startupRelayReadyMs: Number(startupRelayReadyMs.toFixed(1)), + immediateHookMs: Number(immediateHook.elapsedMs.toFixed(1)), relayRefreshes, reattachRefreshes, stale, diff --git a/package.json b/package.json index 06407e81144..d1e3e604af7 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "bench:macos-computer-helper-owner-loss": "node config/scripts/macos-computer-helper-owner-loss-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:wsl-hook-relay-startup": "pnpm run build:relay && node config/scripts/wsl-hook-relay-reattach-benchmark.mjs", "bench:wsl-hook-relay-reattach": "pnpm run build:relay && node config/scripts/wsl-hook-relay-reattach-benchmark.mjs", "bench:wsl-git-shell": "node config/scripts/wsl-git-shell-benchmark.mjs", "bench:hang-watchdog-memory": "pnpm run ensure:electron-runtime && node config/scripts/hang-watchdog-memory-benchmark.mjs", diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 5cb25e312f2..221ea42f080 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -16,6 +16,7 @@ import { type WslRelayStartupFailure } from './wsl-hook-relay-sentinel' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { wslHookRelayStateKey } from './wsl-hook-relay-state-key' import { WSL_HOOK_RELAY_BUNDLE_NAME, WSL_HOOK_RELAY_DIR, @@ -140,14 +141,8 @@ export function spawnWslRelayProcess( }) } -/** True when the distro shows in `wsl --list --running`. Listing does NOT - * boot anything — unlike `wsl -d`, which starts a stopped distro. The - * restart timer must check this so relay recovery never resurrects a VM the - * user shut down with `wsl --shutdown`. Fails CLOSED (false) on probe - * errors: booting a VM the user shut down is worse than a skipped restart - * (the next WSL PTY spawn re-ensures), and a wsl.exe too wedged to list - * distros would not have launched the relay anyway. */ -export function isWslDistroRunning(distro: string): Promise { +/** One non-booting snapshot; null fails closed when WSL cannot report authority. */ +export function listRunningWslDistros(): Promise { return new Promise((resolve) => { execFile( 'wsl.exe', @@ -157,20 +152,26 @@ export function isWslDistroRunning(distro: string): Promise { { env: { ...process.env, WSL_UTF8: '1' }, timeout: 10_000, windowsHide: true }, (err, stdout) => { if (err) { - resolve(false) + resolve(null) return } - const wanted = distro.trim().toLowerCase() const running = decodeWslText(String(stdout)) .split(/\r?\n/) - .map((line) => line.trim().toLowerCase()) + .map((line) => line.trim()) .filter(Boolean) - resolve(running.includes(wanted)) + resolve(running) } ) }) } +/** Recovery must not resurrect a distro the user stopped with `wsl --shutdown`. */ +export async function isWslDistroRunning(distro: string): Promise { + const running = await listRunningWslDistros() + const wanted = wslHookRelayStateKey(distro) + return running?.some((candidate) => wslHookRelayStateKey(candidate) === wanted) ?? false +} + export function runWslInstallProcess( distro: string, script: string, diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 4d2ae9b7bb5..99bd16abacf 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -267,6 +267,27 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('exposes first-attempt readiness for startup ordering', async () => { + let releaseInstall: (() => void) | undefined + const installHooks = vi.fn( + () => new Promise((resolve) => (releaseInstall = () => resolve([]))) + ) + const { manager, deps } = createManager({ installHooks }) + let ready = false + + const firstAttempt = manager.ensureForDistroReady('Ubuntu').then(() => { + ready = true + }) + await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) + expect(ready).toBe(false) + + releaseInstall?.() + await firstAttempt + expect(manager.getGuestEndpointFilePath('Ubuntu')).not.toBeNull() + expect(ready).toBe(true) + manager.disposeAll() + }) + it('ships the OpenCode plugin to the guest and exposes the overlay dir', async () => { const { manager } = createManager({}) manager.ensureForDistro('Ubuntu') diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 74d163ca42f..2b4e0356552 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -82,10 +82,15 @@ export class WslHookRelayManager { /** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */ ensureForDistro(distro: string | null): void { + void this.ensureForDistroReady(distro) + } + + /** Awaitable first attempt for the startup gate; failures retain timer-driven recovery. */ + async ensureForDistroReady(distro: string | null): Promise { if (this.disposed || !isWslHookRelayAllowed(this.deps)) { return } - void this.ensureInternal(distro).catch((err) => { + await this.ensureInternal(distro).catch((err) => { const detail = err instanceof Error ? err.message : String(err) this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) }) diff --git a/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts b/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts index 8e9e425a05c..881d25a2984 100644 --- a/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-reattach.test.ts @@ -17,7 +17,8 @@ describe('ensureWslHookRelayForReattach', () => { ['native reattach', { isReattach: true, wslDistro: null }, null], ['legacy reattach without ownership context', { isReattach: true }, null], ['blank distro', { isReattach: true, wslDistro: ' ' }, null], - ['SSH reattach', { isReattach: true, wslDistro: 'Ubuntu' }, 'ssh-1'] + ['SSH reattach', { isReattach: true, wslDistro: 'Ubuntu' }, 'ssh-1'], + ['relay reattach', { isReattach: true, wslDistro: 'Ubuntu' }, 'relay-1'] ])('does not refresh a %s', (_label, result, connectionId) => { const ensure = vi.fn() diff --git a/src/main/agent-hooks/wsl-hook-relay-startup-reconciliation.test.ts b/src/main/agent-hooks/wsl-hook-relay-startup-reconciliation.test.ts new file mode 100644 index 00000000000..84fb4d06a92 --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-startup-reconciliation.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { PtyProcessInfo } from '../providers/pty-process-info' +import { reconcileWslHookRelaysOnStartup } from './wsl-hook-relay-startup-reconciliation' + +function session(id: string, wslDistro?: string | null): PtyProcessInfo { + return { + id, + cwd: '', + title: 'shell', + ...(wslDistro !== undefined ? { wslDistro } : {}) + } +} + +describe('reconcileWslHookRelaysOnStartup', () => { + it('groups surviving panes by distro and awaits one relay per distro', async () => { + const events: string[] = [] + const releases = new Map void>() + const ensureForDistro = vi.fn( + (distro: string) => + new Promise((resolve) => { + events.push(`ensure:${distro}`) + releases.set(distro, resolve) + }) + ) + const listRunningDistros = vi.fn(async () => ['Ubuntu', 'Debian']) + + const reconciliation = reconcileWslHookRelaysOnStartup({ + platform: 'win32', + listLiveProcesses: async () => [ + session('pane-1', 'Ubuntu'), + session('pane-2', 'ubuntu'), + session('pane-3', 'Debian') + ], + listRunningDistros, + ensureForDistro + }).then(() => events.push('ready')) + + await vi.waitFor(() => expect(events).toEqual(['ensure:Ubuntu', 'ensure:Debian'])) + releases.get('Ubuntu')?.() + await Promise.resolve() + expect(events).not.toContain('ready') + releases.get('Debian')?.() + await reconciliation + + expect(events).toEqual(['ensure:Ubuntu', 'ensure:Debian', 'ready']) + expect(listRunningDistros).toHaveBeenCalledOnce() + }) + + it('ignores native panes, blank values, and old daemons without WSL metadata', async () => { + const ensureForDistro = vi.fn(async () => {}) + + await reconcileWslHookRelaysOnStartup({ + platform: 'win32', + listLiveProcesses: async () => [ + session('native', null), + session('old-daemon'), + session('blank', ' ') + ], + listRunningDistros: vi.fn(async () => ['Ubuntu']), + ensureForDistro + }) + + expect(ensureForDistro).not.toHaveBeenCalled() + }) + + it('does not infer desired distros from unavailable inventory', async () => { + const ensureForDistro = vi.fn(async () => {}) + + await reconcileWslHookRelaysOnStartup({ + platform: 'win32', + listLiveProcesses: async () => null, + listRunningDistros: vi.fn(async () => ['Ubuntu']), + ensureForDistro + }) + + expect(ensureForDistro).not.toHaveBeenCalled() + }) + + it('does not query local daemon inventory on native hosts', async () => { + const listLiveProcesses = vi.fn(async () => [session('remote', 'Ubuntu')]) + + await reconcileWslHookRelaysOnStartup({ platform: 'linux', listLiveProcesses }) + await reconcileWslHookRelaysOnStartup({ platform: 'darwin', listLiveProcesses }) + + expect(listLiveProcesses).not.toHaveBeenCalled() + }) + + it('does not boot a distro that stopped after the live inventory was captured', async () => { + const ensureForDistro = vi.fn(async () => {}) + + await reconcileWslHookRelaysOnStartup({ + platform: 'win32', + listLiveProcesses: async () => [session('survivor', 'Ubuntu')], + listRunningDistros: async () => [], + ensureForDistro + }) + + expect(ensureForDistro).not.toHaveBeenCalled() + }) + + it('fails closed when the running-distro snapshot is unavailable', async () => { + const ensureForDistro = vi.fn(async () => {}) + + await reconcileWslHookRelaysOnStartup({ + platform: 'win32', + listLiveProcesses: async () => [session('survivor', 'Ubuntu')], + listRunningDistros: async () => null, + ensureForDistro + }) + + expect(ensureForDistro).not.toHaveBeenCalled() + }) + + it('uses live ownership for folder workspace sessions without parsing their ids', async () => { + const ensureForDistro = vi.fn(async () => {}) + + await reconcileWslHookRelaysOnStartup({ + platform: 'win32', + listLiveProcesses: async () => [session('folder-workspace@@pane', 'Ubuntu-24.04')], + listRunningDistros: async () => ['Ubuntu-24.04'], + ensureForDistro + }) + + expect(ensureForDistro).toHaveBeenCalledWith('Ubuntu-24.04') + }) +}) diff --git a/src/main/agent-hooks/wsl-hook-relay-startup-reconciliation.ts b/src/main/agent-hooks/wsl-hook-relay-startup-reconciliation.ts new file mode 100644 index 00000000000..eb5152afb0d --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-startup-reconciliation.ts @@ -0,0 +1,56 @@ +import type { PtyProcessInfo } from '../providers/pty-process-info' +import { listLiveDaemonProcesses } from '../daemon/daemon-init' +import { wslHookRelayManager } from './wsl-hook-relay-manager' +import { wslHookRelayStateKey } from './wsl-hook-relay-state-key' +import { listRunningWslDistros } from './wsl-hook-relay-launch' + +type StartupReconciliationDeps = { + platform: NodeJS.Platform + listLiveProcesses: () => Promise + listRunningDistros: () => Promise + ensureForDistro: (distro: string) => Promise +} + +const defaultDeps: StartupReconciliationDeps = { + platform: process.platform, + listLiveProcesses: listLiveDaemonProcesses, + listRunningDistros: listRunningWslDistros, + ensureForDistro: (distro) => wslHookRelayManager.ensureForDistroReady(distro) +} + +export async function reconcileWslHookRelaysOnStartup( + deps: Partial = {} +): Promise { + const io = { ...defaultDeps, ...deps } + if (io.platform !== 'win32') { + return + } + const processes = await io.listLiveProcesses() + if (!processes) { + return + } + const distros = new Map() + for (const process of processes) { + if (typeof process.wslDistro !== 'string') { + continue + } + const distro = process.wslDistro.trim() + const key = wslHookRelayStateKey(distro) + if (distro && !distros.has(key)) { + distros.set(key, distro) + } + } + if (distros.size === 0) { + return + } + const runningSnapshot = await io.listRunningDistros() + if (!runningSnapshot) { + return + } + const runningKeys = new Set(runningSnapshot.map(wslHookRelayStateKey)) + await Promise.all( + [...distros].flatMap(([key, distro]) => + runningKeys.has(key) ? [io.ensureForDistro(distro)] : [] + ) + ) +} diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 7b95d4a010c..a91d1ca3e1f 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -59,6 +59,7 @@ import { } from '../claude-accounts/live-pty-gate' import { parseDaemonReadyIdentity, readDaemonProcessIncarnation } from './daemon-ready-identity' import type { DaemonEndpointIdentity } from './daemon-hello-protocol' +import type { PtyProcessInfo } from '../providers/pty-process-info' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { @@ -1081,7 +1082,7 @@ export async function getCurrentDaemonMacTccAttributionHealth(): Promise { +export async function listLiveDaemonProcesses(): Promise { if (!adapter) { return null } @@ -1096,10 +1097,15 @@ export async function listLiveDaemonPtyIds(): Promise { return null } return inventories.flatMap((inventory) => - inventory.status === 'fulfilled' ? inventory.value.map((process) => process.id) : [] + inventory.status === 'fulfilled' ? inventory.value : [] ) } +export async function listLiveDaemonPtyIds(): Promise { + const processes = await listLiveDaemonProcesses() + return processes?.map((process) => process.id) ?? null +} + // Why: keep the module-level adapter and ipc/pty.ts's localProvider in sync so app-quit can't dispose a stale reference. export function replaceDaemonProvider(newAdapter: DaemonProvider): void { adapter = newAdapter diff --git a/src/main/index.ts b/src/main/index.ts index ddf79fd9455..ebb68552f21 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,6 +35,7 @@ import { initDaemonPtyProvider, disconnectDaemon, getDaemonProvider, + listLiveDaemonProcesses, listLiveDaemonPtyIds, shutdownDaemon } from './daemon/daemon-init' @@ -249,6 +250,7 @@ import { agentHookServer, type AgentHookProviderSessionIdentity } from './agent- import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation' import { createHookStatusSessionTabsInvalidator } from './agent-hooks/hook-status-session-tabs-invalidation' import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' +import { reconcileWslHookRelaysOnStartup } from './agent-hooks/wsl-hook-relay-startup-reconciliation' import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' @@ -940,6 +942,14 @@ function startTerminalRuntimeStartupServices(): Promise { }) logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) }, + reconcileWslHookRelays: async () => { + if (!isAgentStatusHooksEnabled(store?.getSettings())) { + return + } + logStartupMilestone('startup-service-start', { service: 'wsl-hook-relay-reconciliation' }) + await reconcileWslHookRelaysOnStartup({ listLiveProcesses: listLiveDaemonProcesses }) + logStartupMilestone('startup-service-done', { service: 'wsl-hook-relay-reconciliation' }) + }, onDaemonError: (error) => { // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). const reason = error instanceof Error ? error.message : String(error) @@ -951,6 +961,9 @@ function startTerminalRuntimeStartupServices(): Promise { onAgentHookServerError: (error) => { // Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails. console.error('[agent-hooks] Failed to start local hook server:', error) + }, + onWslHookRelayReconciliationError: (error) => { + console.warn('[agent-hooks] Failed to reconcile startup WSL hook relays:', error) } }) firstWindowStartupServicesReady = startupServices.firstWindowReady diff --git a/src/main/startup/first-window-startup-services.test.ts b/src/main/startup/first-window-startup-services.test.ts index 728fd5c41b9..ca9d90b7006 100644 --- a/src/main/startup/first-window-startup-services.test.ts +++ b/src/main/startup/first-window-startup-services.test.ts @@ -22,8 +22,10 @@ describe('startFirstWindowStartupServices', () => { events.push('hooks-started') resolveHooks = resolve }), + reconcileWslHookRelays: vi.fn(async () => {}), onDaemonError: vi.fn(), - onAgentHookServerError: vi.fn() + onAgentHookServerError: vi.fn(), + onWslHookRelayReconciliationError: vi.fn() }) await Promise.resolve() @@ -45,6 +47,66 @@ describe('startFirstWindowStartupServices', () => { expect(completed).toBe(true) }) + it('reconciles after both authorities and keeps local reattach gated until relays are ready', async () => { + const events: string[] = [] + let resolveDaemon!: () => void + let resolveHooks!: () => void + let resolveRelays!: () => void + const started = startFirstWindowStartupServices({ + startDaemonPtyProvider: () => + new Promise((resolve) => { + events.push('daemon-started') + resolveDaemon = resolve + }), + startAgentHookServer: () => + new Promise((resolve) => { + events.push('hooks-started') + resolveHooks = resolve + }), + reconcileWslHookRelays: () => + new Promise((resolve) => { + events.push('relay-reconciliation-started') + resolveRelays = resolve + }), + onDaemonError: vi.fn(), + onAgentHookServerError: vi.fn(), + onWslHookRelayReconciliationError: vi.fn() + }) + await Promise.resolve() + + resolveHooks() + await Promise.resolve() + expect(events).toEqual(['daemon-started', 'hooks-started']) + + resolveDaemon() + await vi.waitFor(() => expect(events).toContain('relay-reconciliation-started')) + let localPtyReady = false + void started.localPtyReady.then(() => { + localPtyReady = true + }) + await Promise.resolve() + expect(localPtyReady).toBe(false) + + resolveRelays() + await started.localPtyReady + expect(localPtyReady).toBe(true) + }) + + it('reports reconciliation failure and still opens the startup gate', async () => { + const onWslHookRelayReconciliationError = vi.fn() + const started = startFirstWindowStartupServices({ + startDaemonPtyProvider: () => Promise.resolve(), + startAgentHookServer: () => Promise.resolve(), + reconcileWslHookRelays: () => Promise.reject(new Error('inventory unavailable')), + onDaemonError: vi.fn(), + onAgentHookServerError: vi.fn(), + onWslHookRelayReconciliationError + }) + + await expect(started.localPtyReady).resolves.toBeUndefined() + expect(onWslHookRelayReconciliationError).toHaveBeenCalledWith(expect.any(Error)) + }) + it('opens the provider gate when daemon startup finishes without waiting for hooks', async () => { let resolveDaemon!: () => void let resolveHooks!: () => void @@ -57,8 +119,10 @@ describe('startFirstWindowStartupServices', () => { new Promise((resolve) => { resolveHooks = resolve }), + reconcileWslHookRelays: vi.fn(async () => {}), onDaemonError: vi.fn(), - onAgentHookServerError: vi.fn() + onAgentHookServerError: vi.fn(), + onWslHookRelayReconciliationError: vi.fn() }) await Promise.resolve() @@ -81,8 +145,10 @@ describe('startFirstWindowStartupServices', () => { const started = startFirstWindowStartupServices({ startDaemonPtyProvider: () => Promise.reject(new Error('daemon failed')), startAgentHookServer: () => Promise.reject(new Error('hooks failed')), + reconcileWslHookRelays: vi.fn(async () => {}), onDaemonError, - onAgentHookServerError + onAgentHookServerError, + onWslHookRelayReconciliationError: vi.fn() }) await expect(started.firstWindowReady).resolves.toBeUndefined() @@ -104,8 +170,10 @@ describe('startFirstWindowStartupServices', () => { startAgentHookServer: () => { throw new Error('hooks sync failed') }, + reconcileWslHookRelays: vi.fn(async () => {}), onDaemonError, - onAgentHookServerError + onAgentHookServerError, + onWslHookRelayReconciliationError: vi.fn() }) await expect(started.firstWindowReady).resolves.toBeUndefined() @@ -131,8 +199,10 @@ describe('startFirstWindowStartupServices', () => { }) }, startAgentHookServer: () => Promise.resolve(), + reconcileWslHookRelays: vi.fn(async () => {}), onDaemonError, - onAgentHookServerError: vi.fn() + onAgentHookServerError: vi.fn(), + onWslHookRelayReconciliationError: vi.fn() }) let ptyGateOpened = false @@ -172,8 +242,10 @@ describe('startFirstWindowStartupServices', () => { return new Promise(() => {}) }, startAgentHookServer: () => Promise.resolve(), + reconcileWslHookRelays: vi.fn(async () => {}), onDaemonError, - onAgentHookServerError + onAgentHookServerError, + onWslHookRelayReconciliationError: vi.fn() }) await Promise.resolve() diff --git a/src/main/startup/first-window-startup-services.ts b/src/main/startup/first-window-startup-services.ts index 407ba23f555..813779d005d 100644 --- a/src/main/startup/first-window-startup-services.ts +++ b/src/main/startup/first-window-startup-services.ts @@ -1,8 +1,10 @@ type FirstWindowStartupServices = { startDaemonPtyProvider: (signal: AbortSignal) => Promise startAgentHookServer: (signal: AbortSignal) => Promise + reconcileWslHookRelays: () => Promise onDaemonError: (error: unknown) => void onAgentHookServerError: (error: unknown) => void + onWslHookRelayReconciliationError: (error: unknown) => void } type StartupService = { @@ -62,8 +64,10 @@ function startService( export function startFirstWindowStartupServices({ startDaemonPtyProvider, startAgentHookServer, + reconcileWslHookRelays, onDaemonError, - onAgentHookServerError + onAgentHookServerError, + onWslHookRelayReconciliationError }: FirstWindowStartupServices): FirstWindowStartupServicesResult { // Why: daemon startup and hook-server binding are independent, but both gate // restored terminals; run them together so cold-start latency is max(), not sum(). @@ -74,7 +78,11 @@ export function startFirstWindowStartupServices({ // strand any fallback PTYs that spawn after the gate opens. const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError) const hooks = startService('agent hook server', startAgentHookServer, onAgentHookServerError) - const allServicesReady = Promise.all([daemon.ready, hooks.ready]).then(() => undefined) + let failOpened = false + const allServicesReady = Promise.all([daemon.ready, hooks.ready]) + .then(() => (failOpened ? undefined : reconcileWslHookRelays())) + .catch(onWslHookRelayReconciliationError) + .then(() => undefined) let windowTimeout: ReturnType | null = null let failOpenTimeout: ReturnType | null = null const servicesSettled = allServicesReady.finally(() => { @@ -87,6 +95,7 @@ export function startFirstWindowStartupServices({ }) const failOpenReady = new Promise((resolve) => { failOpenTimeout = setTimeout(() => { + failOpened = true daemon.reportTimeout() hooks.reportTimeout() resolve()