fix(wsl): say why a silent WSL guest command failed (#34304)

* fix(wsl): say why a silent WSL guest command failed

A guest command that exits non-zero with empty stderr was reported as
"WSL <distro>: command failed". When wsl.exe itself fails it writes its
error to stdout, so the real cause was dropped. Report stderr, else
wsl.exe's own error, else the exit code, for the Claude account pointer,
OpenCode reader, WSL CLI install and WSL file operations.

Refs STA-12262 (#33917)

* refactor(wsl): share the wsl.exe host-failure exit check

* test(wsl): type the guest failure fixture instead of asserting it
This commit is contained in:
Neil
2026-10-27 00:58:25 -07:00
committed by GitHub
parent 3414917b70
commit 80c9278b89
7 changed files with 67 additions and 13 deletions
@@ -19,6 +19,7 @@ import {
import { REMOTE_NODE_RUNTIME_READY } from '../ssh/orcad-remote-node-runtime-report'
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { runWslProcess, type WslSpec } from '../wsl/wsl-runner'
import { describeWslProcessFailure } from '../wsl/wsl-process-failure'
import { filterPathsToRunningWslDistrosAsync } from '../wsl-running-path-filter'
import type { OpenCodeWslRuntime } from './session-scanner-opencode-wsl-runtime'
@@ -97,7 +98,7 @@ async function prepare(distro: string): Promise<OpenCodeWslRuntime> {
maxOutputBytes: 16 * 1024
})
if (result.code !== 0 || result.timedOut) {
throw new Error(`WSL SQLite reader setup failed: ${result.stderr.trim() || 'command failed'}`)
throw new Error(`WSL SQLite reader setup failed: ${describeWslProcessFailure(result)}`)
}
return result.stdout.trim()
}
@@ -17,7 +17,12 @@ import type { ClaudeManagedAccount } from '../../shared/managed-account-types'
import type { WslSpec } from '../wsl/wsl-runner'
import type * as WslPaths from '../../shared/wsl-paths'
const guest = vi.hoisted(() => ({ home: '' }))
const guest = vi.hoisted(
(): { home: string; failure: { code: number; stdout: string } | null } => ({
home: '',
failure: null
})
)
// Emulate the guest with local POSIX paths and /bin/sh.
vi.mock('../../shared/wsl-paths', async (original) => ({
...(await original<typeof WslPaths>()),
@@ -29,6 +34,9 @@ vi.mock('../wsl', () => ({
}))
vi.mock('../wsl/wsl-runner', () => ({
runWslProcess: async (spec: WslSpec) => {
if (guest.failure) {
return { ...guest.failure, stderr: '', timedOut: false }
}
const result = spawnSync('/bin/sh', ['-c', spec.script ?? '', 'sh', ...(spec.args ?? [])], {
encoding: 'utf8'
})
@@ -49,7 +57,10 @@ import { wslClaudeProfile } from './claude-profile-wsl-paths'
// Guest emulation runs POSIX scripts and the Linux Node bundle.
const posixHost = process.platform !== 'win32'
const roots: string[] = []
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })))
afterEach(() => {
guest.failure = null
roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))
})
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'claude-wsl-router-'))
@@ -95,6 +106,16 @@ function fixture() {
}
describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => {
it('names why a silent guest command failed instead of a bare "command failed"', async () => {
const f = fixture()
guest.failure = { code: 0xffffffff, stdout: 'Wsl/Service/0x80072747\r\n' }
await expect(f.router.publish('Ubuntu')).rejects.toThrow(
'WSL Ubuntu: wsl.exe: Wsl/Service/0x80072747'
)
guest.failure = { code: 3, stdout: '' }
await expect(f.router.publish('Ubuntu')).rejects.toThrow('WSL Ubuntu: exited with code 3')
})
it('publishes and launches the saved account across distro casing without changing guest spelling', async () => {
const f = fixture()
await f.router.publish('ubuntu')
@@ -13,6 +13,7 @@ import { getWslHomeAsync, listRunningWslDistrosAsync } from '../wsl'
import { ensureWslPinnedRuntime } from '../wsl/wsl-pinned-runtime'
import { relayBundleCandidates } from '../ssh/relay-bundle-paths'
import { runWslProcess, type WslSpec } from '../wsl/wsl-runner'
import { describeWslProcessFailure } from '../wsl/wsl-process-failure'
import { claudeProfileMarkerPath, type ClaudeProfileDescriptor } from './claude-profile-paths'
import type { ClaudeProfileRouterSettings } from './claude-profile-router'
import { claudeProfileMissing, claudeProfileSetupFailed } from './claude-profile-launch-errors'
@@ -262,9 +263,7 @@ async function writePointer(distro: string, pointer: string, home: string): Prom
async function runGuest(distro: string, spec: WslSpec, timeoutMs = 15_000): Promise<string> {
const result = await runWslProcess({ ...spec, distro, timeoutMs, maxOutputBytes: 256 * 1024 })
if (result.code !== 0 || result.timedOut) {
throw new Error(
`WSL ${distro}: ${result.stderr.trim() || (result.timedOut ? 'timed out' : 'command failed')}`
)
throw new Error(`WSL ${distro}: ${describeWslProcessFailure(result)}`)
}
return result.stdout.trim()
}
+2 -1
View File
@@ -1,6 +1,7 @@
import type { CliInstallStatus } from '../../shared/cli-install-types'
import { getDefaultWslDistro } from '../wsl'
import { runWslProcess } from '../wsl/wsl-runner'
import { describeWslProcessFailure } from '../wsl/wsl-process-failure'
import { CliInstaller } from './cli-installer'
import {
buildManagedLegacyRemoveCommand,
@@ -305,7 +306,7 @@ async function runWslCommand(
throw new WslCliLoginPathUnavailableError(distro)
}
if (result.code !== 0) {
throw new Error(result.stderr.trim() || `WSL command failed with exit code ${result.code}.`)
throw new Error(describeWslProcessFailure(result))
}
return result.stdout
}
@@ -1,8 +1,6 @@
import { isWslHostFailureExitCode, readWslHostDiagnostic } from '../../wsl/wsl-process-failure'
import type { ResolvedCommand } from './wsl-command-resolution'
/** wsl.exe's own launch-failure exit, distinct from any status the guest process can return. */
export const WSL_HOST_FAILURE_EXIT_CODE = 0xffffffff
function outputText(value: unknown): string {
if (typeof value === 'string') {
return value
@@ -26,10 +24,10 @@ export function readWslHostFailureDiagnostic(
}
const exitCode = typeof code === 'number' ? code : typeof status === 'number' ? status : null
// Recognize the host launch-failure shape without reclassifying guest stderr.
if (exitCode !== WSL_HOST_FAILURE_EXIT_CODE || outputText(stderr).trim().length > 0) {
if (!isWslHostFailureExitCode(exitCode) || outputText(stderr).trim().length > 0) {
return null
}
const diagnostic = outputText(stdout).replaceAll('\u0000', '').trim()
const diagnostic = readWslHostDiagnostic(outputText(stdout))
return diagnostic.length > 0 ? diagnostic : 'wsl.exe reported no diagnostic.'
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { quotePosixShell } from '../shared/shell-argument-quoting'
import { removeHostTree } from './host-tree-removal'
import { toLinuxPath } from './wsl'
import { resolveWslInteropSpawnCwd } from './wsl-interop-spawn-directory'
import { describeWslProcessFailure } from './wsl/wsl-process-failure'
import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof'
export { toHostFilesystemPath, toHostRemovalPath } from './host-tree-removal'
@@ -37,7 +38,7 @@ async function runWslCommand(distro: string, command: string): Promise<string> {
throw new Error(`WSL filesystem command timed out after ${WSL_FILE_OPERATION_TIMEOUT_MS}ms`)
}
if (result.code !== 0) {
throw Object.assign(new Error(result.stderr.trim() || `wsl.exe exited ${result.code}`), {
throw Object.assign(new Error(describeWslProcessFailure(result)), {
exitCode: result.code,
stderr: result.stderr
})
+33
View File
@@ -0,0 +1,33 @@
/** wsl.exe's own launch-failure exit, distinct from any status the guest process can return. */
const WSL_HOST_FAILURE_EXIT_CODE = 0xffffffff
/** Node reports the unsigned DWORD; >>> 0 also folds a signed -1. */
export function isWslHostFailureExitCode(code: number | null | undefined): boolean {
return typeof code === 'number' && code >>> 0 === WSL_HOST_FAILURE_EXIT_CODE
}
/** wsl.exe writes its own errors to stdout; strip UTF-16LE NULs left when WSL_UTF8 was ignored. */
export function readWslHostDiagnostic(stdout: string): string {
return stdout.replaceAll('\u0000', '').trim()
}
/** Why a guest command failed: its stderr, else wsl.exe's own error, else the exit status. */
export function describeWslProcessFailure(result: {
code: number | null
stdout: string
stderr: string
timedOut: boolean
}): string {
const stderr = result.stderr.trim()
if (stderr) {
return stderr
}
if (result.timedOut) {
return 'timed out'
}
if (isWslHostFailureExitCode(result.code)) {
const diagnostic = readWslHostDiagnostic(result.stdout)
return diagnostic ? `wsl.exe: ${diagnostic}` : 'wsl.exe could not start the command.'
}
return `exited with code ${result.code ?? 'unknown'}`
}