mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* perf(windows): stop the capability poll respawning blocking wsl.exe probes #11295 added a 30s renderer interval to `useWindowsTerminalCapabilities` whose early-return only fires when WSL is available with at least one distro, so on the common Windows host (no WSL) it re-ran a full capability read forever. Each read IPCs four probes whose main-process handlers were synchronous `execFileSync` calls to wsl.exe/pwsh.exe, blocking the Electron main event loop for up to 5s a time. The un-latching intent is kept: a host that answers "no WSL" is still re-checked, now on an exponential backoff (30s, +60s, +120s) that parks once the answer stops moving, re-arms on window focus, is shared by all consumers of an owner key, and stops entirely when the last consumer unmounts. The wsl/pwsh IPC handlers now use async twins that share the existing caches and back off identically. * fix(windows): classify async wsl/pwsh probe failures with the execFile error shape The async twins feed `execFile` callback errors into classifiers written for `execFileSync`: a non-zero exit lands on `error.code` as a number rather than `error.status`, and a timeout is a SIGTERM kill rather than ETIMEDOUT. So a Windows host without WSL (wsl.exe ships in System32, so it exits non-zero instead of ENOENT) was cached as retryable, shrinking the shared window from 10min to 45s and making the still-sync callers re-pay their blocking spawn ~13x more often; and a pwsh cold start past 5s cached "pwsh missing" for 30s, demoting the user's PowerShell 7 preference — the exact case the ETIMEDOUT branch exists to prevent. Also drops a literal NUL byte from the new re-probe module's signature separator, which made the file binary to git, and seeds `lastProbeAt` at registration so focus churn right after mount cannot defer the first re-probe indefinitely. Co-authored-by: Orca <help@stably.ai> * perf(windows): route relay host-capability probes through the async wsl/pwsh twins A paired web/mobile client resolves `useWindowsTerminalCapabilities` to a local target (TabBar's `isWebClient` gate, and `useSettingsNavigationMetadata` forces `{kind:'local'}`), so the new re-probe arms there too. But `window.api.wsl/pwsh` on a web client is not the ipc/app.ts channel — it is `host.wsl.*`/`host.pwsh.*` over the runtime RPC, which still ran the sync probes and blocked the desktop main event loop on `execFileSync('wsl.exe' | 'pwsh.exe')` for up to 5s per call. Switch those handlers and the relay preflight capability probe to the async twins added here; they share the same caches, dedupe and backoff, so remote callers see no behavior change. * fix(windows): harden async capability reprobes * fix(windows): dedupe PowerShell shell probes --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
287 lines
8.8 KiB
TypeScript
287 lines
8.8 KiB
TypeScript
import { execFile } from 'node:child_process'
|
|
import { userInfo } from 'node:os'
|
|
import { promisify } from 'node:util'
|
|
import path, { win32 } from 'node:path'
|
|
import type { RelayDispatcher } from './dispatcher'
|
|
import { buildRelayCommandEnv } from './relay-command-env'
|
|
import { isPwshAvailableAsync } from '../main/pwsh'
|
|
import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl'
|
|
import { isGitBashAvailable } from '../main/git-bash'
|
|
import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup'
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
type CommandLookupSpec = {
|
|
file: string
|
|
args: string[]
|
|
windowsHide?: true
|
|
}
|
|
|
|
type RelayCommandLookupOptions = {
|
|
platform?: NodeJS.Platform
|
|
env?: NodeJS.ProcessEnv
|
|
accountLoginShell?: string | null
|
|
}
|
|
|
|
type AgentDetectionRuntime = NodeJS.Platform | 'wsl'
|
|
|
|
type AgentDetectionCommand = {
|
|
id: string
|
|
cmd: string
|
|
requiredCommands?: readonly string[]
|
|
unsupportedRuntimes?: readonly AgentDetectionRuntime[]
|
|
}
|
|
|
|
const SUPPORTED_POSIX_SHELLS = new Set(['sh', 'dash', 'bash', 'zsh', 'fish'])
|
|
const CONSERVATIVE_SYSTEM_SHELL_DIRS = new Set(['/bin', '/usr/bin'])
|
|
const AGENT_PATH_PREFIX = '__ORCA_AGENT_PATH__'
|
|
|
|
export class PreflightHandler {
|
|
private dispatcher: RelayDispatcher
|
|
|
|
constructor(dispatcher: RelayDispatcher) {
|
|
this.dispatcher = dispatcher
|
|
this.registerHandlers()
|
|
}
|
|
|
|
private registerHandlers(): void {
|
|
this.dispatcher.onRequest('preflight.detectAgents', (p) => this.detectAgents(p))
|
|
this.dispatcher.onRequest('preflight.detectWindowsTerminalCapabilities', () =>
|
|
this.detectWindowsTerminalCapabilities()
|
|
)
|
|
}
|
|
|
|
// Why: the client sends the command list rather than importing TUI_AGENT_CONFIG
|
|
// on the relay side. This keeps the relay bundle minimal and makes the protocol
|
|
// self-describing — the relay doesn't need to know the agent catalog.
|
|
private async detectAgents(params: Record<string, unknown>): Promise<{ agents: string[] }> {
|
|
const commands = params.commands as AgentDetectionCommand[]
|
|
if (!Array.isArray(commands)) {
|
|
return { agents: [] }
|
|
}
|
|
const probeCommands = [
|
|
...new Set(
|
|
commands
|
|
.filter((command) => !isDetectionUnsupportedInRuntime(command, process.platform))
|
|
.flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])])
|
|
)
|
|
]
|
|
|
|
const results = await Promise.all(
|
|
probeCommands.map(async (cmd) => ({
|
|
cmd,
|
|
installed: await this.isCommandOnPath(cmd)
|
|
}))
|
|
)
|
|
const foundCommands = new Set(
|
|
results.filter((result) => result.installed).map(({ cmd }) => cmd)
|
|
)
|
|
|
|
return {
|
|
agents: [
|
|
...new Set(
|
|
commands
|
|
.filter(
|
|
(command) =>
|
|
!isDetectionUnsupportedInRuntime(command, process.platform) &&
|
|
foundCommands.has(command.cmd) &&
|
|
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
|
|
)
|
|
.map(({ id }) => id)
|
|
)
|
|
]
|
|
}
|
|
}
|
|
|
|
private async detectWindowsTerminalCapabilities(): Promise<{
|
|
wslAvailable: boolean
|
|
wslDistros: string[]
|
|
pwshAvailable: boolean
|
|
gitBashAvailable: boolean
|
|
hostPlatform: NodeJS.Platform | null
|
|
}> {
|
|
const [wslAvailable, pwshAvailable, gitBashAvailable] = await Promise.all([
|
|
isWslAvailableAsync().catch(() => false),
|
|
isPwshAvailableAsync().catch(() => false),
|
|
Promise.resolve(isGitBashAvailable()).catch(() => false)
|
|
])
|
|
const wslDistros = wslAvailable ? await listWslDistrosAsync().catch(() => []) : []
|
|
return {
|
|
wslAvailable,
|
|
wslDistros,
|
|
pwshAvailable,
|
|
gitBashAvailable,
|
|
hostPlatform: process.platform
|
|
}
|
|
}
|
|
|
|
// Why: SSH exec channels give the relay a minimal environment without shell
|
|
// startup files sourced. Ask the user's configured shell so agent dirs added
|
|
// by zsh/bash/fish startup hooks match the remote terminal experience.
|
|
// Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there.
|
|
private async isCommandOnPath(command: string): Promise<boolean> {
|
|
return isCommandOnPathForRelay(command)
|
|
}
|
|
}
|
|
|
|
function isDetectionUnsupportedInRuntime(
|
|
command: AgentDetectionCommand,
|
|
runtime: AgentDetectionRuntime
|
|
): boolean {
|
|
return command.unsupportedRuntimes?.includes(runtime) === true
|
|
}
|
|
|
|
export function buildCommandLookupSpec(
|
|
command: string,
|
|
platform: NodeJS.Platform,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
accountLoginShell?: string | null
|
|
): CommandLookupSpec {
|
|
const [spec] = buildCommandLookupSpecs(command, platform, env, accountLoginShell)
|
|
return spec ?? buildPosixCommandLookupSpec(command, '/bin/sh')
|
|
}
|
|
|
|
export function buildCommandLookupSpecs(
|
|
command: string,
|
|
platform: NodeJS.Platform,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
accountLoginShell?: string | null
|
|
): CommandLookupSpec[] {
|
|
if (platform === 'win32') {
|
|
return [{ file: 'where.exe', args: [command], windowsHide: true }]
|
|
}
|
|
const trustedShell = pickTrustedPosixShell(
|
|
env,
|
|
resolveAccountLoginShell(platform, accountLoginShell)
|
|
)
|
|
const specs: CommandLookupSpec[] = []
|
|
|
|
if (trustedShell) {
|
|
specs.push(buildPosixCommandLookupSpec(command, trustedShell))
|
|
}
|
|
|
|
const inheritedPathSpec = buildPosixCommandLookupSpec(command, '/bin/sh')
|
|
if (!trustedShell || trustedShell !== inheritedPathSpec.file) {
|
|
specs.push(inheritedPathSpec)
|
|
}
|
|
|
|
return specs
|
|
}
|
|
|
|
export async function isCommandOnPathForRelay(
|
|
command: string,
|
|
options: RelayCommandLookupOptions = {}
|
|
): Promise<boolean> {
|
|
const platform = options.platform ?? process.platform
|
|
const env = options.env ?? process.env
|
|
const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell)
|
|
|
|
for (const spec of specs) {
|
|
try {
|
|
const { stdout } = await execFileAsync(spec.file, spec.args, {
|
|
encoding: 'utf-8',
|
|
env: buildRelayCommandEnv(env, platform),
|
|
timeout: 5000,
|
|
...(spec.windowsHide ? { windowsHide: true } : {})
|
|
})
|
|
if (hasAbsoluteCommandPath(stdout, platform)) {
|
|
return true
|
|
}
|
|
} catch {
|
|
// Try the inherited-PATH fallback before reporting the agent missing.
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean {
|
|
const pathOps = platform === 'win32' ? win32 : path
|
|
return output
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.some((line) => {
|
|
const resolvedPath =
|
|
platform === 'win32'
|
|
? line
|
|
: line.startsWith(AGENT_PATH_PREFIX)
|
|
? line.slice(AGENT_PATH_PREFIX.length)
|
|
: ''
|
|
return pathOps.isAbsolute(resolvedPath)
|
|
})
|
|
}
|
|
|
|
function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec {
|
|
const shellName = path.posix.basename(shell).toLowerCase()
|
|
if (shellName === 'fish') {
|
|
return { file: shell, args: ['-ilc', buildFishCommandLookupScript(command)] }
|
|
}
|
|
return { file: shell, args: [getShellCommandMode(shell), buildShCommandLookupScript(command)] }
|
|
}
|
|
|
|
function buildShCommandLookupScript(command: string): string {
|
|
// Why: login shells may define aliases or functions that mask the PATH executable.
|
|
return [
|
|
buildPosixCommandPathLookupScript({ kind: 'literal', value: command }),
|
|
'if [ -n "$resolved" ]; then',
|
|
`printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`,
|
|
'fi'
|
|
].join('\n')
|
|
}
|
|
|
|
function buildFishCommandLookupScript(command: string): string {
|
|
const quotedCommand = shellQuote(command)
|
|
return [
|
|
`set -l resolved (command -v ${quotedCommand} 2>/dev/null)`,
|
|
'if test -n "$resolved"',
|
|
`printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`,
|
|
'end'
|
|
].join('\n')
|
|
}
|
|
|
|
function resolveAccountLoginShell(
|
|
platform: NodeJS.Platform,
|
|
accountLoginShell?: string | null
|
|
): string | null {
|
|
if (accountLoginShell !== undefined) {
|
|
return accountLoginShell
|
|
}
|
|
if (platform === 'win32') {
|
|
return null
|
|
}
|
|
try {
|
|
return userInfo().shell ?? null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function pickTrustedPosixShell(
|
|
env: NodeJS.ProcessEnv,
|
|
accountLoginShell: string | null
|
|
): string | null {
|
|
const shell = env.SHELL
|
|
if (!shell || !path.posix.isAbsolute(shell)) {
|
|
return null
|
|
}
|
|
const shellName = path.posix.basename(shell).toLowerCase()
|
|
if (!SUPPORTED_POSIX_SHELLS.has(shellName)) {
|
|
return null
|
|
}
|
|
if (accountLoginShell) {
|
|
return shell === accountLoginShell ? shell : null
|
|
}
|
|
return CONSERVATIVE_SYSTEM_SHELL_DIRS.has(path.posix.dirname(shell)) ? shell : null
|
|
}
|
|
|
|
function getShellCommandMode(shell: string): '-lc' | '-ilc' {
|
|
const shellName = path.posix.basename(shell).toLowerCase()
|
|
// Why: bash/zsh/fish users commonly add package-manager bins from interactive
|
|
// startup files. POSIX sh/dash may not support interactive login flags.
|
|
return shellName === 'sh' || shellName === 'dash' ? '-lc' : '-ilc'
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, "'\\''")}'`
|
|
}
|