mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
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.
This commit is contained in:
@@ -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<typeof FsPromises>()),
|
||||
stat: mocks.stat
|
||||
}))
|
||||
vi.mock('../../../wsl-background-distro-gate', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof BackgroundDistroGateModule>()),
|
||||
readWslDistroRunningState: mocks.readWslDistroRunningState
|
||||
}))
|
||||
vi.mock(
|
||||
'../../../persistence/moving-wsl-projects/wsl-move-scope-guard',
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslMoveScopeGuardModule>()),
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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> | 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> | 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<void> {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -120,7 +120,8 @@ export type PtySpawnIpcDeps = PtySpawnCodexResumeDeps & {
|
||||
assertWorkspaceAcceptsNewPty: (
|
||||
worktreeId: string | undefined,
|
||||
sessionId?: string,
|
||||
cwd?: string
|
||||
cwd?: string,
|
||||
passiveWslRestore?: boolean
|
||||
) => Promise<void> | void
|
||||
resolvePtySpawnStartupCwd: (
|
||||
worktreeId: string | undefined,
|
||||
|
||||
@@ -218,8 +218,10 @@ export function registerPtyHandlers(
|
||||
const assertWorkspaceAcceptsNewPty = (
|
||||
worktreeId: string | undefined,
|
||||
sessionId?: string,
|
||||
cwd?: string
|
||||
): Promise<void> | void => assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId, cwd)
|
||||
cwd?: string,
|
||||
passiveWslRestore?: boolean
|
||||
): Promise<void> | void =>
|
||||
assertWorkspaceAcceptsNewPtyImpl(store, worktreeId, sessionId, cwd, passiveWslRestore)
|
||||
const resolvePtySpawnStartupCwd = (
|
||||
worktreeId: string | undefined,
|
||||
cwd: string | undefined,
|
||||
|
||||
@@ -25,7 +25,8 @@ export type PtyRuntimeControllerDeps = PtySpawnCodexResumeDeps & {
|
||||
assertWorkspaceAcceptsNewPty: (
|
||||
worktreeId: string | undefined,
|
||||
sessionId?: string,
|
||||
cwd?: string
|
||||
cwd?: string,
|
||||
passiveWslRestore?: boolean
|
||||
) => Promise<void> | void
|
||||
resolvePtySpawnStartupCwd: (
|
||||
worktreeId: string | undefined,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user