From d56c32bc9d757916ecc3d69b3039abd768b78f92 Mon Sep 17 00:00:00 2001 From: Astral Products <157904385+ilyaxuwu@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:35:36 +0300 Subject: [PATCH 1/3] Update run-terminal-command.ts --- sdk/src/tools/run-terminal-command.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index af3122bddb..d1d419ee5a 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -213,15 +213,16 @@ export function runTerminalCommand({ cwd: resolvedCwd, env: processEnv, stdio: 'pipe', - // On POSIX, give the command its own process group so that killing it - // (timeout or user abort) also kills any grandchild processes. - detached: !isWindows, - // On Windows, give the child its own invisible console (CREATE_NO_WINDOW) - // instead of attaching it to the TUI's console. Console-attached - // descendants can open CONIN$/CONOUT$ directly (cmd, pause, choice, - // PSReadLine, ...) even when stdio is piped, stealing the VT input that - // ConPTY generates for the TUI's mouse/focus tracking and echoing it as - // gibberish like `^[[I^[[<35;12;7M` painted over the UI. + // Give the command its own process group so that killing it (timeout or + // user abort) also kills any grandchild processes. On POSIX this uses a + // negative pid kill against the process group. On Windows `detached: true` + // maps to DETACHED_PROCESS, which combined with CREATE_NO_WINDOW (from + // windowsHide) completely detaches the child from the parent's console. + // Without DETACHED_PROCESS, console-attached descendants can open + // CONIN$/CONOUT$ directly even when stdio is piped, stealing the VT input + // that ConPTY generates for the TUI's mouse/focus tracking and echoing it + // as gibberish like `^[[I^[[<35;12;7M` painted over the UI. + detached: true, windowsHide: true, }) From dd174f797d98925ea10daeb4fdea3cbabc9f9c8e Mon Sep 17 00:00:00 2001 From: Astral Products <157904385+ilyaxuwu@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:27:13 +0300 Subject: [PATCH 2/3] Update run-terminal-command.ts --- sdk/src/tools/run-terminal-command.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index d1d419ee5a..1944a39cf1 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -175,6 +175,12 @@ export function runTerminalCommand({ const processEnv = { ...getSystemProcessEnv(), ...(env ?? {}), + // On Windows, prevent MSYS2 (Git Bash) from allocating its own PTY + // via ConPTY, which can reattach to the parent console even when + // DETACHED_PROCESS is set. MSYS=disable_pcon tells the MSYS2 runtime + // to skip pseudo console allocation, keeping console interaction + // minimal and preventing ConPTY mouse/focus VT events from leaking. + ...(isWindows ? { MSYS: 'disable_pcon' } : {}), } as NodeJS.ProcessEnv if (signal?.aborted) { From d33b518a9f5b572ed0fc0aea2f9e3c37b2e9834d Mon Sep 17 00:00:00 2001 From: Astral Products <157904385+ilyaxuwu@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:28:51 +0300 Subject: [PATCH 3/3] Add Job Object protection for Windows child processes --- sdk/src/tools/isolated-command.ts | 139 ++++++++++++++++++++++++++ sdk/src/tools/run-terminal-command.ts | 8 ++ 2 files changed, 147 insertions(+) create mode 100644 sdk/src/tools/isolated-command.ts diff --git a/sdk/src/tools/isolated-command.ts b/sdk/src/tools/isolated-command.ts new file mode 100644 index 0000000000..4796e8d023 --- /dev/null +++ b/sdk/src/tools/isolated-command.ts @@ -0,0 +1,139 @@ +/** + * Windows Job Object protection for child processes. + * + * On Windows, wraps a child process in a Job Object with + * JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so that if the parent dies + * unexpectedly (crash, taskkill /F), Windows terminates all children + * automatically. + * + * Uses Bun.FFI to call kernel32.dll directly. Falls back gracefully + * (returns null) if FFI is unavailable or fails. + */ + +import * as os from 'os' + +// ─── Bun.FFI — lazily loaded ──────────────────────────────────────────────── + +let kernel32: Record any> | null = null +let ffiPtr: ((buf: Buffer) => bigint) | null = null + +function ensureFFI(): boolean { + if (kernel32) return true + try { + const ffi = require('bun:ffi') as { + dlopen: (lib: string, symbols: Record) => { symbols: Record } + FFIType: { pointer: string; bool: string; u32: string; i32: string } + ptr: (buf: Buffer) => bigint + } + + const lib = ffi.dlopen('kernel32.dll', { + CreateJobObjectW: { + args: [ffi.FFIType.pointer, ffi.FFIType.pointer], + returns: ffi.FFIType.pointer, + }, + AssignProcessToJobObject: { + args: [ffi.FFIType.pointer, ffi.FFIType.pointer], + returns: ffi.FFIType.bool, + }, + SetInformationJobObject: { + args: [ffi.FFIType.pointer, ffi.FFIType.i32, ffi.FFIType.pointer, ffi.FFIType.i32], + returns: ffi.FFIType.bool, + }, + CloseHandle: { + args: [ffi.FFIType.pointer], + returns: ffi.FFIType.bool, + }, + OpenProcess: { + args: [ffi.FFIType.u32, ffi.FFIType.bool, ffi.FFIType.u32], + returns: ffi.FFIType.pointer, + }, + }) + kernel32 = lib.symbols + ffiPtr = ffi.ptr + return true + } catch { + kernel32 = null + ffiPtr = null + return false + } +} + +// ─── Win32 constants ──────────────────────────────────────────────────────── + +const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x0000_2000 +const JobObjectBasicLimitInformation = 2 +const PROCESS_SET_QUOTA_TERMINATE = 0x0000_1000 | 0x0000_0001 + +// ═══════════════════════════════════════════════════════════════════════════ +// Public API +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Wrap a child process in a Windows Job Object. + * + * The job is configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so that + * when this process exits (normally or via crash/kill), Windows terminates + * the child process automatically. + * + * Call immediately after spawn(). Returns a cleanup function to call when + * the child exits naturally. Returns null if: + * - Not on Windows + * - Bun.FFI is unavailable + * - Any FFI call fails (graceful degradation) + * + * @param childPid – PID of the child process to protect. + */ +export function protectChildWithJob(childPid: number): (() => void) | null { + if (os.platform() !== 'win32') return null + if (!ensureFFI() || !kernel32 || !ffiPtr) return null + + try { + const k32 = kernel32 + const ptr = ffiPtr + + // 1. Create an unnamed job object + const jobHandle = BigInt(k32.CreateJobObjectW(0n, 0n)) + if (!jobHandle) return null + + // 2. Set JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + // JOBOBJECT_BASIC_LIMIT_INFORMATION: 48 bytes, LimitFlags at offset 16 + const limitInfo = Buffer.alloc(48) + limitInfo.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16) + + const limitOk = k32.SetInformationJobObject( + jobHandle, + JobObjectBasicLimitInformation, + ptr(limitInfo), + 48, + ) + + if (!limitOk) { + k32.CloseHandle(jobHandle) + return null + } + + // 3. Open child process with minimal rights for job assignment + const hProcess = BigInt(k32.OpenProcess(PROCESS_SET_QUOTA_TERMINATE, false, childPid)) + if (!hProcess) { + k32.CloseHandle(jobHandle) + return null + } + + // 4. Assign process to the job + const assigned = k32.AssignProcessToJobObject(jobHandle, hProcess) + k32.CloseHandle(hProcess) + + if (!assigned) { + k32.CloseHandle(jobHandle) + return null + } + + // Return cleanup: close job handle when child exits naturally + return () => { + try { k32.CloseHandle(jobHandle) } catch { /* ignore */ } + } + } catch { + // FFI can throw on type marshalling errors. Swallow gracefully. + return null + } +} diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index ac5dd7d47c..fa123a6378 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -4,6 +4,7 @@ import * as os from 'os' import * as path from 'path' import type { ChildProcess } from 'child_process' +import { protectChildWithJob } from './isolated-command' import { stripColors, @@ -232,6 +233,12 @@ export function runTerminalCommand({ windowsHide: true, }) + // On Windows, add Job Object protection so the child is terminated + // automatically if this process crashes or is killed (taskkill /F). + const jobCleanup = isWindows && childProcess.pid != null + ? protectChildWithJob(childProcess.pid) + : null + liveChildren.add(childProcess) installExitSweep() @@ -316,6 +323,7 @@ export function runTerminalCommand({ // Handle process completion childProcess.on('close', (exitCode) => { liveChildren.delete(childProcess) + if (jobCleanup) jobCleanup() if (sigkillTimer) { clearTimeout(sigkillTimer) sigkillTimer = null