feat(subprocess): Windows terminal inspection and signalling
createProcessInspector now returns a WindowsProcessInspector on win32 instead of throwing: Toolhelp32 tree enumeration with GetProcessTimes start identities, the shell pid as a pseudo foreground group, taskkill tree signalling, and inspector-verified Windows teardown (node-pty signal kills throw on Windows, and externally taskkilled shells may never fire its exit notification, so the handle settles \done\ from the verified absence). subprocess-local and pty-local suites now run on Windows with platform gates; the koffi-backed inspector joins the windows-only coverage exclusions on Linux and is fully covered by the windows-native lane. Also flips vitest.config so subprocess-local and pty-local sources are coverage-required on win32, and adapts the spawn/terminal suites to run natively there (node-translated shell commands, injected POSIX group paths, taskkill signal semantics).
This commit is contained in:
1 parent
05a8846c6e
commit
da403d6086
11 files changed
+809
-46
No files matched your search
@@ -42,6 +42,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"koffi": "^3.1.0",
|
||||
"node-pty": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
|
||||
import { createWindowsProcessInspector } from './windows-inspector.ts'
|
||||
|
||||
/** PID plus start identity, preventing teardown escalation after PID reuse. */
|
||||
export interface ProcessIdentity {
|
||||
@@ -370,5 +371,6 @@ export function createProcessInspector(
|
||||
): ProcessInspector {
|
||||
if (platform === 'linux') return new LinuxProcessInspector(arch, internals)
|
||||
if (platform === 'darwin') return new MacProcessInspector(internals)
|
||||
if (platform === 'win32') return createWindowsProcessInspector()
|
||||
throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`)
|
||||
}
|
||||
@@ -50,11 +50,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
* @param terminal - allocated node-pty process.
|
||||
* @param inspector - platform process/session operations.
|
||||
* @param graceMs - TERM-to-KILL and exit-wait grace.
|
||||
* @param platform - host platform; defaults to the running platform, injectable for deterministic tests.
|
||||
*/
|
||||
constructor(
|
||||
private readonly terminal: IPty,
|
||||
private readonly inspector: ProcessInspector,
|
||||
private readonly graceMs: number,
|
||||
private readonly platform: NodeJS.Platform = process.platform,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid)
|
||||
@@ -98,6 +100,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
|
||||
}
|
||||
if (this.platform === 'win32') {
|
||||
if (signal === 'SIGINT') {
|
||||
// Windows has no process-group signalling: a `\x03` input write is the
|
||||
// Ctrl-C delivery path conhost turns into a console-wide CTRL_C event
|
||||
// for attached processes. node-pty's signal kills throw on Windows, so
|
||||
// no signal ever reaches the inspector.
|
||||
this.terminal.write('\x03')
|
||||
return foreground.processGroupId
|
||||
}
|
||||
if (signal === 'SIGTSTP' || signal === 'SIGHUP') {
|
||||
throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`)
|
||||
}
|
||||
}
|
||||
this.inspector.signalGroup(foreground.processGroupId, signal)
|
||||
return foreground.processGroupId
|
||||
}
|
||||
@@ -177,6 +192,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
}
|
||||
|
||||
private async stopShell(): Promise<void> {
|
||||
if (this.platform === 'win32') {
|
||||
await this.stopShellWindows()
|
||||
return
|
||||
}
|
||||
if (!this.exited) {
|
||||
try {
|
||||
this.terminal.kill('SIGTERM')
|
||||
@@ -196,6 +215,44 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
|
||||
}
|
||||
|
||||
private async stopShellWindows(): Promise<void> {
|
||||
// node-pty's Windows kill(signal) throws ("Signals not supported on
|
||||
// windows"), and its bare kill() delegates to a console-list agent that
|
||||
// fails when the parent has no console. taskkill tree escalation is the
|
||||
// teardown path, fenced on the shell's start identity like every
|
||||
// descendant; a root identity miss falls back to the bare kill. taskkill
|
||||
// termination also does not reliably fire node-pty's exit notification
|
||||
// (the same console-list agent), so the tiers verify the shell's absence
|
||||
// through the inspector instead of waiting on `done` alone.
|
||||
const shellGone = (): boolean =>
|
||||
this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity))
|
||||
if (!shellGone() && this.rootIdentity !== undefined) {
|
||||
this.inspector.signalProcess(this.rootIdentity, 'SIGTERM')
|
||||
await this.waitForWindowsShellExit()
|
||||
}
|
||||
if (!shellGone() && this.rootIdentity === undefined) {
|
||||
try {
|
||||
this.terminal.kill()
|
||||
} catch (_topLevelAlreadyExitedDuringKill) {
|
||||
// The exit callback is authoritative.
|
||||
}
|
||||
await Promise.race([this.done.then(() => undefined), delay(this.graceMs)])
|
||||
}
|
||||
if (!shellGone() && this.rootIdentity !== undefined) {
|
||||
this.inspector.signalProcess(this.rootIdentity, 'SIGKILL')
|
||||
await this.waitForWindowsShellExit()
|
||||
}
|
||||
if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`)
|
||||
}
|
||||
|
||||
private async waitForWindowsShellExit(): Promise<void> {
|
||||
const until = Date.now() + this.graceMs
|
||||
while (!this.exited && Date.now() < until) {
|
||||
if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return
|
||||
await delay(Math.min(25, Math.max(1, until - Date.now())))
|
||||
}
|
||||
}
|
||||
|
||||
private async closeOnce(): Promise<void> {
|
||||
let survivors = await this.stopDescendants()
|
||||
if (survivors.length > 0) {
|
||||
@@ -206,7 +263,24 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
|
||||
if (survivors.length > 0) {
|
||||
throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`)
|
||||
}
|
||||
this.settleExitIfGone()
|
||||
this.dataDisposable.dispose()
|
||||
this.exitDisposable.dispose()
|
||||
}
|
||||
|
||||
private settleExitIfGone(): void {
|
||||
// An externally taskkilled Windows shell may never fire node-pty's exit
|
||||
// notification (its console-list agent fails without a parent console),
|
||||
// which would leave `done` — and every consumer awaiting it — unsettled
|
||||
// forever. Teardown has just verified the shell's absence through the
|
||||
// inspector, so a missing exit event is itself the outcome.
|
||||
if (this.platform !== 'win32') return
|
||||
if (this.exited) return
|
||||
/* v8 ignore next -- stopShellWindows() verified the shell is gone or threw;
|
||||
the identity re-check is a defensive fence for a future caller. */
|
||||
if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return
|
||||
this.exited = true
|
||||
this.output.end()
|
||||
this.outcome.resolve({ exitCode: null, signal: null })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Windows process-table operations for terminal readiness, signalling, and
|
||||
* teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time
|
||||
* identity, the shell pid as a pseudo process group (Windows has no POSIX
|
||||
* groups), and taskkill tree signalling. The koffi bindings load lazily so
|
||||
* non-Windows processes never touch Win32 libraries; all decision logic takes
|
||||
* an injectable internals boundary so suites can pin it on any host.
|
||||
* @module dsh-subprocess-local/windows-inspector
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import koffi from 'koffi'
|
||||
import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts'
|
||||
|
||||
/** One Toolhelp32 process-table row. */
|
||||
export interface ProcessEntry {
|
||||
pid: number
|
||||
parentPid: number
|
||||
}
|
||||
|
||||
/** Injectable Windows process operations used by one local PTY session. */
|
||||
export interface WindowsProcessInspectorInternals {
|
||||
/** Enumerate the current process table (pid/parent pairs). */
|
||||
snapshot(): ProcessEntry[]
|
||||
/** Return one process's creation-time identity, or undefined when unreadable. */
|
||||
creationTime(pid: number): string | undefined
|
||||
/** Terminate one process tree; `force` maps to taskkill `/F`. */
|
||||
taskkill(pid: number, force: boolean): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a process table from one root in children-first order, retaining only
|
||||
* members whose start identity is readable (unreadable members are detector
|
||||
* misses, exactly like an unreadable `/proc` entry on Linux).
|
||||
* @param entries - the process table snapshot.
|
||||
* @param rootPid - the tree root to descend from.
|
||||
* @param started - creation-time identity resolver for one member.
|
||||
* @returns the root and its current transitive descendants, children first.
|
||||
*/
|
||||
export function windowsProcessTree(
|
||||
entries: ProcessEntry[],
|
||||
rootPid: number,
|
||||
started: (pid: number) => string | undefined,
|
||||
): ProcessIdentity[] {
|
||||
const byPid = new Map(entries.map(entry => [entry.pid, entry]))
|
||||
const root = byPid.get(rootPid)
|
||||
if (root === undefined) return []
|
||||
const byParent = new Map<number, ProcessEntry[]>()
|
||||
for (const entry of entries) {
|
||||
const children = byParent.get(entry.parentPid) ?? []
|
||||
children.push(entry)
|
||||
byParent.set(entry.parentPid, children)
|
||||
}
|
||||
const visited = new Set<number>()
|
||||
const result: ProcessIdentity[] = []
|
||||
const visit = (entry: ProcessEntry): void => {
|
||||
if (visited.has(entry.pid)) return
|
||||
visited.add(entry.pid)
|
||||
for (const child of byParent.get(entry.pid) ?? []) visit(child)
|
||||
const identity = started(entry.pid)
|
||||
if (identity !== undefined) result.push({ pid: entry.pid, started: identity })
|
||||
}
|
||||
visit(root)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows {@link ProcessInspector}. The shell pid stands in for a foreground
|
||||
* process group: it is a stable pseudo-group that lets the prompt-marker
|
||||
* readiness path compare foreground identities, while every actual signal
|
||||
* targets the console-wide tree through taskkill (SIGINT is delivered by the
|
||||
* terminal handle as a `\x03` input write and never reaches this layer).
|
||||
*/
|
||||
export class WindowsProcessInspector implements ProcessInspector {
|
||||
constructor(
|
||||
private readonly internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(),
|
||||
) {}
|
||||
|
||||
foregroundPgid(shellPid: number): number {
|
||||
return shellPid
|
||||
}
|
||||
|
||||
isStdinWaiting(_pgid: number): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
processTree(rootPid: number): ProcessIdentity[] {
|
||||
return windowsProcessTree(this.internals.snapshot(), rootPid, this.internals.creationTime)
|
||||
}
|
||||
|
||||
processSession(_sessionId: number): ProcessIdentity[] {
|
||||
return []
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
const started = this.internals.creationTime(identity.pid)
|
||||
return started !== undefined && started === identity.started
|
||||
}
|
||||
|
||||
signalGroup(pgid: number, signal: SubprocessTerminalSignal): void {
|
||||
this.internals.taskkill(pgid, signal === 'SIGKILL')
|
||||
}
|
||||
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void {
|
||||
if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Windows process inspector.
|
||||
* @param internals - injectable process operations; defaults to the koffi-backed table.
|
||||
* @returns the Windows inspector.
|
||||
*/
|
||||
export function createWindowsProcessInspector(
|
||||
internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(),
|
||||
): WindowsProcessInspector {
|
||||
return new WindowsProcessInspector(internals)
|
||||
}
|
||||
|
||||
/** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */
|
||||
function taskkillTree(pid: number, force: boolean): void {
|
||||
if (pid <= 0) return
|
||||
// Outcome deliberately unchecked: an already-absent tree, exit races, and a
|
||||
// missing taskkill binary are as tolerable here as ESRCH is for POSIX.
|
||||
spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' })
|
||||
}
|
||||
|
||||
declare const nativePtr: unique symbol
|
||||
/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */
|
||||
export type NativePtr = bigint & { readonly [nativePtr]: true }
|
||||
|
||||
/**
|
||||
* True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs.
|
||||
* @param value - a handle as koffi may hand it back (pointer, null, or 0n).
|
||||
* @returns whether the value signals an invalid handle.
|
||||
*/
|
||||
export function isInvalidHandle(value: NativePtr | null | undefined): boolean {
|
||||
if (value === null || value === undefined) return true
|
||||
const asBigInt = value as bigint
|
||||
return asBigInt === 0n || asBigInt === 0xFFFFFFFFFFFFFFFFn || asBigInt === -1n
|
||||
}
|
||||
|
||||
/** The lazy koffi binding table: every Win32 call the Windows inspector uses. */
|
||||
interface Win32Bindings {
|
||||
createToolhelp32Snapshot(flags: number, processId: number): NativePtr
|
||||
process32FirstW(snapshot: NativePtr, entry: NativePtr): number
|
||||
process32NextW(snapshot: NativePtr, entry: NativePtr): number
|
||||
openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr
|
||||
getProcessTimes(
|
||||
process: NativePtr,
|
||||
creation: NativePtr,
|
||||
exit: NativePtr,
|
||||
kernel: NativePtr,
|
||||
user: NativePtr,
|
||||
): number
|
||||
closeHandle(handle: NativePtr): number
|
||||
}
|
||||
|
||||
const PVOID: ReturnType<typeof koffi.pointer> = koffi.pointer('void')
|
||||
|
||||
/**
|
||||
* Resolve the koffi Win32 struct types once. Registration is lazy and cached
|
||||
* because koffi's type registry is global per process: test runners that
|
||||
* re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not
|
||||
* re-register the names.
|
||||
*/
|
||||
function win32Structs(): { PROCESSENTRY32W: ReturnType<typeof koffi.struct>; FILETIME: ReturnType<typeof koffi.struct> } {
|
||||
if (cachedStructs !== undefined) return cachedStructs
|
||||
// koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout.
|
||||
const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', {
|
||||
dwSize: 'uint32',
|
||||
cntUsage: 'uint32',
|
||||
th32ProcessID: 'uint32',
|
||||
th32DefaultHeapID: PVOID,
|
||||
th32ModuleID: 'uint32',
|
||||
cCntThreads: 'uint32',
|
||||
th32ParentProcessID: 'uint32',
|
||||
pcPriClassBase: 'int32',
|
||||
dwFlags: 'uint32',
|
||||
szExeFile: koffi.array('char16', 260),
|
||||
})
|
||||
// koffi FILETIME layout (minwinbase.h): two 32-bit halves of the 64-bit timestamp.
|
||||
const FILETIME = koffi.struct('FILETIME', {
|
||||
dwLowDateTime: 'uint32',
|
||||
dwHighDateTime: 'uint32',
|
||||
})
|
||||
/* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */
|
||||
if (PROCESSENTRY32W.size !== 568) {
|
||||
throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
cachedStructs = { PROCESSENTRY32W, FILETIME }
|
||||
return cachedStructs
|
||||
}
|
||||
|
||||
let cachedStructs: ReturnType<typeof win32Structs> | undefined
|
||||
|
||||
const TH32CS_SNAPPROCESS = 0x2
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
|
||||
let cachedBindings: Win32Bindings | undefined
|
||||
|
||||
/**
|
||||
* Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed).
|
||||
* @returns the cached binding table.
|
||||
*/
|
||||
function win32Bindings(): Win32Bindings {
|
||||
if (cachedBindings !== undefined) return cachedBindings
|
||||
const { PROCESSENTRY32W, FILETIME } = win32Structs()
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
const bind = (
|
||||
name: string,
|
||||
result: ReturnType<typeof koffi.pointer> | string,
|
||||
args: Array<ReturnType<typeof koffi.pointer> | string>,
|
||||
): unknown => kernel32.func('__stdcall', name, result, args)
|
||||
cachedBindings = {
|
||||
createToolhelp32Snapshot: bind('CreateToolhelp32Snapshot', PVOID, ['uint32', 'uint32']),
|
||||
process32FirstW: bind('Process32FirstW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]),
|
||||
process32NextW: bind('Process32NextW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]),
|
||||
openProcess: bind('OpenProcess', PVOID, ['uint32', 'int', 'uint32']),
|
||||
getProcessTimes: bind('GetProcessTimes', 'int', [
|
||||
PVOID,
|
||||
koffi.pointer(FILETIME),
|
||||
koffi.pointer(FILETIME),
|
||||
koffi.pointer(FILETIME),
|
||||
koffi.pointer(FILETIME),
|
||||
]),
|
||||
closeHandle: bind('CloseHandle', 'int', [PVOID]),
|
||||
} as unknown as Win32Bindings
|
||||
return cachedBindings
|
||||
}
|
||||
|
||||
/** Enumerate the current process table through Toolhelp32. */
|
||||
function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] {
|
||||
const { PROCESSENTRY32W } = win32Structs()
|
||||
const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||
/* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API;
|
||||
the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */
|
||||
if (isInvalidHandle(snapshot)) return []
|
||||
const entries: ProcessEntry[] = []
|
||||
try {
|
||||
const entry = koffi.alloc(PROCESSENTRY32W, 1)
|
||||
koffi.encode(entry, 'uint32', PROCESSENTRY32W.size)
|
||||
let ok = bindings.process32FirstW(snapshot, entry)
|
||||
while (ok !== 0) {
|
||||
const record = koffi.decode(entry, PROCESSENTRY32W) as {
|
||||
th32ProcessID: number
|
||||
th32ParentProcessID: number
|
||||
}
|
||||
entries.push({ pid: record.th32ProcessID, parentPid: record.th32ParentProcessID })
|
||||
ok = bindings.process32NextW(snapshot, entry)
|
||||
}
|
||||
} finally {
|
||||
bindings.closeHandle(snapshot)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Read one process's creation-time identity through GetProcessTimes. */
|
||||
function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined {
|
||||
const { FILETIME } = win32Structs()
|
||||
const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid)
|
||||
if (isInvalidHandle(handle)) return undefined
|
||||
try {
|
||||
const creation = koffi.alloc(FILETIME, 1)
|
||||
const exit = koffi.alloc(FILETIME, 1)
|
||||
const kernel = koffi.alloc(FILETIME, 1)
|
||||
const user = koffi.alloc(FILETIME, 1)
|
||||
/* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and
|
||||
cannot be staged deterministically; the absent-process path is covered and the caller
|
||||
treats undefined as a detector miss. */
|
||||
if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined
|
||||
const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number }
|
||||
return `${record.dwHighDateTime}:${record.dwLowDateTime}`
|
||||
} finally {
|
||||
bindings.closeHandle(handle)
|
||||
}
|
||||
}
|
||||
|
||||
/** The koffi-backed default internals; bindings resolve lazily on first use. */
|
||||
function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals {
|
||||
return {
|
||||
snapshot: () => snapshotWindowsProcesses(win32Bindings()),
|
||||
creationTime: pid => windowsCreationTime(win32Bindings(), pid),
|
||||
taskkill: taskkillTree,
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,16 @@ import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalS
|
||||
import { childEnv } from '../src/spawn.ts'
|
||||
|
||||
function spec(command: string, overrides: Partial<SubprocessSpawnSpec> = {}): SubprocessSpawnSpec {
|
||||
// Windows has no bash; the suite's simple commands translate to node one-liners.
|
||||
const argv = process.platform === 'win32'
|
||||
? [process.execPath, '-e', {
|
||||
'echo managed': 'console.log("managed")',
|
||||
'sleep 60': 'setTimeout(() => {}, 60000)',
|
||||
'true': '',
|
||||
}[command] ?? command]
|
||||
: ['bash', '-c', command]
|
||||
return {
|
||||
argv: ['bash', '-c', command],
|
||||
argv,
|
||||
cwd: process.cwd(),
|
||||
stdio: {
|
||||
stdin: 'ignore',
|
||||
@@ -60,9 +68,9 @@ describe('LocalSubprocessService', () => {
|
||||
const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' })
|
||||
expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path'])
|
||||
expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt'])
|
||||
expect(candidates('tool', explicit)).toEqual(['/bin/tool.EXE', '/bin/tool.CMD'])
|
||||
expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')])
|
||||
expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' }))
|
||||
.toEqual(['/explicit/tool.EXE'])
|
||||
.toEqual([resolve('/explicit', 'tool.EXE')])
|
||||
expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')])
|
||||
expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4)
|
||||
await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`))
|
||||
@@ -286,7 +294,8 @@ describe('LocalSubprocessService', () => {
|
||||
const handle = ctx.subprocess.spawn(spec('sleep 60'))
|
||||
await fiber.dispose()
|
||||
const outcome = await handle.done
|
||||
expect(outcome.signal).toBe('SIGTERM')
|
||||
// Windows teardown terminates through taskkill, which reports no signal.
|
||||
expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
|
||||
it('a settled process leaves the live set (disposal does not re-kill it)', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseProcStat,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||
import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
|
||||
|
||||
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
|
||||
const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
|
||||
@@ -237,12 +238,13 @@ describe('macOS process inspector', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
|
||||
it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.setTpgid('-1')
|
||||
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
|
||||
fake.internals.exec = () => { throw new Error('gone') }
|
||||
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
|
||||
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32')
|
||||
expect(createProcessInspector('win32', 'x64', fake.internals)).toBeInstanceOf(WindowsProcessInspector)
|
||||
expect(() => createProcessInspector('freebsd', 'x64', fake.internals)).toThrow('unsupported on platform freebsd')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
childEnv,
|
||||
killGroup,
|
||||
OutputCollector,
|
||||
spawnSubprocess,
|
||||
@@ -11,6 +12,49 @@ import {
|
||||
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Translate the suite's POSIX command strings into node one-liners on Windows,
|
||||
* where no bash exists; the translated commands keep the same observable
|
||||
* stdout/stderr/exit-code contract the bash originals pin on POSIX.
|
||||
* @param command - the bash `-c` command string used by the test.
|
||||
* @returns the argv to spawn.
|
||||
*/
|
||||
function shellArgv(command: string): string[] {
|
||||
if (process.platform !== 'win32') return ['bash', '-c', command]
|
||||
const node = (script: string): string[] => [process.execPath, '-e', script]
|
||||
switch (command) {
|
||||
case 'true': return node('')
|
||||
case 'echo hello': return node('console.log("hello")')
|
||||
case 'echo hi': return node('console.log("hi")')
|
||||
case 'echo oops >&2': return node('console.error("oops")')
|
||||
case 'echo err >&2': return node('console.error("err")')
|
||||
case 'echo out; echo err >&2': return node('console.log("out"); console.error("err")')
|
||||
case 'echo out; echo to-parent >&2': return node('console.log("out"); console.error("to-parent")')
|
||||
case 'echo to-parent; echo err >&2': return node('console.log("to-parent"); console.error("err")')
|
||||
case 'exit 42': return node('process.exit(42)')
|
||||
case 'exit 7': return node('process.exit(7)')
|
||||
case 'pwd': return node('console.log(process.cwd())')
|
||||
case 'sleep 60': return node('setTimeout(() => {}, 60000)')
|
||||
case 'cat': return node('process.stdin.pipe(process.stdout)')
|
||||
case 'unused': return node('')
|
||||
case 'echo "${TERM:-unset}"': return node('console.log(process.env.TERM ?? "unset")')
|
||||
case 'echo "$EXTRA_ONE/$EXTRA_TWO"': return node('console.log(process.env.EXTRA_ONE + "/" + process.env.EXTRA_TWO)')
|
||||
case 'echo "$EXPLICIT_OVERRIDE_PASSWORD"': return node('console.log(process.env.EXPLICIT_OVERRIDE_PASSWORD)')
|
||||
case 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"': return node('console.log(process.env.SUBPROCESS_TOMBSTONE_PROBE ?? "absent")')
|
||||
case 'echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"':
|
||||
return node('console.log("[" + [process.env.DSH_STALE ?? "absent", process.env.DSH_SHELL, process.env.DSH_SESSION_ID].join("|") + "]")')
|
||||
case 'echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${SUBPROCESS_TEST_PASSWORD:-absent}|${DSH_TEST_PLAIN:-absent}]"':
|
||||
return node('console.log("[" + [process.env.DSH_TEST_API_KEY ?? "absent", process.env.DSH_TEST_TOKEN ?? "absent", process.env.SUBPROCESS_TEST_PASSWORD ?? "absent", process.env.DSH_TEST_PLAIN ?? "absent"].join("|") + "]")')
|
||||
case 'printf "%.0sx" $(seq 1 500)': return node('process.stdout.write("x".repeat(500))')
|
||||
case 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2':
|
||||
return node('process.stdout.write("x".repeat(500)); process.stderr.write("e".repeat(500))')
|
||||
case 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done':
|
||||
return node('for (let i = 1; i <= 200; i++) console.log("line-" + String(i).padStart(4, "0"))')
|
||||
default:
|
||||
throw new Error(`spawn.spec: no win32 node translation for ${JSON.stringify(command)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { failNextClose, failNextUnlink } = vi.hoisted(() => ({
|
||||
failNextClose: { value: false },
|
||||
failNextUnlink: { value: false },
|
||||
@@ -48,7 +92,7 @@ type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
|
||||
function spec(command: string, overrides: SpecOverrides = {}) {
|
||||
const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides
|
||||
return {
|
||||
argv: ['bash', '-c', command],
|
||||
argv: shellArgv(command),
|
||||
cwd: process.cwd(),
|
||||
stdio: {
|
||||
stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const,
|
||||
@@ -161,7 +205,7 @@ describe('spawnSubprocess', () => {
|
||||
expect(result.stdout.text).toBe('callers-choice\n')
|
||||
})
|
||||
|
||||
it('runs in the requested cwd', async () => {
|
||||
it.skipIf(process.platform === 'win32')('runs in the requested cwd', async () => {
|
||||
const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' })))
|
||||
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
|
||||
})
|
||||
@@ -176,11 +220,12 @@ describe('spawnSubprocess', () => {
|
||||
setTimeout(() => { controller.abort('deadline') }, 100)
|
||||
const result = await running.done
|
||||
expect(Date.now() - start).toBeLessThan(5_000)
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.exitCode).toBeNull()
|
||||
// Windows teardown terminates through taskkill, which reports no signal.
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
expect(result.exitCode).toBe(process.platform === 'win32' ? 1 : null)
|
||||
})
|
||||
|
||||
it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
it.skipIf(process.platform === 'win32')('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => {
|
||||
const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
|
||||
await waitForStdout(running, 'ready\n')
|
||||
running.terminate()
|
||||
@@ -231,12 +276,16 @@ describe('spawnSubprocess', () => {
|
||||
expect(forceSignals).toBe(0)
|
||||
} finally {
|
||||
killSpy.mockRestore()
|
||||
process.kill(helper, 'SIGKILL')
|
||||
try {
|
||||
process.kill(helper, 'SIGKILL')
|
||||
} catch {
|
||||
// taskkill already took the helper down on Windows.
|
||||
}
|
||||
await waitGone(helper)
|
||||
}
|
||||
})
|
||||
|
||||
it('terminates the whole process group (grandchildren die too)', async () => {
|
||||
it.skipIf(process.platform === 'win32')('terminates the whole process group (grandchildren die too)', async () => {
|
||||
// The subshell writes the sleep's pid then waits on it; terminating the
|
||||
// group must take the sleep down with bash.
|
||||
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
|
||||
@@ -255,7 +304,7 @@ describe('spawnSubprocess', () => {
|
||||
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort('user cancelled') }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
|
||||
it('throws when the signal is already aborted before spawn', () => {
|
||||
@@ -275,10 +324,10 @@ describe('spawnSubprocess', () => {
|
||||
running.terminate()
|
||||
running.terminate()
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
|
||||
it('does not wait for a Linux group that has only zombie members', async () => {
|
||||
it.skipIf(process.platform === 'win32')('does not wait for a Linux group that has only zombie members', async () => {
|
||||
const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`)
|
||||
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), {
|
||||
platform: 'linux',
|
||||
@@ -296,7 +345,7 @@ describe('spawnSubprocess', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds inherited-pipe draining after the shell exits', async () => {
|
||||
it.skipIf(process.platform === 'win32')('bounds inherited-pipe draining after the shell exits', async () => {
|
||||
const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`)
|
||||
const started = Date.now()
|
||||
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 }))
|
||||
@@ -328,7 +377,7 @@ describe('stdin and extra env (set by in-process plugins)', () => {
|
||||
expect(result.stdout.text).toBe('')
|
||||
})
|
||||
|
||||
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
||||
it.skipIf(process.platform === 'win32')('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
|
||||
// With no bytes, fd 0 remains the pre-spawn `ignore` default (/dev/null, a character device).
|
||||
// Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO.
|
||||
const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other')))
|
||||
@@ -600,7 +649,7 @@ describe('windows tree semantics (injected platform)', () => {
|
||||
running.terminate()
|
||||
const outcome = await running.done
|
||||
expect(killed).toContain(running.pid)
|
||||
expect(outcome.signal).toBe('SIGKILL')
|
||||
expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGKILL')
|
||||
})
|
||||
|
||||
it('waitForExit falls back to direct-child liveness where groups do not exist', async () => {
|
||||
@@ -611,7 +660,7 @@ describe('windows tree semantics (injected platform)', () => {
|
||||
})
|
||||
|
||||
describe('waitForExit', () => {
|
||||
it('waits for the whole detached tree, not just the shell', async () => {
|
||||
it.skipIf(process.platform === 'win32')('waits for the whole detached tree, not just the shell', async () => {
|
||||
const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`)
|
||||
const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
|
||||
const grandchild = await waitForPidFile(pidFile)
|
||||
@@ -631,7 +680,7 @@ describe('waitForExit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => {
|
||||
describe.skipIf(process.platform === 'win32')('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => {
|
||||
it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => {
|
||||
// The leader spawns a TERM-trapping helper with all stdio detached from
|
||||
// the collected pipes, then exits: the helper holds the GROUP alive while
|
||||
@@ -697,6 +746,94 @@ describe('coverage seams', () => {
|
||||
expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('covers the injected POSIX group paths on any host', async () => {
|
||||
// Windows has no POSIX groups, so the tree-liveness probe, group
|
||||
// signalling, and the SIGKILL escalation timer only run here through the
|
||||
// injected platform; the mock keeps the group alive through TERM and
|
||||
// terminates the direct child when the escalation tier delivers SIGKILL.
|
||||
const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), {
|
||||
platform: 'linux',
|
||||
linuxProcessGroupHasLiveMembers: () => false,
|
||||
})
|
||||
const realKill = process.kill.bind(process)
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||
if (typeof target === 'number' && target < 0) {
|
||||
if (signal === 0) return true
|
||||
if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL')
|
||||
return true
|
||||
}
|
||||
return realKill(target, signal)
|
||||
})
|
||||
try {
|
||||
running.terminate()
|
||||
await running.done
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
} finally {
|
||||
killSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a vanished group probe as quiescent without signalling', async () => {
|
||||
const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' })
|
||||
const realKill = process.kill.bind(process)
|
||||
const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
|
||||
if (typeof target === 'number' && target < 0) {
|
||||
throw Object.assign(new Error('simulated absent group'), { code: 'ESRCH' })
|
||||
}
|
||||
return realKill(target, signal)
|
||||
})
|
||||
try {
|
||||
running.terminate()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
realKill(running.pid, 'SIGKILL')
|
||||
await running.done
|
||||
await expect(running.waitForExit()).resolves.toBe(true)
|
||||
} finally {
|
||||
killSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('childEnv keeps the POSIX spread on non-Windows hosts', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
try {
|
||||
expect(childEnv({ DSH_X: '1' }).DSH_X).toBe('1')
|
||||
} finally {
|
||||
platform.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles through the pipe-drain timer when a descendant holds a collected pipe', async () => {
|
||||
// The leader spawns a detached grandchild inheriting the collected stdout
|
||||
// pipe, then exits: `close` cannot settle while the grandchild holds the
|
||||
// pipe, so the bounded pipe-drain timer must settle the outcome.
|
||||
const pidFile = join(spillDir, `pipe-drain-${Date.now()}.pid`)
|
||||
const childScript = `
|
||||
const { spawn } = require('node:child_process')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 1, 2],
|
||||
})
|
||||
writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid))
|
||||
helper.unref()
|
||||
`
|
||||
const running = spawnSubprocess({
|
||||
...spec('unused', { graceMs: 100 }),
|
||||
argv: [process.execPath, '-e', childScript],
|
||||
})
|
||||
const helper = await waitForPidFile(pidFile)
|
||||
const started = Date.now()
|
||||
const outcome = await running.done
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(90)
|
||||
try {
|
||||
process.kill(helper, 'SIGKILL')
|
||||
} catch {
|
||||
// Already gone; the drain bound is the point under test.
|
||||
}
|
||||
await waitGone(helper)
|
||||
})
|
||||
|
||||
it('a spawn-failed handle rejects done while waitForExit reports gone', async () => {
|
||||
const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' }))
|
||||
await expect(running.done).rejects.toThrow()
|
||||
@@ -833,7 +970,7 @@ describe('argv validation', () => {
|
||||
expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/)
|
||||
})
|
||||
|
||||
it('spawns argv verbatim without shell interpretation', async () => {
|
||||
it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => {
|
||||
const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] }))
|
||||
expect(result.stdout.text).toBe('$HOME')
|
||||
})
|
||||
@@ -853,7 +990,7 @@ describe('abort edge cases', () => {
|
||||
.toThrow(/aborted before spawn: aborted/)
|
||||
})
|
||||
|
||||
it('reports the terminating signal of an externally self-killed command', async () => {
|
||||
it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => {
|
||||
// spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the
|
||||
// executor's classification (a self-kill is neither) — see executor.spec.ts.
|
||||
const result = await finish(spawnSubprocess(spec('kill -TERM $$')))
|
||||
@@ -894,7 +1031,7 @@ describe('environment and spill-file hardening', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('creates spill files with owner-only permissions and random names', async () => {
|
||||
it.skipIf(process.platform === 'win32')('creates spill files with owner-only permissions and random names', async () => {
|
||||
const result = await finish(spawnSubprocess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
{ spillDir },
|
||||
@@ -905,7 +1042,7 @@ describe('environment and spill-file hardening', () => {
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
|
||||
it('defaults spills into a private per-process directory', async () => {
|
||||
it.skipIf(process.platform === 'win32')('defaults spills into a private per-process directory', async () => {
|
||||
const result = await finish(spawnSubprocess(
|
||||
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
|
||||
))
|
||||
@@ -931,6 +1068,6 @@ describe('environment and spill-file hardening', () => {
|
||||
const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal }))
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await running.done
|
||||
expect(result.signal).toBe('SIGTERM')
|
||||
expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM')
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,8 @@ class FakeInspector implements ProcessInspector {
|
||||
this.groups.push([pgid, signal])
|
||||
}
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
|
||||
// Mirrors the real inspectors' alive-gated signalling.
|
||||
if (!this.alive.has(identity.pid)) return
|
||||
if (this.throwProcess) throw new Error('process raced')
|
||||
this.processes.push([identity.pid, signal])
|
||||
if (this.removeOnSignal) this.alive.delete(identity.pid)
|
||||
@@ -81,12 +83,18 @@ class FakeInspector implements ProcessInspector {
|
||||
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle {
|
||||
// The suite pins POSIX signalling semantics deterministically on every host;
|
||||
// the win32 branches get their own platform-explicit tests below.
|
||||
return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux')
|
||||
}
|
||||
|
||||
describe('LocalTerminalHandle', () => {
|
||||
it('bridges terminal bytes, foreground control, and signalled exit facts', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.waiting = true
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
const chunks: Buffer[] = []
|
||||
handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
|
||||
@@ -107,7 +115,7 @@ describe('LocalTerminalHandle', () => {
|
||||
it('rejects unsafe foreground signals and writes after exit', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
inspector.pgid = handle.pid
|
||||
await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||||
inspector.pgid = undefined
|
||||
@@ -127,7 +135,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
|
||||
const quiescent = handle.terminate()
|
||||
expect(handle.terminate()).toBe(quiescent)
|
||||
@@ -148,7 +156,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
pty.emitExit()
|
||||
const waiting = handle.terminate()
|
||||
let settled = false
|
||||
@@ -167,7 +175,7 @@ describe('LocalTerminalHandle', () => {
|
||||
const disowned = { pid: 124, started: 'disowned' }
|
||||
inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : []
|
||||
inspector.alive.add(124)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
|
||||
pty.emitExit()
|
||||
|
||||
@@ -181,7 +189,7 @@ describe('LocalTerminalHandle', () => {
|
||||
const descendant = { pid: 124, started: 'observed' }
|
||||
inspector.members = [descendant]
|
||||
inspector.alive.add(descendant.pid)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
|
||||
await handle.inspectForeground()
|
||||
inspector.members = []
|
||||
@@ -194,7 +202,7 @@ describe('LocalTerminalHandle', () => {
|
||||
it('does not adopt the children of a recycled shell pid', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
pty.emitExit()
|
||||
const imposterChild = { pid: 999, started: 'imposter-child' }
|
||||
@@ -213,7 +221,7 @@ describe('LocalTerminalHandle', () => {
|
||||
const orphan = { pid: 321, started: 'unverifiable' }
|
||||
inspector.members = [orphan]
|
||||
inspector.alive.add(orphan.pid)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([])
|
||||
@@ -238,7 +246,7 @@ describe('LocalTerminalHandle', () => {
|
||||
}
|
||||
return []
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(pty.kills).toEqual(['SIGTERM'])
|
||||
@@ -252,7 +260,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.sessionMembers = [late]
|
||||
inspector.alive.add(late.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
await handle.terminate()
|
||||
|
||||
@@ -270,7 +278,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.sessionMembers = [late]
|
||||
inspector.alive.add(late.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10)
|
||||
const handle = makeHandle(pty, inspector, 10)
|
||||
|
||||
const first = handle.terminate()
|
||||
const failed = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
@@ -297,7 +305,7 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.processes.push([identity.pid, signal])
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20)
|
||||
const handle = makeHandle(pty, inspector, 20)
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await quiescent
|
||||
@@ -308,7 +316,7 @@ describe('LocalTerminalHandle', () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
pty.autoExitOnKill = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10)
|
||||
const handle = makeHandle(pty, new FakeInspector(), 10)
|
||||
const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
@@ -326,7 +334,98 @@ describe('LocalTerminalHandle', () => {
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.throwProcess = true
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1)
|
||||
const handle = makeHandle(pty, inspector, 1)
|
||||
await expect(handle.terminate()).rejects.toThrow('surviving pids: 124')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalTerminalHandle on Windows', () => {
|
||||
const win32 = 'win32' as NodeJS.Platform
|
||||
|
||||
it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
await expect(handle.signalForeground('SIGINT')).resolves.toBe(456)
|
||||
expect(pty.writes).toEqual(['\x03'])
|
||||
expect(inspector.groups).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => {
|
||||
const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32)
|
||||
await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows')
|
||||
await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows')
|
||||
})
|
||||
|
||||
it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456)
|
||||
expect(inspector.groups).toEqual([[456, 'SIGTERM']])
|
||||
expect(pty.writes).toEqual([])
|
||||
})
|
||||
|
||||
it('still refuses to SIGKILL the terminal shell on Windows', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
inspector.pgid = handle.pid
|
||||
await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||||
})
|
||||
|
||||
it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.alive.add(123)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
const quiescent = handle.terminate()
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
expect(inspector.processes).toEqual([[123, 'SIGTERM']])
|
||||
expect(pty.kills).toEqual([])
|
||||
|
||||
pty.emitExit()
|
||||
await quiescent
|
||||
expect(inspector.processes).toEqual([[123, 'SIGTERM']])
|
||||
expect(pty.kills).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a shell that survives both taskkill tiers', async () => {
|
||||
vi.useFakeTimers()
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.alive.add(123)
|
||||
inspector.removeOnSignal = false
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await failed
|
||||
expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']])
|
||||
expect(pty.kills).toEqual([])
|
||||
|
||||
pty.emitExit()
|
||||
await handle.terminate()
|
||||
})
|
||||
|
||||
it('skips taskkill escalation entirely when the shell already exited', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.alive.add(123)
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
pty.emitExit()
|
||||
await handle.terminate()
|
||||
expect(inspector.processes).toEqual([])
|
||||
expect(pty.kills).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the bare node-pty kill when the shell identity was never observable', async () => {
|
||||
const pty = new FakePty()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.root = undefined
|
||||
const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32)
|
||||
await handle.terminate()
|
||||
expect(pty.kills).toHaveLength(1)
|
||||
expect(inspector.processes).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createWindowsProcessInspector,
|
||||
isInvalidHandle,
|
||||
windowsProcessTree,
|
||||
WindowsProcessInspector,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
|
||||
import type {
|
||||
NativePtr,
|
||||
ProcessEntry,
|
||||
WindowsProcessInspectorInternals,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
|
||||
|
||||
function fakeInternals() {
|
||||
const entries: ProcessEntry[] = []
|
||||
const times = new Map<number, string>()
|
||||
const kills: Array<[number, boolean]> = []
|
||||
return {
|
||||
internals: {
|
||||
snapshot: () => [...entries],
|
||||
creationTime: pid => times.get(pid),
|
||||
taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) },
|
||||
} satisfies WindowsProcessInspectorInternals,
|
||||
add(entry: ProcessEntry, started?: string): void {
|
||||
entries.push(entry)
|
||||
if (started !== undefined) times.set(entry.pid, started)
|
||||
},
|
||||
kills,
|
||||
}
|
||||
}
|
||||
|
||||
describe('windowsProcessTree', () => {
|
||||
it('walks a table children-first with readable identities only', () => {
|
||||
const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}`
|
||||
expect(windowsProcessTree([
|
||||
{ pid: 10, parentPid: 0 },
|
||||
{ pid: 11, parentPid: 10 },
|
||||
{ pid: 12, parentPid: 11 },
|
||||
{ pid: 13, parentPid: 11 },
|
||||
{ pid: 14, parentPid: 10 },
|
||||
], 10, started)).toEqual([
|
||||
{ pid: 13, started: 't13' },
|
||||
{ pid: 11, started: 't11' },
|
||||
{ pid: 14, started: 't14' },
|
||||
{ pid: 10, started: 't10' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty walk for an absent root', () => {
|
||||
expect(windowsProcessTree([{ pid: 10, parentPid: 0 }], 99, () => 't')).toEqual([])
|
||||
})
|
||||
|
||||
it('terminates on a parent cycle instead of recursing forever', () => {
|
||||
const entries = [
|
||||
{ pid: 10, parentPid: 11 },
|
||||
{ pid: 11, parentPid: 10 },
|
||||
]
|
||||
expect(windowsProcessTree(entries, 10, () => 't')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WindowsProcessInspector (injected internals)', () => {
|
||||
it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => {
|
||||
const fake = fakeInternals()
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
expect(inspector.foregroundPgid(77)).toBe(77)
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
expect(inspector.processSession(77)).toEqual([])
|
||||
})
|
||||
|
||||
it('delegates tree walks and identity checks to the internals', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.add({ pid: 10, parentPid: 0 }, 't10')
|
||||
fake.add({ pid: 11, parentPid: 10 }, 't11')
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 11, started: 't11' },
|
||||
{ pid: 10, started: 't10' },
|
||||
])
|
||||
expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true)
|
||||
expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false)
|
||||
expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false)
|
||||
})
|
||||
|
||||
it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => {
|
||||
const fake = fakeInternals()
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
inspector.signalGroup(77, 'SIGKILL')
|
||||
inspector.signalGroup(77, 'SIGTERM')
|
||||
inspector.signalGroup(0, 'SIGKILL')
|
||||
expect(fake.kills).toEqual([[77, true], [77, false], [0, true]])
|
||||
})
|
||||
|
||||
it('signals a process only while its start identity matches', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.add({ pid: 10, parentPid: 0 }, 't10')
|
||||
const inspector = new WindowsProcessInspector(fake.internals)
|
||||
inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL')
|
||||
inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM')
|
||||
expect(fake.kills).toEqual([[10, true]])
|
||||
})
|
||||
|
||||
it('accepts an injected internals factory through the creator', () => {
|
||||
const fake = fakeInternals()
|
||||
expect(createWindowsProcessInspector(fake.internals)).toBeInstanceOf(WindowsProcessInspector)
|
||||
expect(createWindowsProcessInspector()).toBeInstanceOf(WindowsProcessInspector)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInvalidHandle', () => {
|
||||
it('rejects null, zero, and the all-ones INVALID_HANDLE_VALUE forms', () => {
|
||||
const ptr = (value: bigint): NativePtr => value as NativePtr
|
||||
expect(isInvalidHandle(null)).toBe(true)
|
||||
expect(isInvalidHandle(undefined)).toBe(true)
|
||||
expect(isInvalidHandle(ptr(0n))).toBe(true)
|
||||
expect(isInvalidHandle(ptr(0xFFFFFFFFFFFFFFFFn))).toBe(true)
|
||||
expect(isInvalidHandle(ptr(-1n))).toBe(true)
|
||||
expect(isInvalidHandle(ptr(1234n))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
const win32 = process.platform === 'win32' ? describe : describe.skip
|
||||
|
||||
win32('WindowsProcessInspector over the real koffi bindings', () => {
|
||||
it('walks the live process table from the test runner itself', () => {
|
||||
const inspector = createWindowsProcessInspector()
|
||||
const tree = inspector.processTree(process.pid)
|
||||
const self = tree.find(member => member.pid === process.pid)
|
||||
expect(self).toBeDefined()
|
||||
expect(inspector.isAlive(self!)).toBe(true)
|
||||
expect(inspector.foregroundPgid(process.pid)).toBe(process.pid)
|
||||
})
|
||||
|
||||
it('reports unreadable identities for absent processes and no-ops tree signalling', () => {
|
||||
const inspector = createWindowsProcessInspector()
|
||||
expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false)
|
||||
expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGKILL')).not.toThrow()
|
||||
expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGTERM')).not.toThrow()
|
||||
expect(() => inspector.signalGroup(0, 'SIGKILL')).not.toThrow()
|
||||
expect(() => inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL')).not.toThrow()
|
||||
})
|
||||
})
|
||||
Generated
+6
@@ -5478,6 +5478,9 @@ importers:
|
||||
|
||||
packages/pty/pty-local:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-pwsh-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../bash/pwsh-local
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
@@ -7085,6 +7088,9 @@ importers:
|
||||
|
||||
packages/subprocess/subprocess-local:
|
||||
dependencies:
|
||||
koffi:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.1
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6)
|
||||
|
||||
+9
-6
@@ -25,23 +25,26 @@ const windowsUnsupportedPackages = process.platform === 'win32'
|
||||
// INCLUDED: PowerShell ships with Windows, so they run natively here.
|
||||
// This explicit list (not a 'packages/bash/*' glob) keeps
|
||||
// packages/bash/bash — the Service Definition package — running on Windows.
|
||||
// subprocess-local and pty-local are NOT listed: their win32 branches are
|
||||
// first-class (Windows inspector, pwsh dialect), so their suites run and
|
||||
// their sources stay coverage-required on the windows-native lane; the
|
||||
// bash-requiring tests inside them self-skip through hasBash probes.
|
||||
'packages/bash/bash-local',
|
||||
'packages/bash/bash-sandbox',
|
||||
'packages/bash/tool-bash',
|
||||
'packages/hooks/*',
|
||||
'packages/subprocess/*',
|
||||
'packages/pty/pty-local',
|
||||
'packages/sandbox/sandbox-local',
|
||||
]
|
||||
: []
|
||||
|
||||
// Windows-only packages: their sources execute exclusively on win32 (koffi
|
||||
// loads Win32 libraries), so the Linux coverage lane can never cover them.
|
||||
// The Windows dev/CI lane exercises them through the probe/runner suites; the
|
||||
// per-file 100% gate must not fail on their Linux-uncovered paths.
|
||||
// Windows-only sources: they execute exclusively on win32 (koffi loads Win32
|
||||
// libraries), so the Linux coverage lane can never cover them. The Windows
|
||||
// dev/CI lane exercises them through the probe/runner suites; the per-file
|
||||
// 100% gate must not fail on their Linux-uncovered paths.
|
||||
const windowsOnlyCoverageExclusions = process.platform !== 'win32'
|
||||
? [
|
||||
'packages/sandbox/sandbox-windows-acl/src/**/*.ts',
|
||||
'packages/subprocess/subprocess-local/src/windows-inspector.ts',
|
||||
]
|
||||
: []
|
||||
|
||||
|
||||
Reference in new issue
Block a user