Files
orca/src/main/memory/host-memory.ts
T
Neil b7e79b7ca6 fix(windows): one chokepoint for every child process (#15746)
* feat(process): add the Windows-correct child-process chokepoint

Six decisions have to be made every time Orca starts a child process --
console visibility, argument quoting, .cmd interpretation, binary
resolution, timeout policy, and how the tree is later terminated. POSIX
forgives all six. Windows punishes each differently, and made per-call
site across 172 files they were right in some and wrong in others.

runProcess/spawnProcess make them once:
- windowsHide unconditionally, shell:false unconditionally (shell:true
  concatenates argv unescaped and silently disables windowsHide)
- .cmd/.bat routed through cmd.exe /d /v:off /s /c with a verbatim line,
  because Node refuses to spawn them otherwise (EINVAL)

The encoding was derived by measurement on Windows 11, not from the
docs. An embedded quote is written "" rather than \" so cmd's naive
quote count stays even -- with \" the parity flips and every later &
| < > on the line stops being data. Measured before the fix, argv
["a b", 'c"d', "e%F%g", "h&i", "j^k"] arrived as
["a b", 'c"d', "e^%F^%g", "h"]: the & truncated the argument and
ran its remainder as a command. Each % is broken out of the quoted run
as "^%" because %VAR% expands even inside quotes.

The import-boundary test is a ratchet seeded at today's 172 files; it
only shrinks.

* fix(process): route the console-flashing spawn sites through the chokepoint

The ssh -G config probe fires on every connect and reconnect, and ssh.exe
is console-subsystem, so a GUI-subsystem parent gets a fresh visible
conhost that takes foreground -- keystrokes typed into an Orca terminal
at that moment go into the black box (#10488, #14543). Same for the
ProxyJump tunnel, the ProxyCommand cmd.exe wrapper, the font enumeration
and the DPAPI cookie decrypt.

Also stops spawning powershell by bare name: PATH under Electron is not
the user's, so where policy has pruned the System32 entry the spawn fails
and the font picker silently reports five hardcoded families rather than
an error (#11771).

Deletes system-fonts' 40-line bespoke execFileText -- timeout, output cap
and kill are the chokepoint's job now. Adds runProcessSync so the sync
callers have a compliant path; without one the ratchet could never
reach zero.

The three suites that mocked child_process directly now mock runProcess,
which is the point: how a process gets started is no longer each
module's business. Ratchet 173 -> 170.

* fix(process): do not report a deliberately killed child as timed out

runProcessSync inferred a timeout from signal === 'SIGTERM'. Measured:
a real timeout sets error.code ETIMEDOUT and kills with SIGTERM, but so
does anything else that terminates the child -- and those cases set no
error at all. Reading the signal alone reports a process someone stopped
on purpose as having timed out, which callers retry.

* refactor(process): hold the ratchet as data and migrate the pwsh probes

The allowlist and the adversarial argument corpus are read only by tests,
so they were production modules in name only; they move to __fixtures__.

pwsh.ts carried isTimeoutError() purely to reconcile two spellings of the
same event -- execFileSync reports a timeout as ETIMEDOUT, the execFile
callback as a SIGTERM kill with no code. runProcess reports one timedOut
flag, so the helper and the reasoning behind it both go.

Its sync probe also spawned without windowsHide, which flashes a console
and steals foreground on every cold cache read.

* refactor(process): migrate five more spawn sites onto the chokepoint

Each one deletes a hand-rolled promise/timeout/kill wrapper and stops
re-deciding console visibility for itself. Ratchet 170 -> 164.

Two things this surfaced, both kept:

runProcess now accepts string chunks as well as buffers. A stream someone
called setEncoding on emits strings, and concatenating those as buffers
throws inside a data handler -- where the rejection has nowhere to go and
the caller simply hangs rather than failing.

ProcessSpec keeps its AbortSignal. I had removed it as unused; the macOS
PAM preflight passes one through from its own caller.

ipc/app.ts is deliberately NOT migrated. Its probe spawns a three-stage
 pipeline detached so a timeout can reap the group with one
negative-pid SIGKILL; runProcess kills only the root, which would orphan
the plutil stages. Migrating it needs the chokepoint to own POSIX
process-group termination first -- the same guarantee job objects give on
Windows. Reverted and left on the ratchet.

* test(process): do not assert a POSIX signal on Windows

Windows has no signals, so the same deliberate kill reports an exit code
there and a signal on POSIX. What has to hold on both is that neither
shape reads as a timeout. Caught by running the suite on Windows.

(cherry picked from commit 0a6e9902a22a369a0e85e113ea8d87b726f82e1f)

* fix(process): settle a timed-out run even when the child ignores the kill

close only fires once the child is actually gone, so a child that traps
SIGTERM never emits it and the promise outlives its own deadline
forever. That is the same wedge shape just fixed for the process table,
and it is worse here: pwsh.ts and the snapshot reader both cache an
in-flight probe, so one unkillable child hands every later caller the
same dead promise.

After the deadline it now escalates to SIGKILL and settles regardless,
reporting timedOut with whatever output arrived.

(cherry picked from commit 78ac169197c4e6faee1b9310a7186029cc11acbc)

* fix(process): escalate an aborted child too, not just a timed-out one

The grace escalation I added covered the timeout path and left abort on
the old one, so an aborted caller with an unkillable child still waited
forever -- the same defect, one path over. The macOS PAM preflight is a
real caller that passes an AbortSignal.

Both paths now share one stop-and-settle, and the result reports
timedOut honestly: false when the caller aborted.

(cherry picked from commit 7e9523a9e31172bb8183661b56f04c3ab6a03d0d)

* fix(windows): stop percent escaping from forging an escaped quote

escapePercentForCmd ran as a post-pass over the quoted string, so it
inserted a quote wherever a percent was -- including straight after a
backslash. CommandLineToArgvW reads backslash-quote as an escaped quote,
so C:\Users\%USERNAME%\x arrived corrupted. That is about as common as
Windows paths get, and my 20-case corpus had no backslash-before-percent
entry to catch it.

Percent handling is now part of the quoting loop, where the backslash
run is known and can be doubled before the inserted quote. Two corpus
cases cover the shape.

The program path gets the same treatment. It was quoted but not
percent-escaped, so a launcher under C:\Users\%USERNAME%\ had its own
path expanded on the cmd hop.

quoteWindowsArgument no longer takes a boolean. Passing it to
values.map() handed map's index in as the flag -- which is how the first
version of this fix was written, and the corpus test caught it.

Separately: an AbortSignal that was already aborted never fires the
event, so runProcess ran the child to its full timeout for a caller who
had already given up.

(cherry picked from commit f7e2e56b1ee1f27ab6d1035dde4501b38f95b374)
2026-08-21 21:05:24 -07:00

125 lines
4.1 KiB
TypeScript

import { runProcess } from '../../shared/child-process/run-process'
import { readFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import type { HostAvailableMemorySource, HostMemory } from '../../shared/process-stats-types'
const MEMORY_PRESSURE_TIMEOUT_MS = 1_000
const MEMORY_PRESSURE_MAX_BUFFER = 64 * 1024
const KIB = 1024
let darwinAvailabilitySupported = true
let linuxAvailabilitySupported = true
export async function collectHostMemory(): Promise<HostMemory> {
const total = nonNegativeNumber(os.totalmem())
const free = Math.min(total, nonNegativeNumber(os.freemem()))
const preferred = await readAvailableMemory(os.platform(), total)
const available = Math.min(total, Math.max(free, preferred?.bytes ?? free))
const used = Math.max(0, total - available)
return {
totalMemory: total,
freeMemory: free,
availableMemory: available,
availableMemorySource: preferred?.source ?? 'free-memory',
usedMemory: used,
memoryUsagePercent: total > 0 ? (used / total) * 100 : 0,
cpuCoreCount: Math.max(1, os.cpus().length),
loadAverage1m: nonNegativeNumber(os.loadavg()[0])
}
}
export function fallbackHostMemory(): HostMemory {
const total = nonNegativeNumber(os.totalmem())
const free = Math.min(total, nonNegativeNumber(os.freemem()))
const used = Math.max(0, total - free)
return {
totalMemory: total,
freeMemory: free,
availableMemory: free,
availableMemorySource: 'free-memory',
usedMemory: used,
memoryUsagePercent: total > 0 ? (used / total) * 100 : 0,
cpuCoreCount: Math.max(1, os.cpus().length),
loadAverage1m: nonNegativeNumber(os.loadavg()[0])
}
}
export function parseDarwinAvailableMemory(stdout: string, total: number): number | null {
const match = /System-wide memory free percentage:\s*(\d+)%/.exec(stdout)
const percentage = Number.parseInt(match?.[1] ?? '', 10)
if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100 || total <= 0) {
return null
}
return Math.round((total * percentage) / 100)
}
export function parseLinuxAvailableMemory(meminfo: string): number | null {
const match = /^MemAvailable:\s*(\d+)\s+kB$/m.exec(meminfo)
const kib = Number.parseInt(match?.[1] ?? '', 10)
if (!Number.isSafeInteger(kib) || kib < 0 || kib > Number.MAX_SAFE_INTEGER / KIB) {
return null
}
return kib * KIB
}
async function readAvailableMemory(
platform: NodeJS.Platform,
total: number
): Promise<{ bytes: number; source: HostAvailableMemorySource } | null> {
if (platform === 'darwin' && darwinAvailabilitySupported) {
const bytes = await readDarwinAvailableMemory(total)
if (bytes !== null) {
return { bytes, source: 'memory-pressure' }
}
}
if (platform === 'linux' && linuxAvailabilitySupported) {
const bytes = await readLinuxAvailableMemory()
if (bytes !== null) {
return { bytes, source: 'proc-meminfo' }
}
}
return null
}
async function readDarwinAvailableMemory(total: number): Promise<number | null> {
try {
const result = await runProcess({
program: '/usr/bin/memory_pressure',
args: ['-Q'],
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxOutputBytes: MEMORY_PRESSURE_MAX_BUFFER,
timeoutMs: MEMORY_PRESSURE_TIMEOUT_MS
})
if (result.code === 0 && !result.timedOut) {
const available = parseDarwinAvailableMemory(result.stdout, total)
if (available !== null) {
return available
}
}
} catch {
// The built-in command is unavailable on older or restricted hosts.
}
darwinAvailabilitySupported = false
return null
}
async function readLinuxAvailableMemory(): Promise<number | null> {
try {
const meminfo = await readFile(path.join(path.sep, 'proc', 'meminfo'), 'utf8')
const available = parseLinuxAvailableMemory(meminfo)
if (available !== null) {
return available
}
} catch {
// Non-procfs Linux environments fall back to Node's host value.
}
linuxAvailabilitySupported = false
return null
}
function nonNegativeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
}