mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
fix(wsl): never boot a terminated distro off a list taken while it was still stopping (#35143)
* fix(wsl): never boot a terminated distro off a list taken while it was still stopping WSL keeps listing a distro for a moment while it stops. A bridge pane's exit now marks its distro as possibly stopped, its host-loss verdict and boot-id reread only count a running list started after that settles, and deferred background work (file watcher and hook relay restarts, history cleanup, CLI repair) is drained only by a settled list. Before, a bridge exit's own list could read 'running' mid-stop and lead to a boot-id exec, and any confirmed list probed mid-stop drained deferred restarts that spawn wsl.exe, booting the distro the user had just terminated. * fix(wsl): gate the hook-relay restart and setup-script inspection on a settled running read - A deferred hook-relay restart re-reads a settled running list right before spawning the relay (wsl.exe), and waits again if the distro stopped; a drained deferral is only a hint. - Setup-script import-candidate inspection (sidebar prompt, runtime RPC) opens nothing under \\\\wsl.localhost while the distro is stopped or unverifiable: those UNC opens booted it about 2 s after each terminate on the awin verify. * fix(wsl): close a pane's clean exit at once, and settle the guest-process fast path - A bridge pane's code-0 exit is the user's own (a terminate gives -1): it now closes at once, with no stop mark, settle wait or running-list read. Non-zero exits keep the E9 discipline. - readWslGuestPaneProcesses' fast path for a live pane (Sleep, quit) trusts the cached running list only when it was probed after any stop of the distro settled. - When a mid-stop list skipped deferred work, one unref'd probe just past the settle drains it, instead of waiting on an unrelated probe. * fix(wsl): mark a clean bridge exit as a possible stop, and fix the exit check test's probe timing A code-0 bridge exit still closes at once, but now also marks its distro as possibly stopped, so an in-guest poweroff or wsl --shutdown that exits 0 cannot leave background reads trusting an old running list. The coalesced-probe test waits for the probe, which now starts after the settle anchor, before answering it.
This commit is contained in:
@@ -92,10 +92,40 @@ describe('WslRelayRecovery', () => {
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(deferred).toHaveLength(1)
|
||||
// Something else (the user) started the distro: only now does the relay come back.
|
||||
isDistroRunning.mockResolvedValue(true)
|
||||
deferred[0]()
|
||||
await waitFor(() => restart.mock.calls.length === 1)
|
||||
expect(restart).toHaveBeenCalledExactlyOnceWith('Ubuntu', { launchKinds: [] })
|
||||
})
|
||||
|
||||
it('re-reads a settled list before a drained restart, and waits again if the distro stopped', async () => {
|
||||
const restart = vi.fn()
|
||||
const deferred: (() => void)[] = []
|
||||
const isDistroRunning = vi.fn(async (_distro: string, _startedAfter: number) => false)
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning,
|
||||
deferUntilRunning: (_distro, task) => deferred.push(task),
|
||||
captureSetup: () => ({ launchKinds: [] }),
|
||||
warn: vi.fn(),
|
||||
isDisposed: () => false,
|
||||
isCurrent: () => true,
|
||||
restart,
|
||||
dropState: vi.fn()
|
||||
})
|
||||
recovery.scheduleRestart(makeState())
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await waitFor(() => deferred.length === 1)
|
||||
|
||||
// Drained by a list that still named the stopping distro; a fresh settled read says stopped.
|
||||
deferred[0]()
|
||||
await waitFor(() => deferred.length === 2)
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
|
||||
isDistroRunning.mockResolvedValue(true)
|
||||
deferred[1]()
|
||||
await waitFor(() => restart.mock.calls.length === 1)
|
||||
})
|
||||
|
||||
it('restarts with the relay config captured before dropState discarded it', async () => {
|
||||
const restart = vi.fn()
|
||||
const deferred: (() => void)[] = []
|
||||
@@ -105,8 +135,9 @@ describe('WslRelayRecovery', () => {
|
||||
launchKinds: ['pi', 'omp']
|
||||
}
|
||||
const expected = live
|
||||
const isDistroRunning = vi.fn(async (_distro: string, _startedAfter: number) => false)
|
||||
const recovery = new WslRelayRecovery({
|
||||
isDistroRunning: async () => false,
|
||||
isDistroRunning,
|
||||
deferUntilRunning: (_distro, task) => deferred.push(task),
|
||||
captureSetup: () => live ?? { launchKinds: [] },
|
||||
warn: vi.fn(),
|
||||
@@ -120,7 +151,9 @@ describe('WslRelayRecovery', () => {
|
||||
recovery.scheduleRestart(makeState())
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await waitFor(() => deferred.length === 1)
|
||||
isDistroRunning.mockResolvedValue(true)
|
||||
deferred[0]()
|
||||
await waitFor(() => restart.mock.calls.length === 1)
|
||||
expect(restart).toHaveBeenCalledExactlyOnceWith('Ubuntu', expected)
|
||||
})
|
||||
|
||||
|
||||
@@ -81,17 +81,33 @@ export class WslRelayRecovery {
|
||||
this.io.dropState(state)
|
||||
// E9: come back when the distro runs again (started by the user or another act), never boot it.
|
||||
let restarted = false
|
||||
const restartOnce = (): void => {
|
||||
if (!restarted && !this.io.isDisposed()) {
|
||||
restarted = true
|
||||
this.io.restart(state.distro, setup)
|
||||
const restartOnce = (confirmedRunning = false): void => {
|
||||
if (restarted || this.io.isDisposed()) {
|
||||
return
|
||||
}
|
||||
restarted = true
|
||||
// A drained deferral is a hint: the relay spawn runs wsl.exe, which boots a distro that
|
||||
// stopped again, so re-read a settled running list right before it.
|
||||
const running = confirmedRunning
|
||||
? Promise.resolve(true)
|
||||
: this.io.isDistroRunning(state.distro, performance.now()).catch(() => false)
|
||||
void running.then((running) => {
|
||||
if (this.io.isDisposed()) {
|
||||
return
|
||||
}
|
||||
if (running) {
|
||||
this.io.restart(state.distro, setup)
|
||||
return
|
||||
}
|
||||
restarted = false
|
||||
this.io.deferUntilRunning(state.distro, () => restartOnce())
|
||||
})
|
||||
}
|
||||
this.io.deferUntilRunning(state.distro, restartOnce)
|
||||
this.io.deferUntilRunning(state.distro, () => restartOnce())
|
||||
const reprobe = setTimeout(() => {
|
||||
void this.io
|
||||
.isDistroRunning(state.distro, performance.now())
|
||||
.then((running) => running && restartOnce())
|
||||
.then((running) => running && restartOnce(true))
|
||||
.catch(() => {})
|
||||
}, REPROBE_AFTER_STOPPED_MS)
|
||||
reprobe.unref?.()
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('a WSL bridge pane exit', () => {
|
||||
// The distro's boot id each read returns; never a real wsl.exe.
|
||||
let bootIds: string[]
|
||||
const readBootId = vi.fn(async () => (bootIds.length > 1 ? bootIds.shift()! : bootIds[0]!))
|
||||
const noteMayHaveStopped = vi.fn()
|
||||
|
||||
beforeEach(async () => {
|
||||
historyDir = mkdtempSync(join(tmpdir(), 'orca-distro-loss-history-'))
|
||||
@@ -35,6 +36,7 @@ describe('a WSL bridge pane exit', () => {
|
||||
readRunning.mockClear()
|
||||
bootIds = ['boot-1']
|
||||
readBootId.mockClear()
|
||||
noteMayHaveStopped.mockClear()
|
||||
const harness = await startDaemonAdapterHarness(
|
||||
() => {
|
||||
const subprocess = createMockSubprocess()
|
||||
@@ -46,7 +48,8 @@ describe('a WSL bridge pane exit', () => {
|
||||
historyPath: historyDir,
|
||||
wslBridgeBootIdentity: new WslBridgeBootIdentity({
|
||||
isRunning: async () => true,
|
||||
readBootId
|
||||
readBootId,
|
||||
noteMayHaveStopped
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -97,6 +100,33 @@ describe('a WSL bridge pane exit', () => {
|
||||
expect(clear).toHaveBeenCalledWith(id, -1, expect.any(String), { closeHistory: false })
|
||||
})
|
||||
|
||||
it("closes a pane's own clean exit at once, with no settle wait or running-list read", async () => {
|
||||
// A list that never answers: classifying the exit would hang it.
|
||||
running = () => new Promise<WslRunningDistrosRead>(() => {})
|
||||
const exits = collectExits()
|
||||
const id = await spawnBridgePane()
|
||||
|
||||
subprocesses[0]!._simulateExit(0)
|
||||
|
||||
await waitFor(() => exits.length > 0)
|
||||
expect(exits).toEqual([expect.objectContaining({ id, code: 0 })])
|
||||
expect(exits[0]).not.toHaveProperty('hostLost')
|
||||
expect(readRunning).not.toHaveBeenCalled()
|
||||
// Still marked: background reads must settle in case the distro did stop (poweroff, --shutdown).
|
||||
expect(noteMayHaveStopped).toHaveBeenCalledWith('Ubuntu')
|
||||
})
|
||||
|
||||
it('marks a non-zero exit as a possible stop before classifying it', async () => {
|
||||
running = async () => ({ status: 'confirmed', distros: [] })
|
||||
const exits = collectExits()
|
||||
await spawnBridgePane()
|
||||
|
||||
subprocesses[0]!._simulateExit(1)
|
||||
|
||||
await waitFor(() => exits.length > 0)
|
||||
expect(noteMayHaveStopped).toHaveBeenCalledWith('Ubuntu')
|
||||
})
|
||||
|
||||
it('logs what the running list said, so a run can tell host loss from a raced reboot', async () => {
|
||||
const info = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
for (const [distros, verdict] of [
|
||||
|
||||
@@ -223,10 +223,13 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro
|
||||
}
|
||||
return
|
||||
}
|
||||
// Every bridge exit marks a possible stop (unseen to the user; covers poweroff or --shutdown).
|
||||
if (wslDistro) {
|
||||
this.wslBridgeBootIdentity.noteExit(wslDistro)
|
||||
}
|
||||
if (wslDistro && isProvenProcessExit(event.payload.code)) {
|
||||
// A terminate never exits 0 (awin: -1 per bridge pane; `exit`/`exit 0` gave 0): a clean exit is
|
||||
// the user's own, so it closes at once, without the settle wait or classification.
|
||||
if (wslDistro && event.payload.code !== 0 && isProvenProcessExit(event.payload.code)) {
|
||||
const exitAt = performance.now()
|
||||
void this.finishWslBridgeExit(event.sessionId, event.payload, wslDistro, exitAt)
|
||||
return
|
||||
@@ -250,15 +253,8 @@ export class DaemonPtyAdapter extends DaemonPtyDaemonRecovery implements IPtyPro
|
||||
wslDistro: string,
|
||||
exitAt: number
|
||||
): Promise<void> {
|
||||
// A terminate never exits 0 (awin: -1 for each bridge pane; `exit`/`exit 0` gave 0), so a clean
|
||||
// exit skips the boot-id read and stays an exit.
|
||||
const verdict = await classifyWslBridgeExit(
|
||||
wslDistro,
|
||||
exitAt,
|
||||
this.readRunningWslDistros,
|
||||
async () =>
|
||||
exit.code !== 0 &&
|
||||
(await this.wslBridgeBootIdentity.rebootedSinceSpawn(sessionId, wslDistro))
|
||||
const verdict = await classifyWslBridgeExit(wslDistro, exitAt, this.readRunningWslDistros, () =>
|
||||
this.wslBridgeBootIdentity.rebootedSinceSpawn(sessionId, wslDistro)
|
||||
)
|
||||
// Lost, or booted again since the spawn, keeps the tab; an unanswered check reads as an exit.
|
||||
// The code shows a verify run which codes a terminate yields.
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* calls 'running' can still be told apart as the distro having been terminated and booted again.
|
||||
* Reads go through the background-work gate (E9): only into a distro a fresh list says is running.
|
||||
*/
|
||||
import { isWslDistroRunningForBackgroundWork } from '../wsl-background-distro-gate'
|
||||
import {
|
||||
isWslDistroRunningForBackgroundWork,
|
||||
noteWslDistroMayHaveStopped
|
||||
} from '../wsl-background-distro-gate'
|
||||
import { onRunningWslDistrosStopped } from '../wsl-running-distro-cache'
|
||||
import { readWslDistroBootId } from '../wsl/wsl-distro-boot-id'
|
||||
|
||||
@@ -13,12 +16,15 @@ export type WslBridgeBootIdentityDeps = {
|
||||
readBootId: (distro: string) => Promise<string | null>
|
||||
/** Confirmed running lists that no longer name a distro; returns an unsubscribe. */
|
||||
onDistrosStopped?: (listener: (stopped: string[]) => void) => () => void
|
||||
/** A bridge exit may be its distro stopping: later background reads must start after it settles. */
|
||||
noteMayHaveStopped?: (distro: string) => void
|
||||
}
|
||||
|
||||
const PRODUCTION_DEPS: WslBridgeBootIdentityDeps = {
|
||||
export const PRODUCTION_WSL_BRIDGE_BOOT_IDENTITY_DEPS: WslBridgeBootIdentityDeps = {
|
||||
isRunning: isWslDistroRunningForBackgroundWork,
|
||||
readBootId: readWslDistroBootId,
|
||||
onDistrosStopped: onRunningWslDistrosStopped
|
||||
onDistrosStopped: onRunningWslDistrosStopped,
|
||||
noteMayHaveStopped: noteWslDistroMayHaveStopped
|
||||
}
|
||||
|
||||
function distroKey(distro: string): string {
|
||||
@@ -38,7 +44,9 @@ export class WslBridgeBootIdentity {
|
||||
// Held only while a bridge pane lives, so an idle adapter keeps no listener.
|
||||
private unsubscribeStops: (() => void) | null = null
|
||||
|
||||
constructor(private readonly deps: WslBridgeBootIdentityDeps = PRODUCTION_DEPS) {}
|
||||
constructor(
|
||||
private readonly deps: WslBridgeBootIdentityDeps = PRODUCTION_WSL_BRIDGE_BOOT_IDENTITY_DEPS
|
||||
) {}
|
||||
|
||||
/** A spawn or reattach of `sessionId`; a new incarnation takes the distro's current boot id. */
|
||||
noteSpawn(sessionId: string, distro: string, incarnationId?: string): void {
|
||||
@@ -53,16 +61,22 @@ export class WslBridgeBootIdentity {
|
||||
}
|
||||
// A stop seen before the exit reaches us: a pane spawned meanwhile must not take the old id.
|
||||
this.unsubscribeStops ??=
|
||||
this.deps.onDistrosStopped?.((stopped) => stopped.forEach((name) => this.noteExit(name))) ??
|
||||
this.deps.onDistrosStopped?.((stopped) => stopped.forEach((name) => this.dropBootId(name))) ??
|
||||
null
|
||||
this.bySession.set(sessionId, { distro: key, incarnationId, bootId: this.distroBootId(distro) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous, as the exit arrives: it may be the first of a reboot's, so a spawn during its
|
||||
* verdict must not inherit the old boot's id.
|
||||
* verdict must not inherit the old boot's id. It may also be the distro stopping (E9): until that
|
||||
* settles, a stopping distro WSL still lists must not pass the gate into a boot-id exec.
|
||||
*/
|
||||
noteExit(distro: string): void {
|
||||
this.deps.noteMayHaveStopped?.(distro)
|
||||
this.dropBootId(distro)
|
||||
}
|
||||
|
||||
private dropBootId(distro: string): void {
|
||||
this.byDistro.delete(distroKey(distro))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// E9 regression: a WSL bridge pane's exit after `wsl --terminate` must not lead Orca to run a command
|
||||
// in the distro (any `wsl.exe -d <distro> --exec` boots it again) while WSL still lists it as stopping.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const wslMocks = vi.hoisted(() => ({
|
||||
listRunningWslDistrosAsync:
|
||||
vi.fn<(options?: { requireConfirmed?: boolean; startedAfter?: number }) => Promise<string[]>>(),
|
||||
listWslDistrosAsync: vi.fn<() => Promise<string[]>>()
|
||||
}))
|
||||
vi.mock('../wsl', () => wslMocks)
|
||||
|
||||
import {
|
||||
_resetWslBackgroundDistroGateForTests,
|
||||
deferUntilWslDistroRunning,
|
||||
noteWslDistroMayHaveStopped,
|
||||
readConfirmedRunningWslDistros,
|
||||
wasWslDistroLastSeenRunningSettled
|
||||
} from '../wsl-background-distro-gate'
|
||||
import {
|
||||
_resetRunningWslDistroCacheForTests,
|
||||
resolveRunningWslDistros
|
||||
} from '../wsl-running-distro-cache'
|
||||
import {
|
||||
PRODUCTION_WSL_BRIDGE_BOOT_IDENTITY_DEPS,
|
||||
WslBridgeBootIdentity
|
||||
} from './daemon-wsl-bridge-boot-identity'
|
||||
import { classifyWslBridgeExit } from './daemon-wsl-bridge-exit'
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
// WSL keeps listing a terminated distro for a moment while it stops.
|
||||
const STILL_LISTED_MS = 300
|
||||
let terminatedAt = Number.POSITIVE_INFINITY
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] })
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
terminatedAt = Number.POSITIVE_INFINITY
|
||||
wslMocks.listWslDistrosAsync.mockResolvedValue(['Ubuntu'])
|
||||
wslMocks.listRunningWslDistrosAsync.mockImplementation((options) =>
|
||||
resolveRunningWslDistros(async () => {
|
||||
const startedAt = performance.now()
|
||||
return startedAt < terminatedAt + STILL_LISTED_MS ? ['Ubuntu'] : []
|
||||
}, options)
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
_resetWslBackgroundDistroGateForTests()
|
||||
_resetRunningWslDistroCacheForTests()
|
||||
vi.useRealTimers()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('a WSL bridge pane exit after the distro is terminated', () => {
|
||||
it('never execs into the distro while it is still being listed as it stops', async () => {
|
||||
const readBootId = vi.fn(async () => 'boot-1')
|
||||
// Production gate and stop bookkeeping; only the wsl.exe boot-id read is faked.
|
||||
const boot = new WslBridgeBootIdentity({
|
||||
...PRODUCTION_WSL_BRIDGE_BOOT_IDENTITY_DEPS,
|
||||
readBootId
|
||||
})
|
||||
boot.noteSpawn('pane-a', 'Ubuntu')
|
||||
boot.noteSpawn('pane-b', 'Ubuntu')
|
||||
await vi.waitFor(() => expect(readBootId).toHaveBeenCalledOnce())
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
terminatedAt = performance.now()
|
||||
// Both panes' wsl.exe exit together, as on the awin verify.
|
||||
boot.noteExit('Ubuntu')
|
||||
boot.noteExit('Ubuntu')
|
||||
const exitAt = performance.now()
|
||||
const verdicts = Promise.all(
|
||||
['pane-a', 'pane-b'].map((pane) =>
|
||||
classifyWslBridgeExit('Ubuntu', exitAt, undefined, () =>
|
||||
boot.rebootedSinceSpawn(pane, 'Ubuntu')
|
||||
)
|
||||
)
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
|
||||
const settled = await verdicts
|
||||
// Only the read at spawn, while the distro ran; none after the terminate.
|
||||
expect(readBootId).toHaveBeenCalledOnce()
|
||||
expect(settled).toEqual(['lost', 'lost'])
|
||||
})
|
||||
|
||||
// The pre-boot-id path (build f59bd43): a dying file watcher or hook relay defers its restart
|
||||
// until the distro runs, and any confirmed list used to drain it, one probed mid-stop included.
|
||||
it('runs no deferred work off a list probed while the distro was still stopping', async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
terminatedAt = performance.now()
|
||||
noteWslDistroMayHaveStopped('Ubuntu')
|
||||
const restarts = vi.fn()
|
||||
deferUntilWslDistroRunning('Ubuntu', 'fs-watch:root-a', restarts)
|
||||
deferUntilWslDistroRunning('Ubuntu', 'fs-watch:root-b', restarts)
|
||||
|
||||
// Some other read, e.g. a bridge exit's verdict, probes while WSL still lists the distro.
|
||||
await expect(readConfirmedRunningWslDistros()).resolves.toEqual({
|
||||
status: 'confirmed',
|
||||
distros: ['Ubuntu']
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
await readConfirmedRunningWslDistros()
|
||||
|
||||
expect(restarts).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still runs deferred work once a settled list shows the distro running again', async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
noteWslDistroMayHaveStopped('Ubuntu')
|
||||
const restarts = vi.fn()
|
||||
deferUntilWslDistroRunning('Ubuntu', 'fs-watch:root-a', restarts)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
await readConfirmedRunningWslDistros()
|
||||
|
||||
expect(restarts).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Review of #35143: Sleep and quit reads skip the probe for a pane they hold live.
|
||||
it('does not let a list taken before the stop vouch for a live pane', async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await readConfirmedRunningWslDistros()
|
||||
expect(wasWslDistroLastSeenRunningSettled('Ubuntu')).toBe(true)
|
||||
|
||||
terminatedAt = performance.now()
|
||||
noteWslDistroMayHaveStopped('Ubuntu')
|
||||
expect(wasWslDistroLastSeenRunningSettled('Ubuntu')).toBe(false)
|
||||
|
||||
// A probe while it still stops names it, yet is inside the settle window.
|
||||
await readConfirmedRunningWslDistros()
|
||||
expect(wasWslDistroLastSeenRunningSettled('Ubuntu')).toBe(false)
|
||||
})
|
||||
|
||||
it('lists once more past the settle when a mid-stop list skipped deferred work', async () => {
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
noteWslDistroMayHaveStopped('Ubuntu')
|
||||
const restarts = vi.fn()
|
||||
deferUntilWslDistroRunning('Ubuntu', 'fs-watch:root-a', restarts)
|
||||
// A false stop signal: the distro never went away, so a settled list names it.
|
||||
await readConfirmedRunningWslDistros()
|
||||
expect(restarts).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(restarts).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,8 @@ describe('checkWslBridgeDistro', () => {
|
||||
)
|
||||
const exitAt = performance.now()
|
||||
const checks = [1, 2, 3].map(() => checkWslBridgeDistro('Ubuntu', exitAt, readRunning(probe)))
|
||||
// The checks first wait out the stop's settle anchor, so the probe starts a tick later.
|
||||
await vi.waitFor(() => expect(probe).toHaveBeenCalled())
|
||||
answer(['Debian'])
|
||||
|
||||
await expect(Promise.all(checks)).resolves.toEqual(['lost', 'lost', 'lost'])
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import {
|
||||
readConfirmedRunningWslDistros,
|
||||
settledWslRunningReadAnchor,
|
||||
type WslRunningDistrosRead
|
||||
} from '../wsl-background-distro-gate'
|
||||
|
||||
@@ -39,14 +40,16 @@ export async function checkWslBridgeDistro(
|
||||
readRunning: ReadRunningWslDistros = readConfirmedRunningWslDistros,
|
||||
{ firstMs = WSL_EXIT_CHECK_FIRST_MS, totalMs = WSL_EXIT_CHECK_TOTAL_MS } = {}
|
||||
): Promise<WslBridgeExitVerdict> {
|
||||
// WSL keeps listing a stopping distro for a moment; a read inside that window would say 'running'.
|
||||
const startedAfter = await settledWslRunningReadAnchor(distro, exitAt)
|
||||
const startedAt = performance.now()
|
||||
let running = await readWithin(() => readRunning({ startedAfter: exitAt }), firstMs)
|
||||
let running = await readWithin(() => readRunning({ startedAfter }), firstMs)
|
||||
if (running.status !== 'confirmed') {
|
||||
const remainingMs = totalMs - (performance.now() - startedAt)
|
||||
running =
|
||||
remainingMs > 0
|
||||
? await readWithin(
|
||||
() => readRunning({ startedAfter: exitAt, bypassFailureBackoff: true }),
|
||||
() => readRunning({ startedAfter, bypassFailureBackoff: true }),
|
||||
remainingMs
|
||||
)
|
||||
: running
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isFolderRepo } from '../../../shared/repo-kind'
|
||||
import { inspectSetupScriptImportCandidates } from '../../../shared/setup-script-imports'
|
||||
import { joinWorktreeRelativePath } from '../../runtime/runtime-relative-paths'
|
||||
import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path'
|
||||
import { blockedWslDistroForPath } from '../../wsl/wsl-unc-access-gate'
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { isENOENT } from '../filesystem/filesystem-path-containment'
|
||||
import { resolveRepoForExecutionHost } from '../worktrees/repo-host-ownership'
|
||||
@@ -20,6 +21,10 @@ export function registerWorktreeHookInspectionHandler(context: WorktreeIpcContex
|
||||
if (!repo || isFolderRepo(repo) || getRepoDialableSshTargetId(repo)) {
|
||||
return []
|
||||
}
|
||||
// A background sidebar inspection: opening `\\wsl.localhost` paths would boot a stopped distro (E9).
|
||||
if (await blockedWslDistroForPath(repo.path)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return inspectSetupScriptImportCandidates(
|
||||
async (relativePath) => {
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { Repo } from '../../../shared/repo-types'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: unknown, args: unknown) => unknown>(),
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn()
|
||||
stat: vi.fn(),
|
||||
blocked: vi.fn<(path: string) => Promise<{ distro: string; state: 'stopped' } | null>>()
|
||||
}))
|
||||
vi.mock('../../wsl/wsl-unc-access-gate', () => ({ blockedWslDistroForPath: mocks.blocked }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
@@ -34,6 +36,7 @@ function enoent(path: string): Error {
|
||||
describe('setup script import inspection of a UNC drive-mount project', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
mocks.blocked.mockResolvedValue(null)
|
||||
// WSL 2.7 refuses Windows access to a drive mount over UNC; the drive path opens.
|
||||
mocks.readFile.mockImplementation(async (path: string) => {
|
||||
if (path.startsWith('\\\\')) {
|
||||
@@ -70,4 +73,23 @@ describe('setup script import inspection of a UNC drive-mount project', () => {
|
||||
expect(mocks.readFile).not.toHaveBeenCalledWith(expect.stringMatching(/^\\\\/), 'utf-8')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens nothing in a stopped distro, so a background inspection cannot boot it (E9)', async () => {
|
||||
mocks.readFile.mockClear()
|
||||
mocks.stat.mockClear()
|
||||
mocks.blocked.mockResolvedValue({ distro: 'Ubuntu', state: 'stopped' })
|
||||
const repo: Partial<Repo> = { id: 'repo', path: UNC, kind: 'git', executionHostId: 'local' }
|
||||
const store = { getRepos: () => [repo], getRepo: () => repo }
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The handler reads only these store accessors.
|
||||
registerWorktreeHookInspectionHandler({ store } as never)
|
||||
|
||||
const candidates = await mocks.handlers.get('hooks:inspectSetupScriptImports')!(null, {
|
||||
repoId: 'repo'
|
||||
})
|
||||
|
||||
expect(candidates).toEqual([])
|
||||
expect(mocks.blocked).toHaveBeenCalledWith(UNC)
|
||||
expect(mocks.readFile).not.toHaveBeenCalled()
|
||||
expect(mocks.stat).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isENOENT } from '../ipc/filesystem/filesystem-path-containment'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import { blockedWslDistroForPath } from '../wsl/wsl-unc-access-gate'
|
||||
|
||||
type RuntimeRepositoryHooksCommandsDeps = {
|
||||
resolveRepo: (selector: string) => Promise<Repo>
|
||||
@@ -70,7 +71,8 @@ export class RuntimeRepositoryHooksCommands {
|
||||
|
||||
async inspectRepoSetupScriptImports(repoSelector: string) {
|
||||
const repo = await this.deps.resolveRepo(repoSelector)
|
||||
if (isFolderRepo(repo)) {
|
||||
// A stopped distro's `\\wsl.localhost` paths would boot it for a background inspection (E9).
|
||||
if (isFolderRepo(repo) || (await blockedWslDistroForPath(repo.path))) {
|
||||
return []
|
||||
}
|
||||
return inspectSetupScriptImportCandidates(async (relativePath) => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { outsideWslUserAct } from './wsl/wsl-user-act-scope'
|
||||
import {
|
||||
onConfirmedRunningWslDistros,
|
||||
onRunningWslDistrosStopped,
|
||||
peekConfirmedRunningWslDistros
|
||||
peekConfirmedRunningWslDistros,
|
||||
peekConfirmedRunningWslDistrosObservation
|
||||
} from './wsl-running-distro-cache'
|
||||
|
||||
// Background work must not boot stopped distros: old WSL idle-stop can erase VM-wide WSLInterop (microsoft/WSL#13885).
|
||||
@@ -66,8 +67,21 @@ export function noteWslDistroLaunched(distro: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// The earliest start a running read may have: after `startedAfter` and any stop's settle window.
|
||||
async function settledStartedAfter(
|
||||
// The earliest start a read may have to count as running: after any stop's settle, or a wake.
|
||||
function settledRunningFloor(distro: string): number {
|
||||
const stoppedAt = mayHaveStoppedAt.get(distroKey(distro))
|
||||
const woken = wokenAt.get(distroKey(distro))
|
||||
return Math.max(
|
||||
stoppedAt === undefined ? -Infinity : stoppedAt + WSL_STOP_SETTLE_MS,
|
||||
woken ?? -Infinity
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The earliest start a running read may have: after `startedAfter` and any stop's settle window.
|
||||
* Waits until that moment has passed, so a probe started on return qualifies.
|
||||
*/
|
||||
export async function settledWslRunningReadAnchor(
|
||||
distro: string,
|
||||
startedAfter: number | undefined
|
||||
): Promise<number | undefined> {
|
||||
@@ -76,12 +90,7 @@ async function settledStartedAfter(
|
||||
noteWslDistroMayHaveStopped(candidate)
|
||||
}
|
||||
})
|
||||
const stoppedAt = mayHaveStoppedAt.get(distroKey(distro))
|
||||
const woken = wokenAt.get(distroKey(distro))
|
||||
const floor = Math.max(
|
||||
stoppedAt === undefined ? -Infinity : stoppedAt + WSL_STOP_SETTLE_MS,
|
||||
woken ?? -Infinity
|
||||
)
|
||||
const floor = settledRunningFloor(distro)
|
||||
const anchor = floor === -Infinity ? startedAfter : Math.max(startedAfter ?? -Infinity, floor)
|
||||
// Timers truncate to whole ms on a ms clock and can wake ~2ms short; sleep until the anchor has passed.
|
||||
const settled = anchor ?? -Infinity
|
||||
@@ -91,13 +100,23 @@ async function settledStartedAfter(
|
||||
return anchor
|
||||
}
|
||||
|
||||
/** Without probing: the last confirmed list names `distro` and was probed after any stop settled. */
|
||||
export function wasWslDistroLastSeenRunningSettled(distro: string): boolean {
|
||||
const observation = peekConfirmedRunningWslDistrosObservation()
|
||||
return (
|
||||
observation !== null &&
|
||||
observation.probeStartedAt >= settledRunningFloor(distro) &&
|
||||
observation.distros.some((candidate) => distroKey(candidate) === distroKey(distro))
|
||||
)
|
||||
}
|
||||
|
||||
/** A confirmed list from a probe started after `startedAfter` and after `distro`'s stop settled. */
|
||||
export async function readSettledConfirmedRunningWslDistros(
|
||||
distro: string,
|
||||
startedAfter?: number
|
||||
): Promise<WslRunningDistrosRead> {
|
||||
return readConfirmedRunningWslDistros({
|
||||
startedAfter: await settledStartedAfter(distro, startedAfter)
|
||||
startedAfter: await settledWslRunningReadAnchor(distro, startedAfter)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,7 +127,7 @@ export async function readWslDistroRunningState(
|
||||
distro: string,
|
||||
options: { startedAfter?: number; retryUnverifiable?: boolean } = {}
|
||||
): Promise<WslDistroRunningState> {
|
||||
const startedAfter = await settledStartedAfter(distro, options.startedAfter)
|
||||
const startedAfter = await settledWslRunningReadAnchor(distro, options.startedAfter)
|
||||
let read = await readConfirmedRunningWslDistros({ startedAfter })
|
||||
if (read.status === 'unavailable' && options.retryUnverifiable) {
|
||||
read = await readConfirmedRunningWslDistros({ startedAfter, bypassFailureBackoff: true })
|
||||
@@ -150,9 +169,16 @@ export function deferUntilWslDistroRunning(
|
||||
return () => {}
|
||||
}
|
||||
// A probe started inside a user act must not hand its boot allowance to the drain it causes.
|
||||
unsubscribeObservations ??= onConfirmedRunningWslDistros((distros) =>
|
||||
outsideWslUserAct(() => noteWslDistrosRunning(distros))
|
||||
)
|
||||
// A list probed while a distro was still stopping names it too: only a settled one may drain it.
|
||||
unsubscribeObservations ??= onConfirmedRunningWslDistros((distros, probeStartedAt) => {
|
||||
const settled = distros.filter((distro) => probeStartedAt >= settledRunningFloor(distro))
|
||||
for (const distro of distros) {
|
||||
if (!settled.includes(distro)) {
|
||||
probeAfterStopSettles(distro)
|
||||
}
|
||||
}
|
||||
outsideWslUserAct(() => noteWslDistrosRunning(settled))
|
||||
})
|
||||
let tasks = deferredTasksByDistro.get(keyForDistro)
|
||||
if (!tasks) {
|
||||
tasks = new Map()
|
||||
@@ -167,6 +193,26 @@ export function deferUntilWslDistroRunning(
|
||||
}
|
||||
}
|
||||
|
||||
const settleProbes = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/** Deferred work a mid-stop list skipped should not wait on an unrelated probe: list once more past the settle. */
|
||||
function probeAfterStopSettles(distro: string): void {
|
||||
const key = distroKey(distro)
|
||||
if (!deferredTasksByDistro.get(key)?.size || settleProbes.has(key)) {
|
||||
return
|
||||
}
|
||||
const floor = settledRunningFloor(distro)
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
settleProbes.delete(key)
|
||||
void readConfirmedRunningWslDistros({ startedAfter: floor })
|
||||
},
|
||||
Math.max(Math.ceil(floor - performance.now()), 0) + 1
|
||||
)
|
||||
timer.unref?.()
|
||||
settleProbes.set(key, timer)
|
||||
}
|
||||
|
||||
/** Runs work deferred for these distros; callers must know they are running now. */
|
||||
export function noteWslDistrosRunning(distros: readonly string[]): void {
|
||||
for (const distro of distros) {
|
||||
@@ -196,6 +242,10 @@ function logDeferredTaskFailure(error: unknown): void {
|
||||
|
||||
export function _resetWslBackgroundDistroGateForTests(): void {
|
||||
deferredTasksByDistro.clear()
|
||||
for (const timer of settleProbes.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
settleProbes.clear()
|
||||
unsubscribeObservations?.()
|
||||
unsubscribeObservations = null
|
||||
mayHaveStoppedAt.clear()
|
||||
|
||||
@@ -40,22 +40,22 @@ function notifyStopped(previous: string[] | null, running: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
const confirmedObservationListeners = new Set<(distros: readonly string[]) => void>()
|
||||
type ConfirmedObservationListener = (distros: readonly string[], probeStartedAt: number) => void
|
||||
|
||||
/** Notified with every confirmed (successful) probe result; never with a fallback list. */
|
||||
export function onConfirmedRunningWslDistros(
|
||||
listener: (distros: readonly string[]) => void
|
||||
): () => void {
|
||||
const confirmedObservationListeners = new Set<ConfirmedObservationListener>()
|
||||
|
||||
/** Notified with every confirmed (successful) probe result and when its probe started; never a fallback list. */
|
||||
export function onConfirmedRunningWslDistros(listener: ConfirmedObservationListener): () => void {
|
||||
confirmedObservationListeners.add(listener)
|
||||
return () => {
|
||||
confirmedObservationListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function notifyConfirmedObservation(distros: readonly string[]): void {
|
||||
function notifyConfirmedObservation(distros: readonly string[], probeStartedAt: number): void {
|
||||
for (const listener of confirmedObservationListeners) {
|
||||
try {
|
||||
listener(distros)
|
||||
listener(distros, probeStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('[wsl] running-distro listener failed', error)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ function observeRunningWslDistros(
|
||||
failureStreak = 0
|
||||
backoffBypassed = false
|
||||
notifyStopped(previous, distros)
|
||||
notifyConfirmedObservation(distros)
|
||||
notifyConfirmedObservation(distros, startedAt)
|
||||
return { distros, confirmed: true }
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -155,6 +155,14 @@ export function peekConfirmedRunningWslDistros(): readonly string[] | null {
|
||||
return cache
|
||||
}
|
||||
|
||||
/** The last confirmed list with its probe's start, for callers that must judge its age. */
|
||||
export function peekConfirmedRunningWslDistrosObservation(): {
|
||||
distros: readonly string[]
|
||||
probeStartedAt: number
|
||||
} | null {
|
||||
return cache ? { distros: cache, probeStartedAt: cacheStartedAt } : null
|
||||
}
|
||||
|
||||
export function _resetRunningWslDistroCacheForTests(): void {
|
||||
cache = null
|
||||
cacheStartedAt = -Infinity
|
||||
|
||||
@@ -11,9 +11,11 @@ const runWslProcess = vi.hoisted(() =>
|
||||
timedOut: false
|
||||
}))
|
||||
)
|
||||
const peekConfirmedRunningWslDistros = vi.hoisted(() => vi.fn((): string[] | null => null))
|
||||
vi.mock('../wsl-background-distro-gate', () => ({ isWslDistroRunning }))
|
||||
vi.mock('../wsl-running-distro-cache', () => ({ peekConfirmedRunningWslDistros }))
|
||||
const wasWslDistroLastSeenRunningSettled = vi.hoisted(() => vi.fn((_distro: string) => false))
|
||||
vi.mock('../wsl-background-distro-gate', () => ({
|
||||
isWslDistroRunning,
|
||||
wasWslDistroLastSeenRunningSettled
|
||||
}))
|
||||
vi.mock('./wsl-runner', () => ({ runWslProcess }))
|
||||
|
||||
const {
|
||||
@@ -33,8 +35,8 @@ describe('the guest process table', () => {
|
||||
beforeEach(() => {
|
||||
isWslDistroRunning.mockReset()
|
||||
isWslDistroRunning.mockResolvedValue(true)
|
||||
peekConfirmedRunningWslDistros.mockReset()
|
||||
peekConfirmedRunningWslDistros.mockReturnValue(null)
|
||||
wasWslDistroLastSeenRunningSettled.mockReset()
|
||||
wasWslDistroLastSeenRunningSettled.mockReturnValue(false)
|
||||
runWslProcess.mockReset()
|
||||
})
|
||||
|
||||
@@ -77,8 +79,8 @@ describe('the guest process table', () => {
|
||||
expect(runWslProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the running check for a live pane whose distro was last seen running', async () => {
|
||||
peekConfirmedRunningWslDistros.mockReturnValue(['ubuntu'])
|
||||
it('skips the running check for a live pane whose distro was last seen running, settled', async () => {
|
||||
wasWslDistroLastSeenRunningSettled.mockReturnValue(true)
|
||||
runWslProcess.mockResolvedValue({
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
@@ -87,12 +89,13 @@ describe('the guest process table', () => {
|
||||
timedOut: false
|
||||
})
|
||||
expect(await readWslGuestPaneProcesses('Ubuntu', 3_000, { running: true })).toEqual([])
|
||||
expect(wasWslDistroLastSeenRunningSettled).toHaveBeenCalledWith('Ubuntu')
|
||||
expect(isWslDistroRunning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Review of #33398: a session the client still holds as live may be in a distro terminated since.
|
||||
it('still checks a live pane whose distro is not in the last confirmed running list', async () => {
|
||||
peekConfirmedRunningWslDistros.mockReturnValue(['Debian'])
|
||||
it('still checks a live pane whose distro was not last seen running after any stop settled', async () => {
|
||||
wasWslDistroLastSeenRunningSettled.mockReturnValue(false)
|
||||
isWslDistroRunning.mockResolvedValue(false)
|
||||
expect(await readWslGuestPaneProcesses('Ubuntu', 3_000, { running: true })).toBeNull()
|
||||
expect(isWslDistroRunning).toHaveBeenCalledWith('Ubuntu')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/** Windows sees only wsl.exe and bridge bash lacks OSC 7; inherited ORCA_PANE_KEY identifies shell folders inside the guest. */
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
import { isWslDistroRunning } from '../wsl-background-distro-gate'
|
||||
import { peekConfirmedRunningWslDistros } from '../wsl-running-distro-cache'
|
||||
import {
|
||||
isWslDistroRunning,
|
||||
wasWslDistroLastSeenRunningSettled
|
||||
} from '../wsl-background-distro-gate'
|
||||
import { runWslProcess } from './wsl-runner'
|
||||
|
||||
export type WslGuestProcessRow = {
|
||||
@@ -76,10 +78,9 @@ export function readWslGuestPaneProcesses(
|
||||
return inFlight
|
||||
}
|
||||
const read = (async () => {
|
||||
// A client-held live session may belong to a terminated distro; require confirmation before skipping the boot guard.
|
||||
const knownRunning =
|
||||
opts.running === true &&
|
||||
(peekConfirmedRunningWslDistros() ?? []).some((running) => running.toLowerCase() === key)
|
||||
// A client-held live session may belong to a terminated distro; skip the probe only on a list
|
||||
// taken after any stop of the distro settled (a list from before a terminate still names it).
|
||||
const knownRunning = opts.running === true && wasWslDistroLastSeenRunningSettled(distro)
|
||||
if (!knownRunning && !(await isWslDistroRunning(distro))) {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user