mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(wsl): harden hook relay startup readiness
This commit is contained in:
@@ -323,15 +323,15 @@ async function main() {
|
||||
// Why: if jiti ever returns a second manager module, our monkey-patch would miss the singleton
|
||||
// that ensureWslHookRelayForReattach (loaded via pty.ts) closes over — fail closed now.
|
||||
const singletonProbe = []
|
||||
const previousEnsure = wslHookRelayManager.ensureForDistro.bind(wslHookRelayManager)
|
||||
wslHookRelayManager.ensureForDistro = (probedDistro) => {
|
||||
const previousEnsureReady = wslHookRelayManager.ensureForDistroReady.bind(wslHookRelayManager)
|
||||
wslHookRelayManager.ensureForDistroReady = async (probedDistro) => {
|
||||
singletonProbe.push(probedDistro)
|
||||
}
|
||||
ensureWslHookRelayForReattach(
|
||||
await ensureWslHookRelayForReattach(
|
||||
{ isReattach: true, wslDistro: '__bench-singleton-probe__' },
|
||||
null
|
||||
)
|
||||
wslHookRelayManager.ensureForDistro = previousEnsure
|
||||
wslHookRelayManager.ensureForDistroReady = previousEnsureReady
|
||||
if (singletonProbe.length !== 1 || singletonProbe[0] !== '__bench-singleton-probe__') {
|
||||
throw new Error(
|
||||
'jiti duplicated the WSL hook-relay manager graph; reattach patch would not observe pty.ts'
|
||||
@@ -360,15 +360,15 @@ async function main() {
|
||||
// Why: pty.ts refreshes through the production singleton, so route that singleton at the
|
||||
// benchmark-scoped manager instead of calling the reattach helper from here — a removed or
|
||||
// mislocated integration call in pty.ts must fail this benchmark.
|
||||
wslHookRelayManager.ensureForDistro = (refreshedDistro) => {
|
||||
wslHookRelayManager.ensureForDistroReady = async (refreshedDistro) => {
|
||||
relayRefreshes.push(refreshedDistro)
|
||||
manager?.ensureForDistro(refreshedDistro)
|
||||
await manager?.ensureForDistroReady(refreshedDistro)
|
||||
}
|
||||
|
||||
const { runtime, getController } = createRuntimeStub()
|
||||
// Why hooks off: fresh WSL spawn still runs buildPtyHostEnv, which calls ensureForDistro when
|
||||
// hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach);
|
||||
// the manager's own hooks gate lives behind the ensureForDistro patch above, so it stays out
|
||||
// the manager's own hooks gate lives behind the ensureForDistroReady patch above, so it stays out
|
||||
// of the measurement — `manager` below resolves its own (enabled) managed-hook settings.
|
||||
ptyIpc.registerPtyHandlers(
|
||||
createRendererWindowStub(),
|
||||
@@ -474,7 +474,7 @@ async function main() {
|
||||
],
|
||||
ensureForDistro: async (startupDistro) => {
|
||||
startupDistros.push(startupDistro)
|
||||
await manager.ensureForDistroReady(startupDistro)
|
||||
await manager.ensureRunningDistroForStartup(startupDistro)
|
||||
}
|
||||
})
|
||||
const startupRelayReadyMs = performance.now() - startupStartedAt
|
||||
|
||||
@@ -249,15 +249,26 @@ export async function launchWslRelayWithInstall(options: {
|
||||
onChild: (child: ChildProcessWithoutNullStreams) => void
|
||||
onNoNode: () => void
|
||||
onFailure: (message: string) => void
|
||||
continueAfterFailure?: () => Promise<boolean>
|
||||
onContinuationBlocked: () => void
|
||||
connect: (transport: MultiplexerTransport, child: ChildProcessWithoutNullStreams) => Promise<void>
|
||||
}): Promise<void> {
|
||||
const { distro, env, bundleJsPath, version, io } = options
|
||||
let installTried = false
|
||||
let transientRetries = 0
|
||||
let continuationRequiresRunning = false
|
||||
for (;;) {
|
||||
if (options.isDisposed()) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
continuationRequiresRunning &&
|
||||
!(await canContinueRelayStartup(options.continueAfterFailure))
|
||||
) {
|
||||
options.onContinuationBlocked()
|
||||
return
|
||||
}
|
||||
continuationRequiresRunning = false
|
||||
const child = io.spawnRelay(distro, env, version)
|
||||
options.onChild(child)
|
||||
try {
|
||||
@@ -284,13 +295,19 @@ export async function launchWslRelayWithInstall(options: {
|
||||
) {
|
||||
transientRetries++
|
||||
await new Promise((resolve) => setTimeout(resolve, io.transientRetryDelayMs))
|
||||
continuationRequiresRunning = true
|
||||
continue
|
||||
}
|
||||
if (!installTried) {
|
||||
if (!(await canContinueRelayStartup(options.continueAfterFailure))) {
|
||||
options.onContinuationBlocked()
|
||||
return
|
||||
}
|
||||
installTried = true
|
||||
const script = buildGuestInstallScript(io.readBundle(bundleJsPath), version)
|
||||
const result = await io.runInstall(distro, script, env)
|
||||
if (result.code === 0) {
|
||||
continuationRequiresRunning = true
|
||||
continue
|
||||
}
|
||||
options.onFailure(
|
||||
@@ -304,6 +321,19 @@ export async function launchWslRelayWithInstall(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function canContinueRelayStartup(
|
||||
continueAfterFailure: (() => Promise<boolean>) | undefined
|
||||
): Promise<boolean> {
|
||||
if (!continueAfterFailure) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
return await continueAfterFailure()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWslRelayFailure(failure: WslRelayStartupFailure): string {
|
||||
const detail = failure.stderr.trim()
|
||||
return `startup failed (${failure.kind}, code ${failure.code ?? 'unknown'})${detail ? `: ${detail}` : ''}`
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
|
||||
export type WslHookRelayLifecycleState = {
|
||||
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
|
||||
distro: string
|
||||
phase: 'starting' | 'running' | 'failed'
|
||||
child?: ChildProcessWithoutNullStreams
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
guestEndpointFilePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
connectedAt?: number
|
||||
restartTimer?: ReturnType<typeof setTimeout>
|
||||
reinstallTimer?: ReturnType<typeof setTimeout>
|
||||
lastInstallAt?: number
|
||||
firstAttempt?: Promise<void>
|
||||
}
|
||||
@@ -275,7 +275,7 @@ describe('WslHookRelayManager', () => {
|
||||
const { manager, deps } = createManager({ installHooks })
|
||||
let ready = false
|
||||
|
||||
const firstAttempt = manager.ensureForDistroReady('Ubuntu').then(() => {
|
||||
const firstAttempt = manager.ensureRunningDistroForStartup('Ubuntu').then(() => {
|
||||
ready = true
|
||||
})
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
@@ -285,6 +285,90 @@ describe('WslHookRelayManager', () => {
|
||||
await firstAttempt
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).not.toBeNull()
|
||||
expect(ready).toBe(true)
|
||||
expect(deps.isDistroRunning).not.toHaveBeenCalled()
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('awaits an equivalent first attempt already started by a PTY path', async () => {
|
||||
let releaseInstall: (() => void) | undefined
|
||||
const installHooks = vi.fn(
|
||||
() => new Promise<never[]>((resolve) => (releaseInstall = () => resolve([])))
|
||||
)
|
||||
const { manager, deps } = createManager({ installHooks })
|
||||
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1))
|
||||
let ready = false
|
||||
const startupAttempt = manager.ensureForDistroReady('ubuntu').then(() => {
|
||||
ready = true
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(ready).toBe(false)
|
||||
|
||||
releaseInstall?.()
|
||||
await startupAttempt
|
||||
expect(ready).toBe(true)
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('does not respawn when a startup-owned distro stops during guest install', async () => {
|
||||
const isDistroRunning = vi
|
||||
.fn<() => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
const waitForSentinel = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(startupError(42))
|
||||
.mockImplementationOnce(async () => guestTransport())
|
||||
const { manager, deps } = createManager({ isDistroRunning, waitForSentinel })
|
||||
|
||||
await manager.ensureRunningDistroForStartup('Ubuntu')
|
||||
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
expect(deps.runInstall).toHaveBeenCalledTimes(1)
|
||||
expect(isDistroRunning).toHaveBeenCalledTimes(2)
|
||||
expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('continuation skipped'))
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('does not retry a transient startup failure after the distro stops', async () => {
|
||||
const isDistroRunning = vi.fn(async () => false)
|
||||
const waitForSentinel = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(startupError(1, 'Catastrophic failure (E_UNEXPECTED)'))
|
||||
.mockImplementationOnce(async () => guestTransport())
|
||||
const { manager, deps } = createManager({ isDistroRunning, waitForSentinel })
|
||||
|
||||
await manager.ensureRunningDistroForStartup('Ubuntu')
|
||||
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
expect(deps.runInstall).not.toHaveBeenCalled()
|
||||
expect(isDistroRunning).toHaveBeenCalledOnce()
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('lets a concurrent reattach retry after startup drops a stopped attempt', async () => {
|
||||
let rejectStartup!: (error: Error) => void
|
||||
const waitForSentinel = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() => new Promise<MultiplexerTransport>((_resolve, reject) => (rejectStartup = reject))
|
||||
)
|
||||
.mockImplementationOnce(async () => guestTransport())
|
||||
const { manager, deps } = createManager({
|
||||
isDistroRunning: vi.fn(async () => false),
|
||||
waitForSentinel
|
||||
})
|
||||
|
||||
const startup = manager.ensureRunningDistroForStartup('Ubuntu')
|
||||
await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(1))
|
||||
const reattach = manager.ensureForDistroReady('ubuntu')
|
||||
rejectStartup(startupError(42))
|
||||
await Promise.all([startup, reattach])
|
||||
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(2)
|
||||
expect(deps.installHooks).toHaveBeenCalledTimes(1)
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { wireWslRelayLink } from './wsl-hook-relay-link'
|
||||
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
|
||||
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
|
||||
import type { WslHookRelayLifecycleState } from './wsl-hook-relay-lifecycle-state'
|
||||
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
@@ -30,27 +31,10 @@ import {
|
||||
wslHookRelayEndpointFilePath
|
||||
} from '../../shared/wsl-hook-relay-contract'
|
||||
|
||||
type DistroState = {
|
||||
/** Original casing for wsl.exe argv and breadcrumbs; map keys are lowercased. */
|
||||
distro: string
|
||||
phase: 'starting' | 'running' | 'failed'
|
||||
child?: ChildProcessWithoutNullStreams
|
||||
mux?: SshChannelMultiplexer
|
||||
guestHome?: string
|
||||
guestEndpointFilePath?: string
|
||||
opencodeOverlayDir?: string
|
||||
failures: number
|
||||
cooldownUntil: number
|
||||
connectedAt?: number
|
||||
restartTimer?: ReturnType<typeof setTimeout>
|
||||
reinstallTimer?: ReturnType<typeof setTimeout>
|
||||
lastInstallAt?: number
|
||||
}
|
||||
|
||||
export class WslHookRelayManager {
|
||||
private deps: WslHookRelayManagerDeps
|
||||
private recovery: WslRelayRecovery
|
||||
private states = new Map<string, DistroState>()
|
||||
private states = new Map<string, WslHookRelayLifecycleState>()
|
||||
/** Distros a hooks-off teardown stopped, so re-enabling can put them back. */
|
||||
private stoppedByHooksOff = new Set<string>()
|
||||
private defaultDistro: string | null = null
|
||||
@@ -85,18 +69,30 @@ export class WslHookRelayManager {
|
||||
void this.ensureForDistroReady(distro)
|
||||
}
|
||||
|
||||
/** Awaitable first attempt for the startup gate; failures retain timer-driven recovery. */
|
||||
/** Awaitable first attempt for startup and reattach ordering. */
|
||||
async ensureForDistroReady(distro: string | null): Promise<void> {
|
||||
await this.ensureForDistroReadyWhile(distro)
|
||||
}
|
||||
|
||||
/** Startup retries must not resurrect a distro that stopped after inventory. */
|
||||
async ensureRunningDistroForStartup(distro: string): Promise<void> {
|
||||
await this.ensureForDistroReadyWhile(distro, () => this.deps.isDistroRunning(distro))
|
||||
}
|
||||
|
||||
private async ensureForDistroReadyWhile(
|
||||
distro: string | null,
|
||||
continueAfterFailure?: () => Promise<boolean>
|
||||
): Promise<void> {
|
||||
if (this.disposed || !isWslHookRelayAllowed(this.deps)) {
|
||||
return
|
||||
}
|
||||
await this.ensureInternal(distro).catch((err) => {
|
||||
await this.ensureInternal(distro, continueAfterFailure).catch((err) => {
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`)
|
||||
})
|
||||
}
|
||||
|
||||
private stateFor(distro: string | null): DistroState | undefined {
|
||||
private stateFor(distro: string | null): WslHookRelayLifecycleState | undefined {
|
||||
// Empty key never matches a real (non-empty) distro state.
|
||||
return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? ''))
|
||||
}
|
||||
@@ -146,7 +142,10 @@ export class WslHookRelayManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInternal(requestedDistro: string | null): Promise<void> {
|
||||
private async ensureInternal(
|
||||
requestedDistro: string | null,
|
||||
continueAfterFailure?: () => Promise<boolean>
|
||||
): Promise<void> {
|
||||
const distro = requestedDistro ?? (await this.resolveDefaultDistro())
|
||||
if (!distro || this.disposed) {
|
||||
return
|
||||
@@ -158,6 +157,10 @@ export class WslHookRelayManager {
|
||||
void maybeRerunWslRelayGuestInstall(this.deps, existing)
|
||||
return
|
||||
}
|
||||
if (existing.phase === 'starting') {
|
||||
await existing.firstAttempt
|
||||
return await this.ensureInternal(requestedDistro, continueAfterFailure)
|
||||
}
|
||||
if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) {
|
||||
return
|
||||
}
|
||||
@@ -182,7 +185,7 @@ export class WslHookRelayManager {
|
||||
if (existing) {
|
||||
this.recovery.clearTimers(existing)
|
||||
}
|
||||
const state: DistroState = {
|
||||
const state: WslHookRelayLifecycleState = {
|
||||
distro,
|
||||
phase: 'starting',
|
||||
failures: existing?.failures ?? 0,
|
||||
@@ -195,31 +198,44 @@ export class WslHookRelayManager {
|
||||
|
||||
const env = buildWslRelaySpawnEnv(coords, bundle.version, instanceKey)
|
||||
|
||||
state.firstAttempt = launchWslRelayWithInstall({
|
||||
distro: state.distro,
|
||||
env,
|
||||
bundleJsPath: bundle.jsPath,
|
||||
version: bundle.version,
|
||||
io: this.deps,
|
||||
// Why the identity half: a hooks-off teardown drops this state and kills its child, but
|
||||
// that kill reads as a startup failure and the retry loop would respawn an untracked relay.
|
||||
isDisposed: () => this.disposed || this.states.get(key) !== state,
|
||||
onChild: (child) => {
|
||||
state.child = child
|
||||
},
|
||||
onNoNode: () =>
|
||||
this.markFailed(
|
||||
state,
|
||||
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
|
||||
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
|
||||
),
|
||||
onFailure: (message) =>
|
||||
this.markFailed(state, message, {
|
||||
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
|
||||
}),
|
||||
continueAfterFailure,
|
||||
onContinuationBlocked: () => {
|
||||
state.phase = 'failed'
|
||||
state.child = undefined
|
||||
if (this.states.get(key) === state) {
|
||||
this.states.delete(key)
|
||||
}
|
||||
this.deps.warn(
|
||||
`[agent-hooks] WSL hook relay (${state.distro}): distro no longer confirmed running; startup continuation skipped`
|
||||
)
|
||||
},
|
||||
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
|
||||
})
|
||||
|
||||
try {
|
||||
await launchWslRelayWithInstall({
|
||||
distro: state.distro,
|
||||
env,
|
||||
bundleJsPath: bundle.jsPath,
|
||||
version: bundle.version,
|
||||
io: this.deps,
|
||||
// Why the identity half: a hooks-off teardown drops this state and kills its child, but
|
||||
// that kill reads as a startup failure and the retry loop would respawn an untracked relay.
|
||||
isDisposed: () => this.disposed || this.states.get(key) !== state,
|
||||
onChild: (child) => {
|
||||
state.child = child
|
||||
},
|
||||
onNoNode: () =>
|
||||
this.markFailed(
|
||||
state,
|
||||
`no node >= 18 found in distro '${state.distro}'; agent hooks stay degraded there`,
|
||||
{ cooldownBaseMs: NO_NODE_COOLDOWN_MS }
|
||||
),
|
||||
onFailure: (message) =>
|
||||
this.markFailed(state, message, {
|
||||
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
|
||||
}),
|
||||
connect: (transport, child) => this.connect(state, transport, child, instanceKey)
|
||||
})
|
||||
await state.firstAttempt
|
||||
} catch (err) {
|
||||
// Why: teardown may have already recorded this failure; don't double-
|
||||
// count. A request-level error can leave a live child — never leak it.
|
||||
@@ -230,11 +246,13 @@ export class WslHookRelayManager {
|
||||
cooldownBaseMs: FAILURE_COOLDOWN_BASE_MS
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
state.firstAttempt = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async connect(
|
||||
state: DistroState,
|
||||
state: WslHookRelayLifecycleState,
|
||||
transport: MultiplexerTransport,
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
instanceKey: string
|
||||
@@ -306,7 +324,7 @@ export class WslHookRelayManager {
|
||||
* one failed relaunch must not end self-recovery; the timer's
|
||||
* distro-running probe keeps this from booting stopped distros. */
|
||||
private markFailed(
|
||||
state: DistroState,
|
||||
state: WslHookRelayLifecycleState,
|
||||
message: string,
|
||||
options: { cooldownBaseMs: number }
|
||||
): void {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ensureWslHookRelayForReattach } from './wsl-hook-relay-reattach'
|
||||
import {
|
||||
ensureWslHookRelayForReattach,
|
||||
WSL_HOOK_RELAY_REATTACH_FAIL_OPEN_MS
|
||||
} from './wsl-hook-relay-reattach'
|
||||
|
||||
describe('ensureWslHookRelayForReattach', () => {
|
||||
it('refreshes the surviving session distro after a local reattach', () => {
|
||||
it('refreshes the surviving session distro after a local reattach', async () => {
|
||||
const ensure = vi.fn()
|
||||
|
||||
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu-24.04' }, null, ensure)
|
||||
await ensureWslHookRelayForReattach(
|
||||
{ isReattach: true, wslDistro: 'Ubuntu-24.04' },
|
||||
null,
|
||||
ensure
|
||||
)
|
||||
|
||||
expect(ensure).toHaveBeenCalledOnce()
|
||||
expect(ensure).toHaveBeenCalledWith('Ubuntu-24.04')
|
||||
@@ -19,20 +26,76 @@ describe('ensureWslHookRelayForReattach', () => {
|
||||
['blank distro', { isReattach: true, wslDistro: ' ' }, null],
|
||||
['SSH reattach', { isReattach: true, wslDistro: 'Ubuntu' }, 'ssh-1'],
|
||||
['relay reattach', { isReattach: true, wslDistro: 'Ubuntu' }, 'relay-1']
|
||||
])('does not refresh a %s', (_label, result, connectionId) => {
|
||||
])('does not refresh a %s', async (_label, result, connectionId) => {
|
||||
const ensure = vi.fn()
|
||||
|
||||
ensureWslHookRelayForReattach(result, connectionId, ensure)
|
||||
await ensureWslHookRelayForReattach(result, connectionId, ensure)
|
||||
|
||||
expect(ensure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves exact distro ownership across multiple local reattachments', () => {
|
||||
it('preserves exact distro ownership across multiple local reattachments', async () => {
|
||||
const ensure = vi.fn()
|
||||
|
||||
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu' }, null, ensure)
|
||||
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Debian' }, null, ensure)
|
||||
await ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu' }, null, ensure)
|
||||
await ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Debian' }, null, ensure)
|
||||
|
||||
expect(ensure.mock.calls).toEqual([['Ubuntu'], ['Debian']])
|
||||
})
|
||||
|
||||
it('awaits relay readiness only for the reattached WSL session', async () => {
|
||||
let release!: () => void
|
||||
const ensure = vi.fn(() => new Promise<void>((resolve) => (release = resolve)))
|
||||
let ready = false
|
||||
|
||||
const reattach = ensureWslHookRelayForReattach(
|
||||
{ isReattach: true, wslDistro: 'Ubuntu' },
|
||||
null,
|
||||
ensure
|
||||
).then(() => {
|
||||
ready = true
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(ready).toBe(false)
|
||||
|
||||
release()
|
||||
await reattach
|
||||
expect(ready).toBe(true)
|
||||
})
|
||||
|
||||
it('fails open a WSL reattach when optional relay readiness stalls', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const reattach = ensureWslHookRelayForReattach(
|
||||
{ isReattach: true, wslDistro: 'Ubuntu' },
|
||||
null,
|
||||
() => new Promise<void>(() => {})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WSL_HOOK_RELAY_REATTACH_FAIL_OPEN_MS)
|
||||
await expect(reattach).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('timed out'))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails open a WSL reattach when relay readiness rejects', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
await expect(
|
||||
ensureWslHookRelayForReattach({ isReattach: true, wslDistro: 'Ubuntu' }, null, () =>
|
||||
Promise.reject(new Error('relay failed'))
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reattach failed'),
|
||||
expect.any(Error)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,18 +3,37 @@ import { wslHookRelayManager } from './wsl-hook-relay-manager'
|
||||
|
||||
type ReattachResult = Pick<PtySpawnResult, 'isReattach' | 'wslDistro'>
|
||||
|
||||
export function ensureWslHookRelayForReattach(
|
||||
export const WSL_HOOK_RELAY_REATTACH_FAIL_OPEN_MS = 60_000
|
||||
|
||||
export async function ensureWslHookRelayForReattach(
|
||||
result: ReattachResult,
|
||||
connectionId?: string | null,
|
||||
ensureForDistro: (distro: string) => void = (distro) =>
|
||||
wslHookRelayManager.ensureForDistro(distro)
|
||||
): void {
|
||||
ensureForDistro: (distro: string) => void | Promise<void> = (distro) =>
|
||||
wslHookRelayManager.ensureForDistroReady(distro)
|
||||
): Promise<void> {
|
||||
// Why: the current renderer preference can differ from the surviving PTY's proven distro ownership.
|
||||
if (connectionId || result.isReattach !== true || typeof result.wslDistro !== 'string') {
|
||||
return
|
||||
}
|
||||
const distro = result.wslDistro.trim()
|
||||
if (distro) {
|
||||
ensureForDistro(distro)
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.resolve().then(() => ensureForDistro(distro)),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
console.warn(`[agent-hooks] WSL hook relay reattach timed out for '${distro}'`)
|
||||
resolve()
|
||||
}, WSL_HOOK_RELAY_REATTACH_FAIL_OPEN_MS)
|
||||
})
|
||||
])
|
||||
} catch (error) {
|
||||
console.warn(`[agent-hooks] WSL hook relay reattach failed for '${distro}':`, error)
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const defaultDeps: StartupReconciliationDeps = {
|
||||
platform: process.platform,
|
||||
listLiveProcesses: listLiveDaemonProcesses,
|
||||
listRunningDistros: listRunningWslDistros,
|
||||
ensureForDistro: (distro) => wslHookRelayManager.ensureForDistroReady(distro)
|
||||
ensureForDistro: (distro) => wslHookRelayManager.ensureRunningDistroForStartup(distro)
|
||||
}
|
||||
|
||||
export async function reconcileWslHookRelaysOnStartup(
|
||||
|
||||
+105
-4
@@ -1174,6 +1174,84 @@ describe('registerPtyHandlers', () => {
|
||||
clearProviderPtyState(ptyId)
|
||||
})
|
||||
|
||||
it.each(['renderer', 'runtime'] as const)(
|
||||
'rejects %s publication when a WSL PTY exits during relay readiness',
|
||||
async (entry) => {
|
||||
const ptyId = `pty-${entry}-relay-exit`
|
||||
const incarnationId = `incarnation-${entry}-relay-exit`
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const registerRuntimePty = vi.spyOn(runtime, 'registerPty')
|
||||
let releaseRelay!: () => void
|
||||
const ensureForDistro = vi
|
||||
.spyOn(wslHookRelayManager, 'ensureForDistroReady')
|
||||
.mockImplementation(() => new Promise<void>((resolve) => (releaseRelay = resolve)))
|
||||
const provider = createAgentClaimProvider({
|
||||
spawn: vi.fn(async () => {
|
||||
runtime.onPtySpawned(ptyId, incarnationId)
|
||||
return {
|
||||
id: ptyId,
|
||||
incarnationId,
|
||||
isReattach: true,
|
||||
wslDistro: 'Ubuntu-24.04'
|
||||
}
|
||||
}),
|
||||
authoritativeOwnerListings: false
|
||||
})
|
||||
const store = { persistPtyBinding: vi.fn() }
|
||||
setLocalPtyProvider(provider as never)
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const spawnArgs = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp/worktree',
|
||||
sessionId: ptyId,
|
||||
worktreeId: 'repo::/tmp/worktree',
|
||||
tabId: `tab-${entry}-relay-exit`,
|
||||
leafId: '55555555-5555-4555-8555-555555555555'
|
||||
}
|
||||
const controller = (
|
||||
runtime as unknown as {
|
||||
ptyController: { spawn(args: Record<string, unknown>): Promise<unknown> }
|
||||
}
|
||||
).ptyController
|
||||
|
||||
try {
|
||||
const spawn =
|
||||
entry === 'renderer'
|
||||
? (handlers.get('pty:spawn')!(null, spawnArgs) as Promise<unknown>)
|
||||
: controller.spawn({
|
||||
...spawnArgs,
|
||||
preAllocatedHandle: 'term-runtime-relay-exit',
|
||||
persistHostSessionBinding: true
|
||||
})
|
||||
const rejectedSpawn = expect(spawn).rejects.toThrow('agent_session_exited_during_start')
|
||||
await vi.waitFor(() => expect(ensureForDistro).toHaveBeenCalledWith('Ubuntu-24.04'))
|
||||
runtime.onPtyExit(ptyId, 0, incarnationId)
|
||||
releaseRelay()
|
||||
await rejectedSpawn
|
||||
|
||||
expect(store.persistPtyBinding).not.toHaveBeenCalled()
|
||||
expect(registerRuntimePty).not.toHaveBeenCalled()
|
||||
const internals = runtime as unknown as {
|
||||
earlyExitedPtyIncarnations: Map<string, string | null>
|
||||
pendingPtyRegistrationIncarnations: Map<string, string | null>
|
||||
}
|
||||
expect(internals.earlyExitedPtyIncarnations.size).toBe(0)
|
||||
expect(internals.pendingPtyRegistrationIncarnations.size).toBe(0)
|
||||
} finally {
|
||||
ensureForDistro.mockRestore()
|
||||
clearProviderPtyState(ptyId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('adopts a live controller-owned local fallback when listings cannot serialize claims', async () => {
|
||||
const sessions: {
|
||||
id: string
|
||||
@@ -9069,7 +9147,7 @@ describe('registerPtyHandlers', () => {
|
||||
'adopts a completed runtime-owned pane before replacement launch preflight ($label)',
|
||||
async ({ worktreeId, cwd }) => {
|
||||
type StableAdoption = {
|
||||
result: { id: string; incarnationId?: string; isReattach?: boolean }
|
||||
result: { id: string; incarnationId?: string; isReattach?: boolean; wslDistro?: string }
|
||||
owner: { handle?: string; tabId: string; leafId: string; ptyId: string }
|
||||
materialized?: true
|
||||
} | null
|
||||
@@ -9091,6 +9169,9 @@ describe('registerPtyHandlers', () => {
|
||||
const tabId = 'tab-live-owner'
|
||||
const leafId = '66666666-6666-4666-8666-666666666666'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
const ensureForDistro = vi
|
||||
.spyOn(wslHookRelayManager, 'ensureForDistroReady')
|
||||
.mockResolvedValue(undefined)
|
||||
let ownerPublished = false
|
||||
let releaseAttach!: () => void
|
||||
let attachBarrier: Promise<void>
|
||||
@@ -9110,6 +9191,7 @@ describe('registerPtyHandlers', () => {
|
||||
id: 'pty-live-owner',
|
||||
incarnationId: 'inc-live-owner',
|
||||
isReattach: true,
|
||||
wslDistro: 'Ubuntu-24.04',
|
||||
snapshot: 'original-live-output',
|
||||
providerSequence: { value: 20, generation: 'continued' as const }
|
||||
}
|
||||
@@ -9334,6 +9416,8 @@ describe('registerPtyHandlers', () => {
|
||||
expect(
|
||||
mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned')
|
||||
).toHaveLength(1)
|
||||
expect(ensureForDistro).toHaveBeenCalledTimes(2)
|
||||
ensureForDistro.mockRestore()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17482,9 +17566,10 @@ describe('registerPtyHandlers', () => {
|
||||
it('refreshes the WSL hook relay for the distro a reattached pane already owns', async () => {
|
||||
// Why here and not only in the helper's unit test: nothing else catches pty.ts dropping the
|
||||
// reattach call — the manager owns the hooks/platform gating this spy stands in for.
|
||||
let releaseRelay!: () => void
|
||||
const ensureForDistro = vi
|
||||
.spyOn(wslHookRelayManager, 'ensureForDistro')
|
||||
.mockImplementation(() => {})
|
||||
.spyOn(wslHookRelayManager, 'ensureForDistroReady')
|
||||
.mockImplementation(() => new Promise<void>((resolve) => (releaseRelay = resolve)))
|
||||
setLocalPtyProvider({
|
||||
spawn: vi.fn(async () => ({ id: 'pty-wsl', isReattach: true, wslDistro: 'Ubuntu-24.04' })),
|
||||
write: vi.fn(),
|
||||
@@ -17499,7 +17584,23 @@ describe('registerPtyHandlers', () => {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
|
||||
try {
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: 'pty-wsl' })
|
||||
let spawnReady = false
|
||||
const spawn = (
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
sessionId: 'pty-wsl'
|
||||
}) as Promise<unknown>
|
||||
).then(() => {
|
||||
spawnReady = true
|
||||
})
|
||||
await vi.waitFor(() => expect(ensureForDistro).toHaveBeenCalledWith('Ubuntu-24.04'))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(spawnReady).toBe(false)
|
||||
|
||||
releaseRelay()
|
||||
await spawn
|
||||
expect(spawnReady).toBe(true)
|
||||
expect(ensureForDistro).toHaveBeenCalledWith('Ubuntu-24.04')
|
||||
} finally {
|
||||
ensureForDistro.mockRestore()
|
||||
|
||||
+6
-6
@@ -4414,10 +4414,6 @@ export function registerPtyHandlers(
|
||||
leafId: preAdoptedStablePane.owner.leafId
|
||||
}
|
||||
}
|
||||
ensureWslHookRelayForReattach(
|
||||
{ isReattach: true, wslDistro: preAdoptedStablePane.result.wslDistro },
|
||||
args.connectionId
|
||||
)
|
||||
if (!args.connectionId) {
|
||||
options?.onCodexHomePtySpawned?.({
|
||||
id: result.id,
|
||||
@@ -4997,7 +4993,6 @@ export function registerPtyHandlers(
|
||||
sequenceBeforeProviderSpawn
|
||||
)
|
||||
}
|
||||
ensureWslHookRelayForReattach(result, args.connectionId)
|
||||
runtime?.preparePtyExecutionContext?.(
|
||||
result.id,
|
||||
args.connectionId
|
||||
@@ -5006,6 +5001,9 @@ export function registerPtyHandlers(
|
||||
? expectedWslDistro
|
||||
: result.wslDistro
|
||||
)
|
||||
await ensureWslHookRelayForReattach(result, args.connectionId)
|
||||
// Why: the PTY can exit while WSL relay readiness is pending.
|
||||
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
|
||||
} catch (err) {
|
||||
if (
|
||||
(isNewDaemonSession || preparedProvisionalExecutionContext) &&
|
||||
@@ -6430,7 +6428,6 @@ export function registerPtyHandlers(
|
||||
sequenceBeforeProviderSpawn
|
||||
)
|
||||
}
|
||||
ensureWslHookRelayForReattach(result, args.connectionId)
|
||||
runtime?.preparePtyExecutionContext?.(
|
||||
result.id,
|
||||
args.connectionId
|
||||
@@ -6439,6 +6436,9 @@ export function registerPtyHandlers(
|
||||
? expectedWslDistro
|
||||
: result.wslDistro
|
||||
)
|
||||
await ensureWslHookRelayForReattach(result, args.connectionId)
|
||||
// Why: the PTY can exit while WSL relay readiness is pending.
|
||||
runtime?.assertPtyRegistrationAllowed?.(result.id, result.incarnationId)
|
||||
spawnTiming.mark('provider_spawn')
|
||||
} catch (err) {
|
||||
if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) {
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('startFirstWindowStartupServices', () => {
|
||||
expect(completed).toBe(true)
|
||||
})
|
||||
|
||||
it('reconciles after both authorities and keeps local reattach gated until relays are ready', async () => {
|
||||
it('starts reconciliation after both authorities without gating unrelated local PTYs', async () => {
|
||||
const events: string[] = []
|
||||
let resolveDaemon!: () => void
|
||||
let resolveHooks!: () => void
|
||||
@@ -66,7 +66,10 @@ describe('startFirstWindowStartupServices', () => {
|
||||
reconcileWslHookRelays: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
events.push('relay-reconciliation-started')
|
||||
resolveRelays = resolve
|
||||
resolveRelays = () => {
|
||||
events.push('relay-reconciliation-ready')
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
onDaemonError: vi.fn(),
|
||||
onAgentHookServerError: vi.fn(),
|
||||
@@ -80,16 +83,22 @@ describe('startFirstWindowStartupServices', () => {
|
||||
|
||||
resolveDaemon()
|
||||
await vi.waitFor(() => expect(events).toContain('relay-reconciliation-started'))
|
||||
let firstWindowReady = false
|
||||
void started.firstWindowReady.then(() => {
|
||||
firstWindowReady = true
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(firstWindowReady).toBe(true)
|
||||
let localPtyReady = false
|
||||
void started.localPtyReady.then(() => {
|
||||
localPtyReady = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(localPtyReady).toBe(false)
|
||||
|
||||
resolveRelays()
|
||||
await started.localPtyReady
|
||||
expect(localPtyReady).toBe(true)
|
||||
expect(events).not.toContain('relay-reconciliation-ready')
|
||||
|
||||
resolveRelays()
|
||||
await vi.waitFor(() => expect(events).toContain('relay-reconciliation-ready'))
|
||||
})
|
||||
|
||||
it('reports reconciliation failure and still opens the startup gate', async () => {
|
||||
@@ -104,7 +113,9 @@ describe('startFirstWindowStartupServices', () => {
|
||||
})
|
||||
|
||||
await expect(started.localPtyReady).resolves.toBeUndefined()
|
||||
expect(onWslHookRelayReconciliationError).toHaveBeenCalledWith(expect.any(Error))
|
||||
await vi.waitFor(() =>
|
||||
expect(onWslHookRelayReconciliationError).toHaveBeenCalledWith(expect.any(Error))
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the provider gate when daemon startup finishes without waiting for hooks', async () => {
|
||||
@@ -233,16 +244,19 @@ describe('startFirstWindowStartupServices', () => {
|
||||
vi.useFakeTimers()
|
||||
const onDaemonError = vi.fn()
|
||||
const onAgentHookServerError = vi.fn()
|
||||
const reconcileWslHookRelays = vi.fn(async () => {})
|
||||
let daemonSignal: AbortSignal | undefined
|
||||
|
||||
try {
|
||||
const started = startFirstWindowStartupServices({
|
||||
startDaemonPtyProvider: (signal) => {
|
||||
daemonSignal = signal
|
||||
return new Promise<void>(() => {})
|
||||
return new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), { once: true })
|
||||
})
|
||||
},
|
||||
startAgentHookServer: () => Promise.resolve(),
|
||||
reconcileWslHookRelays: vi.fn(async () => {}),
|
||||
reconcileWslHookRelays,
|
||||
onDaemonError,
|
||||
onAgentHookServerError,
|
||||
onWslHookRelayReconciliationError: vi.fn()
|
||||
@@ -257,6 +271,7 @@ describe('startFirstWindowStartupServices', () => {
|
||||
expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error))
|
||||
expect(onAgentHookServerError).not.toHaveBeenCalled()
|
||||
expect(daemonSignal?.aborted).toBe(true)
|
||||
expect(reconcileWslHookRelays).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
@@ -79,13 +79,15 @@ export function startFirstWindowStartupServices({
|
||||
const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError)
|
||||
const hooks = startService('agent hook server', startAgentHookServer, onAgentHookServerError)
|
||||
let failOpened = false
|
||||
const allServicesReady = Promise.all([daemon.ready, hooks.ready])
|
||||
const startupAuthoritiesReady = Promise.all([daemon.ready, hooks.ready]).then(() => undefined)
|
||||
const wslHookRelayReconciliation = startupAuthoritiesReady
|
||||
.then(() => (failOpened ? undefined : reconcileWslHookRelays()))
|
||||
.catch(onWslHookRelayReconciliationError)
|
||||
.then(() => undefined)
|
||||
void wslHookRelayReconciliation
|
||||
let windowTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let failOpenTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const servicesSettled = allServicesReady.finally(() => {
|
||||
const startupAuthoritiesSettled = startupAuthoritiesReady.finally(() => {
|
||||
if (windowTimeout) {
|
||||
clearTimeout(windowTimeout)
|
||||
}
|
||||
@@ -102,12 +104,12 @@ export function startFirstWindowStartupServices({
|
||||
}, LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS)
|
||||
})
|
||||
const firstWindowReady = Promise.race([
|
||||
servicesSettled,
|
||||
startupAuthoritiesSettled,
|
||||
new Promise<void>((resolve) => {
|
||||
windowTimeout = setTimeout(resolve, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
const localPtyReady = Promise.race([servicesSettled, failOpenReady])
|
||||
const localPtyReady = Promise.race([startupAuthoritiesSettled, failOpenReady])
|
||||
// Why: destructive routing only needs daemon authority. A stalled optional
|
||||
// hook server must not hold terminal close for the full fail-open window.
|
||||
const localPtyProviderReady = Promise.race([daemon.ready, failOpenReady])
|
||||
|
||||
Reference in New Issue
Block a user