From bccdb0254ff3ca8e552ab11f6587053239edcd73 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:32:56 -0700 Subject: [PATCH] fix(wsl): never boot a stopped distro to check a folder workspace's path (#35899) A background read of a \\wsl.localhost folder workspace's status (sidebar polling, runtime status RPC, a passive terminal restore) stat'ed the folder and booted its stopped distro. The status now reports 'unavailable' without touching it; a passive restore holds with Start; a spawn the user asked for checks the folder as their act, which may start the distro as before. --- .../spawn-cwd-wsl-stopped-folder.test.ts | 112 ++++++++++++++++++ src/main/ipc/pty/host-env/spawn-cwd.ts | 49 ++++++-- src/main/ipc/pty/ipc/spawn-preflight.ts | 6 +- src/main/ipc/pty/ipc/spawn-types.ts | 3 +- src/main/ipc/pty/register-handlers.ts | 6 +- src/main/ipc/pty/runtime/controller-deps.ts | 3 +- .../folder-workspace-path-status.ts | 6 + 7 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 src/main/ipc/pty/host-env/spawn-cwd-wsl-stopped-folder.test.ts diff --git a/src/main/ipc/pty/host-env/spawn-cwd-wsl-stopped-folder.test.ts b/src/main/ipc/pty/host-env/spawn-cwd-wsl-stopped-folder.test.ts new file mode 100644 index 00000000000..67de9d66fbc --- /dev/null +++ b/src/main/ipc/pty/host-env/spawn-cwd-wsl-stopped-folder.test.ts @@ -0,0 +1,112 @@ +import type * as FsPromises from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as BackgroundDistroGateModule from '../../../wsl-background-distro-gate' +import type * as WslMoveScopeGuardModule from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' +import type { Store } from '../../../persistence' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { wslDistroHoldMessage } from '../../../../shared/wsl-distro-stopped-hold' +import { isWslUserActFor } from '../../../wsl/wsl-user-act-scope' + +const mocks = vi.hoisted(() => ({ + stat: vi.fn<(path: string) => Promise<{ isDirectory(): boolean }>>(), + readWslDistroRunningState: vi.fn<() => Promise<'running' | 'stopped' | 'unverifiable'>>() +})) +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: mocks.stat +})) +vi.mock('../../../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal()), + readWslDistroRunningState: mocks.readWslDistroRunningState +})) +vi.mock( + '../../../persistence/moving-wsl-projects/wsl-move-scope-guard', + async (importOriginal) => ({ + ...(await importOriginal()), + isWslMoveCwdFenced: () => false, + isWslMoveWorkspaceBlocked: () => false + }) +) + +import { assertWorkspaceAcceptsNewPty } from './spawn-cwd' +import { getFolderWorkspacePathStatus } from '../../../project-groups/folder-workspace-path-status' + +const FOLDER = '\\\\wsl.localhost\\Ubuntu\\home\\me\\notes' +const WORKTREE_ID = folderWorkspaceKey('folder-1') +const originalPlatform = process.platform + +function makeStore(): Store { + const workspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'notes', + folderPath: FOLDER, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + const store = { + getRepos: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [workspace] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the folder check reads only these three store methods. + return store as unknown as Store +} + +beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + mocks.stat.mockReset() + mocks.stat.mockImplementation(async () => { + expect(isWslUserActFor('Ubuntu')).toBe(true) + return { isDirectory: () => true } + }) + mocks.readWslDistroRunningState.mockReset() + mocks.readWslDistroRunningState.mockResolvedValue('stopped') +}) + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) +}) + +describe('a folder workspace on a stopped WSL distro', () => { + it('reports the folder unavailable to a background status read without touching it', async () => { + await expect( + getFolderWorkspacePathStatus(makeStore(), { + scope: 'folder-workspace', + folderWorkspaceId: 'folder-1' + }) + ).resolves.toEqual({ path: FOLDER, exists: false, reason: 'unavailable' }) + expect(mocks.stat).not.toHaveBeenCalled() + }) + + it('holds a passive restore with Start instead of checking the folder', async () => { + await expect( + assertWorkspaceAcceptsNewPty(makeStore(), WORKTREE_ID, 'session-1', undefined, true) + ).rejects.toThrow(wslDistroHoldMessage('Ubuntu', 'stopped')) + expect(mocks.stat).not.toHaveBeenCalled() + }) + + it('checks the folder as the user act for a spawn the user asked for', async () => { + await expect( + assertWorkspaceAcceptsNewPty(makeStore(), WORKTREE_ID, undefined, undefined, false) + ).resolves.toBeUndefined() + expect(mocks.stat).toHaveBeenCalledExactlyOnceWith(FOLDER) + }) + + it('lets a passive restore check the folder once the distro runs', async () => { + mocks.readWslDistroRunningState.mockResolvedValue('running') + mocks.stat.mockResolvedValue({ isDirectory: () => true }) + await expect( + assertWorkspaceAcceptsNewPty(makeStore(), WORKTREE_ID, 'session-1', undefined, true) + ).resolves.toBeUndefined() + expect(mocks.stat).toHaveBeenCalledExactlyOnceWith(FOLDER) + }) +}) diff --git a/src/main/ipc/pty/host-env/spawn-cwd.ts b/src/main/ipc/pty/host-env/spawn-cwd.ts index 90649272d7c..a3ad947576f 100644 --- a/src/main/ipc/pty/host-env/spawn-cwd.ts +++ b/src/main/ipc/pty/host-env/spawn-cwd.ts @@ -8,7 +8,8 @@ import { } from '../../../../shared/terminal-startup-cwd' import { assertFolderWorkspacePathUsable, - getFolderWorkspacePathStatus + getFolderWorkspacePathStatus, + resolveFolderWorkspaceStatusPath } from '../../../project-groups/folder-workspace-path-status' import { isWslMoveCwdFenced, @@ -18,6 +19,12 @@ import { WslMoveProjectFencedError } from '../../../persistence/moving-wsl-projects/wsl-move-scope-guard' import { listLiveDaemonPtyIds } from '../../../daemon/daemon-provider-state' +import { wslDistroHoldMessage } from '../../../../shared/wsl-distro-stopped-hold' +import { + blockedWslDistroForPath, + runAsWslUserAct, + wslDistroForPath +} from '../../../wsl/wsl-unc-access-gate' /** * A new PTY needs a workspace no WSL move is taking, and a folder that still exists. Reattaching to @@ -29,15 +36,16 @@ export function assertWorkspaceAcceptsNewPty( store: Store | undefined, worktreeId: string | undefined, sessionId?: string, - cwd?: string + cwd?: string, + passiveWslRestore?: boolean ): Promise | void { const fencedByCwd = !!cwd && isWslMoveCwdFenced(cwd) if (fencedByCwd || isWslMoveWorkspaceBlocked(store, worktreeId)) { return assertReattachesLiveSession(sessionId, worktreeId).then(() => - assertFolderWorkspacePtyPathUsable(store, worktreeId) + assertFolderWorkspacePtyPathUsable(store, worktreeId, passiveWslRestore) ) } - return assertFolderWorkspacePtyPathUsable(store, worktreeId) + return assertFolderWorkspacePtyPathUsable(store, worktreeId, passiveWslRestore) } /** Only a spawn on this machine starts in a folder a WSL move can be taking. */ @@ -64,16 +72,43 @@ async function assertReattachesLiveSession( export function assertFolderWorkspacePtyPathUsable( store: Store | undefined, - worktreeId: string | undefined + worktreeId: string | undefined, + passiveWslRestore = false ): Promise | void { const workspaceScope = typeof worktreeId === 'string' ? parseWorkspaceKey(worktreeId) : null if (!store || workspaceScope?.type !== 'folder') { return } - return getFolderWorkspacePathStatus(store, { + const request = { scope: 'folder-workspace', folderWorkspaceId: workspaceScope.folderWorkspaceId - }).then(assertFolderWorkspacePathUsable) + } as const + return checkFolderWorkspacePtyPath(store, request, passiveWslRestore) +} + +/** + * A spawn the user asked for may start the folder's stopped WSL distro (as before): its check runs + * as their act. A passive restore holds with Start instead, the same as a bridge pane's (E9). + */ +async function checkFolderWorkspacePtyPath( + store: Store, + request: { scope: 'folder-workspace'; folderWorkspaceId: string }, + passiveWslRestore: boolean +): Promise { + const { folderPath } = resolveFolderWorkspaceStatusPath({ store, request }) + if (passiveWslRestore) { + const blocked = await blockedWslDistroForPath(folderPath) + if (blocked) { + throw new Error(wslDistroHoldMessage(blocked.distro, blocked.state)) + } + assertFolderWorkspacePathUsable(await getFolderWorkspacePathStatus(store, request)) + return + } + assertFolderWorkspacePathUsable( + await runAsWslUserAct(wslDistroForPath(folderPath), () => + getFolderWorkspacePathStatus(store, request) + ) + ) } export function resolvePtySpawnStartupCwd( diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index 19f02d4d99e..4e421d05670 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -34,7 +34,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis const admission = ctx.deps.assertWorkspaceAcceptsNewPty( args.worktreeId, args.sessionId, - localPtySpawnCwd(args) + localPtySpawnCwd(args), + args.wslPassiveRestore === true ) if (admission) { await admission @@ -47,7 +48,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis const admission = ctx.deps.assertWorkspaceAcceptsNewPty( args.worktreeId, args.sessionId, - localPtySpawnCwd(args) + localPtySpawnCwd(args), + args.wslPassiveRestore === true ) if (admission) { await admission diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 93381707bbb..fc727749234 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -120,7 +120,8 @@ export type PtySpawnIpcDeps = PtySpawnCodexResumeDeps & { assertWorkspaceAcceptsNewPty: ( worktreeId: string | undefined, sessionId?: string, - cwd?: string + cwd?: string, + passiveWslRestore?: boolean ) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 799b544127b..865407ca680 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -218,8 +218,10 @@ export function registerPtyHandlers( const assertWorkspaceAcceptsNewPty = ( worktreeId: string | undefined, sessionId?: string, - cwd?: string - ): Promise | void => assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId, cwd) + cwd?: string, + passiveWslRestore?: boolean + ): Promise | void => + assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId, cwd, passiveWslRestore) const resolvePtySpawnStartupCwd = ( worktreeId: string | undefined, cwd: string | undefined, diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index 42fd2697d86..8ae616e7a41 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -25,7 +25,8 @@ export type PtyRuntimeControllerDeps = PtySpawnCodexResumeDeps & { assertWorkspaceAcceptsNewPty: ( worktreeId: string | undefined, sessionId?: string, - cwd?: string + cwd?: string, + passiveWslRestore?: boolean ) => Promise | void resolvePtySpawnStartupCwd: ( worktreeId: string | undefined, diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts index 01ff4947c24..fe92a91292a 100644 --- a/src/main/project-groups/folder-workspace-path-status.ts +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -8,6 +8,7 @@ import { getProjectGroupSubtreeIds } from '../../shared/project-groups' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ProjectGroup } from '../../shared/project-group-types' import type { Repo } from '../../shared/repo-types' +import { blockedWslDistroForPath } from '../wsl/wsl-unc-access-gate' type FolderWorkspacePathStatusStore = { getRepos: () => Repo[] @@ -111,6 +112,11 @@ async function statFolderPath( return { path, exists: false, reason: 'unavailable' } } + // A stat of a stopped distro's \\wsl.localhost folder boots it (E9): unknown, never missing. + // A user act on the distro (runAsWslUserAct) passes and may start it. + if (await blockedWslDistroForPath(path)) { + return { path, exists: false, reason: 'unavailable' } + } try { const stats = await statLocalPath(path) return stats.isDirectory()