diff --git a/src/main/runtime/files/file-access.ts b/src/main/runtime/files/file-access.ts index 9733e1b8830..8feef0897b6 100644 --- a/src/main/runtime/files/file-access.ts +++ b/src/main/runtime/files/file-access.ts @@ -24,6 +24,7 @@ import { } from './file-target' import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' import type { FileStat } from '../../providers/filesystem-provider-contract' +import { assertRuntimeReadWslDistroRunning } from '../runtime-wsl-background-read-gate' type RuntimeFileAccessHost = Pick< RuntimeFileCommandHost, @@ -126,6 +127,10 @@ export class RuntimeFileAccess { ): Promise { validatePathExistenceBatch(relativePaths) const targets = await resolveFileExplorerPaths(this.host, worktreeSelector, relativePaths) + // Link checks on render and retry timers: never start a stopped WSL distro (E9). + if (targets[0]) { + await assertRuntimeReadWslDistroRunning(targets[0]) + } return readRuntimeFilePathExistence(targets, this.host) } diff --git a/src/main/runtime/runtime-git-status-commands.ts b/src/main/runtime/runtime-git-status-commands.ts index 6b6d5b82bf9..8fa5a907cf6 100644 --- a/src/main/runtime/runtime-git-status-commands.ts +++ b/src/main/runtime/runtime-git-status-commands.ts @@ -12,8 +12,20 @@ import { localGitOptionsForTarget, requireLocalRuntimeGitRoute, requireRuntimeGitProvider, - type RuntimeGitCommandHost + type RuntimeGitCommandHost, + type RuntimeGitTarget } from './runtime-git-command-target' +import { assertRuntimeReadWslDistroRunning } from './runtime-wsl-background-read-gate' + +// Followers nobody is looking at: a stopped distro must stay stopped for them (E9). +async function resolveGatedGitTarget( + host: RuntimeGitCommandHost, + worktreeSelector: string +): Promise { + const target = await host.resolveRuntimeGitTarget(worktreeSelector) + await assertRuntimeReadWslDistroRunning(target, localGitOptionsForTarget(target).wslDistro) + return target +} export class RuntimeGitStatusCommands { constructor(private readonly host: RuntimeGitCommandHost) {} @@ -22,6 +34,7 @@ export class RuntimeGitStatusCommands { worktreeSelector: string, options?: GitProviderStatusOptions ): Promise { + // A client showing this workspace is a user act; its status is the implicit wake (E9). const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = requireRuntimeGitProvider(target) return options @@ -34,7 +47,7 @@ export class RuntimeGitStatusCommands { submodulePath: string, area: GitStagingArea = 'unstaged' ): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await resolveGatedGitTarget(this.host, worktreeSelector) return requireRuntimeGitProvider(target).getSubmoduleStatus( target.worktree.path, submodulePath, @@ -46,7 +59,7 @@ export class RuntimeGitStatusCommands { worktreeSelector: string, relativePaths: string[] ): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await resolveGatedGitTarget(this.host, worktreeSelector) return requireRuntimeGitProvider(target).checkIgnoredPaths(target.worktree.path, relativePaths) } @@ -54,17 +67,17 @@ export class RuntimeGitStatusCommands { worktreeSelector: string, options: GitHistoryOptions = {} ): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await resolveGatedGitTarget(this.host, worktreeSelector) return requireRuntimeGitProvider(target).getHistory(target.worktree.path, options) } async getRuntimeGitConflictOperation(worktreeSelector: string): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await resolveGatedGitTarget(this.host, worktreeSelector) return requireRuntimeGitProvider(target).detectConflictOperation(target.worktree.path) } async getRuntimeGitMutationInFlight(worktreeSelector: string): Promise { - const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const target = await resolveGatedGitTarget(this.host, worktreeSelector) requireLocalRuntimeGitRoute(target) return { inFlight: await isGitWorktreeMutationInFlight( diff --git a/src/main/runtime/runtime-git-sync-commands.ts b/src/main/runtime/runtime-git-sync-commands.ts index e004bde021c..0036d771d1f 100644 --- a/src/main/runtime/runtime-git-sync-commands.ts +++ b/src/main/runtime/runtime-git-sync-commands.ts @@ -2,6 +2,7 @@ import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../share import type { GitUpstreamStatus } from '../../shared/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' import { materializeWorktreePushTargetRemote } from '../ipc/worktree-remote' +import { assertRuntimeReadWslDistroRunning } from './runtime-wsl-background-read-gate' import type { LocalGitProvider } from '../providers/local-git-provider' import { localGitOptionsForTarget, @@ -51,6 +52,8 @@ export class RuntimeGitSyncCommands { pushTarget?: GitPushTarget ): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + // A poll no user asked for: never start a stopped WSL distro (E9). + await assertRuntimeReadWslDistroRunning(target, localGitOptionsForTarget(target).wslDistro) return requireRuntimeGitProvider(target).getUpstreamStatus(target.worktree.path, pushTarget) } diff --git a/src/main/runtime/runtime-wsl-background-read-gate.test.ts b/src/main/runtime/runtime-wsl-background-read-gate.test.ts new file mode 100644 index 00000000000..32dc159ea18 --- /dev/null +++ b/src/main/runtime/runtime-wsl-background-read-gate.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const readWslDistroRunningStateMock = vi.hoisted(() => vi.fn()) +vi.mock('../wsl-background-distro-gate', async (importOriginal) => ({ + ...(await importOriginal>()), + readWslDistroRunningState: readWslDistroRunningStateMock +})) + +import { RuntimeGitStatusCommands } from './runtime-git-status-commands' +import { RuntimeGitSyncCommands } from './runtime-git-sync-commands' +import { RuntimeGitDiffCommands } from './runtime-git-diff-commands' +import { RuntimeFileAccess } from './files/file-access' +import { RuntimeFileReads } from './files/file-reads' +import { RuntimeFileWatches } from './files/watch/file-watches' +import type { RuntimeGitCommandHost, RuntimeGitTarget } from './runtime-git-command-target' +import { WslDistroStoppedError } from '../wsl/wsl-unc-access-gate' +import { mapRuntimeError } from './rpc/errors' + +const UNC_ROOT = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' +const HOST_ROOT = 'C:\\src\\repo' +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function gitHost(target: Omit & { path: string }) { + const resolveRuntimeGitTarget = vi.fn(async () => ({ + ...target, + worktree: { id: 'wt-1', repoId: 'repo-1', path: target.path, git: {} } + })) + const host = { resolveRuntimeGitTarget } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These reads call only resolveRuntimeGitTarget before the gate decides. + return host as unknown as RuntimeGitCommandHost +} + +function statusCommands( + target: Omit & { path: string } +): RuntimeGitStatusCommands { + return new RuntimeGitStatusCommands(gitHost(target)) +} + +function fileHost(): never { + const host = { + getRuntimeId: () => 'runtime-1', + requireStore: () => ({ getRepos: () => [] }), + resolveRuntimeFileTarget: async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: UNC_ROOT, git: {} }, + executionHostId: 'local' + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These reads call only these members before the gate decides. + return host as never +} + +// verify G7: phones, web and the CLI polled Git followers while the desktop IPC twins were gated. +describe('runtime RPC Git followers for a stopped WSL distro', () => { + beforeEach(() => { + readWslDistroRunningStateMock.mockReset().mockResolvedValue('stopped') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + const uncHost = (): RuntimeGitCommandHost => gitHost({ path: UNC_ROOT, executionHostId: 'local' }) + const unc = (): RuntimeGitStatusCommands => new RuntimeGitStatusCommands(uncHost()) + + it.each([ + ['git.submoduleStatus', () => unc().getRuntimeGitSubmoduleStatus('id:wt-1', 'vendor')], + ['git.checkIgnored', () => unc().checkRuntimeGitIgnoredPaths('id:wt-1', ['dist'])], + ['git.history', () => unc().getRuntimeGitHistory('id:wt-1')], + ['git.conflictOperation', () => unc().getRuntimeGitConflictOperation('id:wt-1')], + ['git.mutationInFlight', () => unc().getRuntimeGitMutationInFlight('id:wt-1')], + [ + 'git.upstreamStatus', + () => new RuntimeGitSyncCommands(uncHost()).getRuntimeGitUpstreamStatus('id:wt-1') + ], + [ + 'files.pathsExist', + () => new RuntimeFileAccess(fileHost()).pathsExistRuntimeFiles('id:wt-1', ['a.ts']) + ] + ])('%s refuses before touching the distro', async (_method, read) => { + await expect(read()).rejects.toBeInstanceOf(WslDistroStoppedError) + expect(readWslDistroRunningStateMock).toHaveBeenCalledWith('Ubuntu', expect.anything()) + }) + + // Explicit taps, browsing and opened views are user acts: they never ask the gate. + it.each([ + [ + 'git.diff', + () => new RuntimeGitDiffCommands(uncHost()).getRuntimeGitDiff('id:wt-1', 'a.ts', false) + ], + [ + 'git.branchCompare', + () => new RuntimeGitDiffCommands(uncHost()).getRuntimeGitBranchCompare('id:wt-1', 'main') + ], + ['files.read', () => new RuntimeFileReads(fileHost()).readMobileFile('id:wt-1', 'a.ts')], + [ + 'files.readPreview', + () => new RuntimeFileReads(fileHost()).readFileExplorerPreview('id:wt-1', 'a.ts') + ], + ['files.readDir', () => new RuntimeFileWatches(fileHost()).readFileExplorerDir('id:wt-1', '')], + ['files.stat', () => new RuntimeFileAccess(fileHost()).statRuntimeFile('id:wt-1', 'a.ts')] + ])('%s stays a user act', async (_method, read) => { + await read().catch((error: unknown) => expect(error).not.toBeInstanceOf(WslDistroStoppedError)) + expect(readWslDistroRunningStateMock).not.toHaveBeenCalled() + }) + + it("keeps git.status as a viewing client's implicit wake", async () => { + await unc() + .getRuntimeGitStatus('id:wt-1') + .catch((error: unknown) => expect(error).not.toBeInstanceOf(WslDistroStoppedError)) + expect(readWslDistroRunningStateMock).not.toHaveBeenCalled() + }) + + it('refuses a Windows-path repo whose Git runs in a stopped distro', async () => { + const commands = statusCommands({ + path: HOST_ROOT, + executionHostId: 'local', + localGitOptions: { wslDistro: 'Ubuntu' } + }) + await expect(commands.getRuntimeGitHistory('id:wt-1')).rejects.toBeInstanceOf( + WslDistroStoppedError + ) + }) + + it('never asks WSL for an SSH host', async () => { + const commands = statusCommands({ path: UNC_ROOT, executionHostId: 'ssh:builder' }) + await expect(commands.getRuntimeGitHistory('id:wt-1')).rejects.not.toBeInstanceOf( + WslDistroStoppedError + ) + expect(readWslDistroRunningStateMock).not.toHaveBeenCalled() + }) + + it('reaches older clients as the runtime_error they already show', () => { + const response = mapRuntimeError( + 'req-1', + { runtimeId: 'runtime-1' }, + new WslDistroStoppedError('Ubuntu') + ) + expect(response.error).toEqual({ + code: 'runtime_error', + message: "WSL distro 'Ubuntu' is not running. Retry, or open a terminal in it, to start it." + }) + }) +}) diff --git a/src/main/runtime/runtime-wsl-background-read-gate.ts b/src/main/runtime/runtime-wsl-background-read-gate.ts new file mode 100644 index 00000000000..48e512b4f38 --- /dev/null +++ b/src/main/runtime/runtime-wsl-background-read-gate.ts @@ -0,0 +1,17 @@ +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { assertWslUncPathRunning } from '../wsl/wsl-unc-access-gate' + +/** + * Paired phone, web and CLI clients poll Git followers no user is looking at; a stopped distro + * refuses them (E9), as the desktop IPC twin does. The refusal reaches every client version as a + * plain `runtime_error` carrying its message. + */ +export async function assertRuntimeReadWslDistroRunning( + target: { worktree: { path: string }; executionHostId: ExecutionHostId }, + wslDistro?: string | null +): Promise { + if (target.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return + } + await assertWslUncPathRunning(target.worktree.path, wslDistro) +}