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:
Neil
2026-10-28 07:56:04 -07:00
committed by GitHub
parent 24b738aba2
commit 4ee621e89f
15 changed files with 401 additions and 65 deletions
@@ -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 [
+6 -10
View File
@@ -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'])
+5 -2
View File
@@ -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) => {
+64 -14
View File
@@ -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()
+16 -8
View File
@@ -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
+12 -9
View File
@@ -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')
+7 -6
View File
@@ -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
}