diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts index 9a041382562..125f523c508 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.test.ts @@ -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) }) diff --git a/src/main/agent-hooks/wsl-hook-relay-recovery.ts b/src/main/agent-hooks/wsl-hook-relay-recovery.ts index ec8d9a36e90..c535b5aeda6 100644 --- a/src/main/agent-hooks/wsl-hook-relay-recovery.ts +++ b/src/main/agent-hooks/wsl-hook-relay-recovery.ts @@ -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?.() diff --git a/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts b/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts index 1f452f03177..97bd84d5115 100644 --- a/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts +++ b/src/main/daemon/daemon-pty-adapter-wsl-distro-loss.test.ts @@ -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(() => {}) + 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 [ diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 273b10d1867..19b614e34b7 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -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 { - // 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. diff --git a/src/main/daemon/daemon-wsl-bridge-boot-identity.ts b/src/main/daemon/daemon-wsl-bridge-boot-identity.ts index d61cfa328ba..dcbcc46412a 100644 --- a/src/main/daemon/daemon-wsl-bridge-boot-identity.ts +++ b/src/main/daemon/daemon-wsl-bridge-boot-identity.ts @@ -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 /** 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)) } diff --git a/src/main/daemon/daemon-wsl-bridge-exit-no-boot.test.ts b/src/main/daemon/daemon-wsl-bridge-exit-no-boot.test.ts new file mode 100644 index 00000000000..244677a0b5c --- /dev/null +++ b/src/main/daemon/daemon-wsl-bridge-exit-no-boot.test.ts @@ -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 --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>(), + listWslDistrosAsync: vi.fn<() => Promise>() +})) +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() + }) +}) diff --git a/src/main/daemon/daemon-wsl-bridge-exit.test.ts b/src/main/daemon/daemon-wsl-bridge-exit.test.ts index b690b1e2557..d0964d2fd76 100644 --- a/src/main/daemon/daemon-wsl-bridge-exit.test.ts +++ b/src/main/daemon/daemon-wsl-bridge-exit.test.ts @@ -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']) diff --git a/src/main/daemon/daemon-wsl-bridge-exit.ts b/src/main/daemon/daemon-wsl-bridge-exit.ts index e8aa57e8f23..8627347baa3 100644 --- a/src/main/daemon/daemon-wsl-bridge-exit.ts +++ b/src/main/daemon/daemon-wsl-bridge-exit.ts @@ -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 { + // 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 diff --git a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts index 12ebcd751e7..83ba23498f3 100644 --- a/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts +++ b/src/main/ipc/hooks/register-worktree-hook-inspection-handler.ts @@ -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) => { diff --git a/src/main/ipc/hooks/worktree-hook-inspection-unc-drive.test.ts b/src/main/ipc/hooks/worktree-hook-inspection-unc-drive.test.ts index 54715d93fe6..1b24db302bb 100644 --- a/src/main/ipc/hooks/worktree-hook-inspection-unc-drive.test.ts +++ b/src/main/ipc/hooks/worktree-hook-inspection-unc-drive.test.ts @@ -4,8 +4,10 @@ import type { Repo } from '../../../shared/repo-types' const mocks = vi.hoisted(() => ({ handlers: new Map 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 = { 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() + }) }) diff --git a/src/main/runtime/runtime-repository-hooks-commands.ts b/src/main/runtime/runtime-repository-hooks-commands.ts index 02588f72aae..01a12ce5f7d 100644 --- a/src/main/runtime/runtime-repository-hooks-commands.ts +++ b/src/main/runtime/runtime-repository-hooks-commands.ts @@ -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 @@ -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) => { diff --git a/src/main/wsl-background-distro-gate.ts b/src/main/wsl-background-distro-gate.ts index fbea1a44d82..45b51168449 100644 --- a/src/main/wsl-background-distro-gate.ts +++ b/src/main/wsl-background-distro-gate.ts @@ -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 { @@ -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 { 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 { - 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>() + +/** 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() diff --git a/src/main/wsl-running-distro-cache.ts b/src/main/wsl-running-distro-cache.ts index e2c0a1d064b..2dd2ba6a4df 100644 --- a/src/main/wsl-running-distro-cache.ts +++ b/src/main/wsl-running-distro-cache.ts @@ -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() + +/** 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 diff --git a/src/main/wsl/wsl-guest-pane-processes.test.ts b/src/main/wsl/wsl-guest-pane-processes.test.ts index e4c6791d677..c38553ff9ef 100644 --- a/src/main/wsl/wsl-guest-pane-processes.test.ts +++ b/src/main/wsl/wsl-guest-pane-processes.test.ts @@ -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') diff --git a/src/main/wsl/wsl-guest-pane-processes.ts b/src/main/wsl/wsl-guest-pane-processes.ts index d9e2dfe5482..d3d369253da 100644 --- a/src/main/wsl/wsl-guest-pane-processes.ts +++ b/src/main/wsl/wsl-guest-pane-processes.ts @@ -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 }