From 80c9278b89a7ef169e8bafa6b82bf1aa77087d8d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:58:25 -0700 Subject: [PATCH] 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 : 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 --- .../opencode-wsl-runtime-preparation.ts | 3 +- .../claude-profile-wsl-router.test.ts | 25 ++++++++++++-- .../claude-profile-wsl-router.ts | 5 ++- src/main/cli/wsl-cli-installer.ts | 3 +- .../git/command-runner/wsl-host-failure.ts | 8 ++--- src/main/local-worktree-filesystem.ts | 3 +- src/main/wsl/wsl-process-failure.ts | 33 +++++++++++++++++++ 7 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 src/main/wsl/wsl-process-failure.ts diff --git a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts index 8d01e00072e..36847613265 100644 --- a/src/main/ai-vault/opencode-wsl-runtime-preparation.ts +++ b/src/main/ai-vault/opencode-wsl-runtime-preparation.ts @@ -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 { 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() } diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index b2f755dcd7d..7b4b9556ca5 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -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()), @@ -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') diff --git a/src/main/claude-accounts/claude-profile-wsl-router.ts b/src/main/claude-accounts/claude-profile-wsl-router.ts index e5e8fc2c6bb..7c4ec6861f8 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.ts @@ -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 { 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() } diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 51a3ea8c59e..478d8d3ad01 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -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 } diff --git a/src/main/git/command-runner/wsl-host-failure.ts b/src/main/git/command-runner/wsl-host-failure.ts index 85105e00e5d..b240069994e 100644 --- a/src/main/git/command-runner/wsl-host-failure.ts +++ b/src/main/git/command-runner/wsl-host-failure.ts @@ -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.' } diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 99f86ff9f91..bd7af6eb116 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -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 { 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 }) diff --git a/src/main/wsl/wsl-process-failure.ts b/src/main/wsl/wsl-process-failure.ts new file mode 100644 index 00000000000..017bb668eab --- /dev/null +++ b/src/main/wsl/wsl-process-failure.ts @@ -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'}` +}