Files
orca/src/shared/terminal-process-inspection.ts
T
Neil 637dc30a32 fix(relay): observe Windows PTY child processes instead of answering false (#18591)
* fix(relay): observe Windows PTY child processes instead of answering false

`processHasChildren` returned a hardcoded `false` on Windows, and a hardcoded
negative is indistinguishable from a measurement. Every close guard reads it as
"nothing is running in this pane", so an SSH-to-Windows tab running a build
closed with no prompt. Measured on a real Windows SSH host: a live `PING.EXE`
under the pane's `cmd.exe` still reported `hasChildProcesses: false`, while the
identical harness on Linux reported `sleep` / `true`.

Windows has no `ps`, but it does have a process table, and the pane walk over it
already existed for the foreground reader. The answer now comes from
`queryWindowsPaneProcessInventory`; a table it could not read reports
`unverifiable` rather than a fabricated negative.

`hasChildProcesses` is a boolean, which cannot hold the third answer, and it is
read both as "busy, do not close" and as "the agent took the PTY, safe to type
into" — so no single mapping of `unverifiable` is safe for both. The verdict
moves to a new optional `childProcessEvidence` member that the close paths read;
the boolean keeps its exact meaning for every client that cannot.

Cost: `pty.inspectProcess` is the polled path and a relay host has no
`@vscode/windows-process-tree`, so its table read falls back to the 1.36s CIM
scan. Polling that would reinstate the fork storm the shared table exists to
prevent, so only a caller whose answer decides something asks for the scan.

* fix(runtime): forward scanChildProcesses through the environment inspection RPC

`guardRunningTerminalClose` asks the host to pay for a real child-process read,
but the environment path dropped the option before it reached the wire: the
renderer sent only `expectedIncarnationId`, and the RPC schema — the shared
`TerminalHandle` — silently stripped anything else. A host routing that pane
through an SSH relay then declined to scan and answered `unverifiable`, which
`inspectionReportsRunningWork` reads as running work. The result was a close
confirmation on an idle pane, which is the nag this PR exists to avoid.

Forwarded through all four layers: renderer payload, RPC schema, method handler,
and the runtime/controller signatures. The schema is a dedicated extension rather
than a field on `TerminalHandle`, so `clearBuffer`/`agentStatus`/`isRunningAgent`
keep refusing an option they have no use for.

The silent strip is not itself the defect — it is what makes a new optional member
safe to send to an old host, per docs/reference/remote-wire-compatibility.md. The
defect was the schema and its caller drifting inside one version, so the tests pin
the registered method rather than the schema alone: pointing it back at
`TerminalHandle` compiles, parses, and drops the option.

Found by review on #18591.

* fix(terminal): teach the shared running-work probe the third child-process answer

Rebasing onto main landed `probePtyRunningWork`, which is a better home for this
than the close guard: it already speaks `live` / `unverifiable` / `exited`, and it
exists so the tab-close and window-close guards cannot drift. The child-process
verdict belongs there, not in a parallel predicate beside it.

So the mapping moves into the probe and `inspectionReportsRunningWork` is deleted
rather than kept alongside. The probe now asks for the scan, and a host that could
not observe the pane reports `unverifiable` instead of collapsing onto `exited` --
which is what `hasChildProcesses: false` meant on every Windows relay.

The pane-close path is routed through the same probe for the same reason; it was
the third caller asking this question through a direct inspect of its own.
2026-09-04 02:11:09 -07:00

129 lines
4.1 KiB
TypeScript

import type { RemoteForegroundEvidence } from './foreground-process-evidence'
/**
* What the execution host observed about processes running under a PTY's shell.
*
* Separate from `hasChildProcesses` because a boolean cannot hold the third answer. The host that
* could not read its own process table and the host that read it and found nothing both had to
* spell themselves `false`, and every close guard reads `false` as "nothing is running here".
* Windows relays spelled it `false` unconditionally.
*/
export type PtyChildProcessVerdict = 'children' | 'no-children' | 'unverifiable'
/** Reasons the renderer could not observe the execution host. */
export type ClientOnlyUnverifiableReason =
| 'transport_loss'
| 'timeout'
| 'terminal_gone'
| 'old_host'
/**
* A renderer-only verdict. Host identity fields are explicitly forbidden so a
* transport failure cannot be promoted into a synthetic host observation.
*/
export type ClientOnlyUnverifiableInspection = {
foregroundProcess: null
hasChildProcesses: false
verdict: 'unverifiable'
reason: string
foregroundProcessEvidence?: never
childProcessEvidence?: never
authorityGeneration?: never
observationEpoch?: never
capturedAgeMs?: never
ptyId?: never
ptyIncarnationId?: never
}
/** Compatibility-shaped host/local inspection returned by the inspect RPC. */
export type HostProcessInspection = {
foregroundProcess: string | null
hasChildProcesses: boolean
/** Optional on old hosts; the renderer treats an omitted field as old-host unverifiable. */
foregroundProcessEvidence?: RemoteForegroundEvidence
/** Absent on hosts that predate the member, and on answers the host did not pay to observe. */
childProcessEvidence?: PtyChildProcessVerdict
verdict?: never
reason?: never
}
export type TerminalProcessInspection = HostProcessInspection | ClientOnlyUnverifiableInspection
export function clientOnlyUnverifiableInspection(reason: string): ClientOnlyUnverifiableInspection {
return {
foregroundProcess: null,
hasChildProcesses: false,
verdict: 'unverifiable',
reason
}
}
export function isClientOnlyUnverifiableInspection(
value: unknown
): value is ClientOnlyUnverifiableInspection {
return (
typeof value === 'object' &&
value !== null &&
(value as { verdict?: unknown }).verdict === 'unverifiable'
)
}
/**
* Classify only failures that mean the execution host could not be observed.
* Unexpected programming errors deliberately return null and remain throws.
*/
export function classifyTerminalProcessInspectionFailure(
error: unknown
): ClientOnlyUnverifiableReason | null {
const message = error instanceof Error ? error.message : String(error)
const code =
error && typeof error === 'object' && 'code' in error
? String((error as { code?: unknown }).code)
: ''
if (
code === 'terminal_handle_stale' ||
code === 'terminal_exited' ||
code === 'terminal_gone' ||
code === 'no_connected_pty' ||
message.includes('terminal_handle_stale') ||
message.includes('terminal_exited') ||
message.includes('terminal_gone') ||
message.includes('no_connected_pty') ||
/PTY\s+"[^"]+"\s+not found/i.test(message)
) {
return 'terminal_gone'
}
if (
code === 'SSH_MUX_REQUEST_TIMEOUT' ||
code === 'request_timeout' ||
code === 'rpc_timeout' ||
code === 'deadline_exceeded' ||
/\b(?:timed?\s*out|timeout)\b/i.test(message)
) {
return 'timeout'
}
if (
code === 'method_not_found' ||
code === 'rpc_method_not_found' ||
code === 'unsupported_method' ||
/(?:method|inspectProcess).*not found|unsupported.*(?:method|inspect)/i.test(message)
) {
return 'old_host'
}
if (
code === 'CONNECTION_LOST' ||
code === 'DISPOSED' ||
code === 'socket_closed' ||
code === 'connection_closed' ||
code === 'transport_closed' ||
code === 'runtime_unavailable' ||
code === 'remote_runtime_unavailable' ||
/(?:connection\s+(?:lost|closed)|socket\s+(?:closed|lost)|terminal\s+closed|runtime\s+unavailable|reconnecting|multiplexer\s+disposed|request\s+closed)/i.test(
message
)
) {
return 'transport_loss'
}
return null
}