Files
oh-my-pi/packages/coding-agent/src/eval/py/spawn-options.ts
T
roboomp 867ee74a3b fix(eval): probed Win32 console directly instead of inferring from stdio
The TTY-OR heuristic still mis-classifies the all-stdio-redirected case:
`omp -p "..." < in.txt > out.txt 2> err.log` from a real Windows Terminal
session has `stdin.isTTY === stdout.isTTY === stderr.isTTY === false`,
but the host process can still own a console that the kernel could
inherit. The TTY signals reflect handle redirection, not console
attachment — `GetConsoleWindow()` is the authoritative Win32 signal.

`spawn-options.ts` now:

- Calls `kernel32!GetConsoleWindow()` via `bun:ffi` on Windows. A non-NULL
  HWND means the host has a console regardless of how the standard
  streams are wired, so `windowsHide` stays `false` and the kernel
  inherits the console — which is what fixes the #1960
  numpy/pandas `LoadLibraryExW` hang and lets SIGINT recover via
  `GenerateConsoleCtrlEvent`.
- Falls back to the TTY-OR heuristic when the FFI probe is unavailable
  or off-Windows. That keeps the predicate working on non-Bun-FFI
  runtimes and on POSIX, where `windowsHide` is a no-op anyway.
- Caches the probe result; console attachment is stable for the host's
  lifetime in practice, and dlopening kernel32 on every kernel spawn
  would be wasteful.

The pure helpers (`shouldHideKernelWindow`, `consoleAttachedViaTTY`)
stay separately exported so they remain unit-testable. The integration
boundary `hostHasInheritableConsole()` is what the kernel spawn site
calls, and its return is asserted to be a concrete boolean (kernel spawn
must commit to a `windowsHide` value).
2026-06-06 01:04:13 +00:00

127 lines
4.9 KiB
TypeScript

/**
* Subprocess spawn-option helpers for the Python kernel.
*
* Pure helpers (`shouldHideKernelWindow`, `consoleAttachedViaTTY`) live here
* so they can be unit-tested without dragging in the kernel's runtime
* dependencies. The effectful `hostHasInheritableConsole` wraps a Win32 FFI
* probe with a TTY fallback and is the function `kernel.ts` actually calls.
*/
import { dlopen, FFIType } from "bun:ffi";
/**
* Decide whether the long-lived Python kernel subprocess should be spawned
* with `windowsHide: true`.
*
* On Windows, Bun maps `windowsHide: true` to the `CREATE_NO_WINDOW` flag,
* which detaches the child from any inherited console. The Python kernel
* runs user code that imports NumPy/pandas; those native extensions
* (`numpy/_core/_multiarray_umath.pyd` + bundled OpenBLAS/SLEEF thread-pool
* init) can deadlock inside `LoadLibraryExW` when no console is attached,
* and a console-less child cannot receive SIGINT via
* `GenerateConsoleCtrlEvent` (the recovery path the host relies on). See
* issue #1960.
*
* So on Windows we hide only when the host itself has no console to share.
* In any launch where a console is attached — even one with every stdio
* stream redirected — the kernel inherits the parent's console, matching
* `python.exe` invoked from `cmd.exe`, which keeps native imports and
* SIGINT recovery working.
*
* Short-lived helper subprocesses elsewhere in the codebase (LSP probes,
* git, plugin installs) keep `windowsHide: true` because they don't load
* complex native modules and the brief console flash would be user-visible
* noise.
*/
export function shouldHideKernelWindow(opts: {
platform: NodeJS.Platform;
hostHasInheritableConsole: boolean;
}): boolean {
if (opts.platform !== "win32") return false;
return !opts.hostHasInheritableConsole;
}
/**
* TTY-based fallback used when the Win32 console probe is unavailable.
*
* Returns `true` if any of stdin/stdout/stderr is currently a TTY. This
* correctly detects the common interactive launches and the partial-
* redirection cases (`omp -p > out.txt`, `< in.txt`, `2> err.log`) where at
* least one stream stays bound to the terminal. The all-stdio-redirected
* case (`< in > out 2> err` from a console) is the reason we prefer the
* Win32 probe over this fallback whenever possible.
*/
export function consoleAttachedViaTTY(opts: {
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
stderrIsTTY: boolean;
}): boolean {
return opts.stdinIsTTY || opts.stdoutIsTTY || opts.stderrIsTTY;
}
/**
* Probe `kernel32.dll!GetConsoleWindow()` to detect whether the current
* Windows process owns a console window.
*
* Returns `true` for a non-NULL HWND, `false` when NULL (no console — true
* service / `DETACHED_PROCESS` / GUI parent), and `null` when the probe
* itself fails (off-Windows, FFI disabled, or unexpected kernel32 layout).
* A `null` return means "don't trust me, use the TTY fallback".
*
* Cached on first call because in practice the console attachment of a
* long-lived OMP host never changes for the lifetime of the process, and
* we don't want to re-dlopen kernel32 on every kernel spawn.
*/
type ConsoleProbeResult = boolean | null;
let cachedWindowsConsoleProbe: { value: ConsoleProbeResult } | undefined;
function probeWindowsConsoleWindow(): ConsoleProbeResult {
if (cachedWindowsConsoleProbe) return cachedWindowsConsoleProbe.value;
let value: ConsoleProbeResult = null;
try {
const lib = dlopen("kernel32.dll", {
GetConsoleWindow: { args: [], returns: FFIType.ptr },
});
try {
const hwnd = lib.symbols.GetConsoleWindow();
// FFIType.ptr returns `Pointer | null`; a 0 pointer should also be
// treated as NULL defensively in case Bun ever returns 0n / 0.
value = hwnd !== null && hwnd !== 0;
} finally {
lib.close();
}
} catch {
value = null;
}
cachedWindowsConsoleProbe = { value };
return value;
}
/** Reset the cached Win32 probe result. Test-only; not part of the public surface. */
export function __resetWindowsConsoleProbeCache(): void {
cachedWindowsConsoleProbe = undefined;
}
/**
* Whether the host process owns a console its children can inherit.
*
* - On Windows, the authoritative signal is `GetConsoleWindow()`. It returns
* a non-NULL HWND whenever the process has a console attached, regardless
* of how the standard streams are redirected — so an `omp -p ... < in.txt
* > out.txt 2> err.log` launched from a real Windows Terminal session is
* correctly classified as console-attached and the kernel keeps its
* inheritable console.
* - On any other platform, or if the FFI probe fails, fall back to the
* TTY-OR heuristic. That still catches the common interactive cases.
*/
export function hostHasInheritableConsole(): boolean {
if (process.platform === "win32") {
const native = probeWindowsConsoleWindow();
if (native !== null) return native;
}
return consoleAttachedViaTTY({
stdinIsTTY: !!process.stdin.isTTY,
stdoutIsTTY: !!process.stdout.isTTY,
stderrIsTTY: !!process.stderr.isTTY,
});
}