mirror of
https://github.com/stablyai/orca.git
synced 2026-11-05 19:47:27 +00:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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'}`
|
||||
}
|
||||
Reference in New Issue
Block a user