fix(wsl): runtime RPC background reads never start a stopped WSL distro (E9) (#35430)

Paired phones, web clients and the CLI poll reads nobody is looking at:
history, submodule status, ignored paths, the conflict operation, the
in-flight mutation probe, upstream status and link-existence checks.
Their desktop IPC twins already refuse a stopped distro; these did not, so
a client left open booted the distro again after the user terminated it.
Each now asks the settled running gate first and refuses with
WslDistroStoppedError, which reaches every client version as the existing
runtime_error.

User acts stay ungated: git.status (a viewing client's implicit wake),
diffs and compares (opened views), file reads and directory listing
(taps and browsing), and stat (the download name check).
This commit is contained in:
Neil
2026-10-28 16:48:31 -07:00
committed by GitHub
parent b18dc951ca
commit 5598d2fd09
5 changed files with 190 additions and 6 deletions
+5
View File
@@ -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<PathExistenceResult[]> {
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)
}
@@ -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<RuntimeGitTarget> {
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<GitStatusResult> {
// 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<GitStatusResult> {
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<string[]> {
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<GitHistoryResult> {
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<GitConflictOperation> {
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<GitMutationInFlight> {
const target = await this.host.resolveRuntimeGitTarget(worktreeSelector)
const target = await resolveGatedGitTarget(this.host, worktreeSelector)
requireLocalRuntimeGitRoute(target)
return {
inFlight: await isGitWorktreeMutationInFlight(
@@ -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<GitUpstreamStatus> {
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)
}
@@ -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<Record<string, unknown>>()),
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<RuntimeGitTarget, 'worktree'> & { 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<RuntimeGitTarget, 'worktree'> & { 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."
})
})
})
@@ -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<void> {
if (target.executionHostId !== LOCAL_EXECUTION_HOST_ID) {
return
}
await assertWslUncPathRunning(target.worktree.path, wslDistro)
}