mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(wsl): reconcile hook relays on startup
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<boolean> {
|
||||
/** One non-booting snapshot; null fails closed when WSL cannot report authority. */
|
||||
export function listRunningWslDistros(): Promise<string[] | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'wsl.exe',
|
||||
@@ -157,20 +152,26 @@ export function isWslDistroRunning(distro: string): Promise<boolean> {
|
||||
{ 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<boolean> {
|
||||
const running = await listRunningWslDistros()
|
||||
const wanted = wslHookRelayStateKey(distro)
|
||||
return running?.some((candidate) => wslHookRelayStateKey(candidate) === wanted) ?? false
|
||||
}
|
||||
|
||||
export function runWslInstallProcess(
|
||||
distro: string,
|
||||
script: string,
|
||||
|
||||
@@ -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<never[]>((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')
|
||||
|
||||
@@ -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<void> {
|
||||
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}`)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<string, () => void>()
|
||||
const ensureForDistro = vi.fn(
|
||||
(distro: string) =>
|
||||
new Promise<void>((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')
|
||||
})
|
||||
})
|
||||
@@ -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<PtyProcessInfo[] | null>
|
||||
listRunningDistros: () => Promise<string[] | null>
|
||||
ensureForDistro: (distro: string) => Promise<void>
|
||||
}
|
||||
|
||||
const defaultDeps: StartupReconciliationDeps = {
|
||||
platform: process.platform,
|
||||
listLiveProcesses: listLiveDaemonProcesses,
|
||||
listRunningDistros: listRunningWslDistros,
|
||||
ensureForDistro: (distro) => wslHookRelayManager.ensureForDistroReady(distro)
|
||||
}
|
||||
|
||||
export async function reconcileWslHookRelaysOnStartup(
|
||||
deps: Partial<StartupReconciliationDeps> = {}
|
||||
): Promise<void> {
|
||||
const io = { ...defaultDeps, ...deps }
|
||||
if (io.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
const processes = await io.listLiveProcesses()
|
||||
if (!processes) {
|
||||
return
|
||||
}
|
||||
const distros = new Map<string, string>()
|
||||
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)] : []
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> = {}): void {
|
||||
@@ -1081,7 +1082,7 @@ export async function getCurrentDaemonMacTccAttributionHealth(): Promise<MacDaem
|
||||
}
|
||||
|
||||
/** Returns null unless every daemon generation supplied an authoritative inventory. */
|
||||
export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
|
||||
export async function listLiveDaemonProcesses(): Promise<PtyProcessInfo[] | null> {
|
||||
if (!adapter) {
|
||||
return null
|
||||
}
|
||||
@@ -1096,10 +1097,15 @@ export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
|
||||
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<string[] | null> {
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
})
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
events.push('daemon-started')
|
||||
resolveDaemon = resolve
|
||||
}),
|
||||
startAgentHookServer: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
events.push('hooks-started')
|
||||
resolveHooks = resolve
|
||||
}),
|
||||
reconcileWslHookRelays: () =>
|
||||
new Promise<void>((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<void>((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<void>(() => {})
|
||||
},
|
||||
startAgentHookServer: () => Promise.resolve(),
|
||||
reconcileWslHookRelays: vi.fn(async () => {}),
|
||||
onDaemonError,
|
||||
onAgentHookServerError
|
||||
onAgentHookServerError,
|
||||
onWslHookRelayReconciliationError: vi.fn()
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
type FirstWindowStartupServices = {
|
||||
startDaemonPtyProvider: (signal: AbortSignal) => Promise<void>
|
||||
startAgentHookServer: (signal: AbortSignal) => Promise<void>
|
||||
reconcileWslHookRelays: () => Promise<void>
|
||||
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<typeof setTimeout> | null = null
|
||||
let failOpenTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const servicesSettled = allServicesReady.finally(() => {
|
||||
@@ -87,6 +95,7 @@ export function startFirstWindowStartupServices({
|
||||
})
|
||||
const failOpenReady = new Promise<void>((resolve) => {
|
||||
failOpenTimeout = setTimeout(() => {
|
||||
failOpened = true
|
||||
daemon.reportTimeout()
|
||||
hooks.reportTimeout()
|
||||
resolve()
|
||||
|
||||
Reference in New Issue
Block a user