mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* 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)
125 lines
4.1 KiB
TypeScript
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
|
|
}
|