fix(rebase): migrate the replayed stack onto current master APIs
The linear replay carried each commit's own lineage, so this checkpoint restores the master-owned surfaces the conflicted regions clobbered and migrates branch-owned code to master's post-rebase APIs: - rebuild subprocess-local spawn.ts on master's tree-exit-observer machinery, keeping the branch's win32 childEnv key semantics and the Linux zombie-quiescence probe; the zombie test reaps its survivor directly since a confirmed-absent verdict is a permanent no-more-signals boundary - migrate pty-local test stubs to the Inbox-model Agent interface, Session.create, runnerFailureRules, and the new turn/start payload - implement the seam's resolveExecutable/spawnTerminal abstracts in the new pwsh-local and tool-fs-search test fakes - restore code-runtime, atomic-write, pwsh-local, and app-boot to master's exact content (the net-zero code-runtime churn is pruned from this history) and drop rename-detection graft debris - re-apply the PR's architecture rows and execution-world paragraph, re-record bilingual pairings, regenerate catalogs, and reconcile the lockfile
This commit is contained in:
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as sleepMs } from 'node:timers/promises'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type {
|
||||
CollectedOutput,
|
||||
SubprocessCollect,
|
||||
@@ -27,16 +28,16 @@ import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
|
||||
|
||||
/**
|
||||
* Build a child environment: explicit caller entries override the scrubbed
|
||||
* parent base using the target platform's environment-key semantics, so a
|
||||
* deliberately supplied credential or current `DSH_*` fact wins over the
|
||||
* scrub that dropped its ambient namesake.
|
||||
* @param extra - explicit caller entries merged after the scrubbed parent.
|
||||
* parent base using the target platform's environment-key semantics. A string
|
||||
* deliberately restores or overrides an entry; an explicit `undefined`
|
||||
* tombstone removes an ordinary ambient entry.
|
||||
* @param extra - explicit caller entries and tombstones, merged after the scrub.
|
||||
* @returns the environment to hand to `spawn` for the child process.
|
||||
*/
|
||||
export function childEnv(extra?: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
||||
export function childEnv(extra?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv {
|
||||
const env = scrubbedParentEnv()
|
||||
if (process.platform !== 'win32') return { ...env, ...extra }
|
||||
let entries = Object.entries(env)
|
||||
let entries: [string, string | undefined][] = Object.entries(env)
|
||||
for (const [key, value] of Object.entries(extra ?? {})) {
|
||||
const normalized = key.toUpperCase()
|
||||
entries = entries.filter(([inherited]) => inherited.toUpperCase() !== normalized)
|
||||
@@ -310,8 +311,12 @@ function signalTree(
|
||||
* @param spec - fully resolved argv, cwd, stdio, grace, cancellation, environment.
|
||||
* @param internals - test-only spill-directory, platform, and taskkill overrides.
|
||||
* @returns live subprocess handle.
|
||||
* @throws when `graceMs` cannot be represented by one Node timer.
|
||||
*/
|
||||
export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInternals = {}): SubprocessHandle {
|
||||
if (!Number.isFinite(spec.graceMs) || spec.graceMs <= 0 || spec.graceMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`subprocess graceMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||
const platform = internals.platform ?? process.platform
|
||||
const taskkill = internals.taskkill ?? taskkillProcessTree
|
||||
@@ -354,7 +359,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
const stdoutCollector = collectStream(outMode, child.stdout, 'stdout')
|
||||
const stderrCollector = collectStream(errMode, child.stderr, 'stderr')
|
||||
|
||||
let graceTimer: NodeJS.Timeout | undefined
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let treeExitObserved = false
|
||||
let treeExitObservation: Promise<void> | undefined
|
||||
let settled = false
|
||||
|
||||
// Failed spawns use pid -1 so signalling remains a no-op.
|
||||
@@ -362,6 +369,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
|
||||
/** Whether the detached tree's root (or POSIX group) is still alive. */
|
||||
const treeAlive = (): boolean => {
|
||||
/* v8 ignore next -- only a timer callback already queued when the observer settles can enter here;
|
||||
the guard is the final defense against probing an id after its tree was confirmed absent. */
|
||||
if (treeExitObserved) return false
|
||||
if (pid <= 0) return false
|
||||
if (platform === 'win32') {
|
||||
// Windows has no group-liveness probe; the direct child's exit is the
|
||||
@@ -389,19 +399,40 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or reuse the handle's single whole-tree exit observer. The first
|
||||
* confirmed absence is a permanent no-more-signals boundary: it cancels a
|
||||
* pending escalation before this process-group id can be reused.
|
||||
*/
|
||||
const observeTreeExit = (): Promise<void> => {
|
||||
treeExitObservation ??= (async () => {
|
||||
while (treeAlive()) await sleepTick()
|
||||
treeExitObserved = true
|
||||
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
||||
graceTimer = undefined
|
||||
})()
|
||||
return treeExitObservation
|
||||
}
|
||||
|
||||
// The escalation's tier primitive (not on the handle — terminate() is the
|
||||
// only consumer-facing termination verb). Guards on TREE liveness, not
|
||||
// outcome settlement: a TERM-trapping helper can outlive the settled direct
|
||||
// child and must stay signalable, while a fully-dead tree (possible pid
|
||||
// reuse) must not be re-signalled by a later tier.
|
||||
const kill = (sig: NodeJS.Signals): void => {
|
||||
/* v8 ignore next -- the shared exit observer cancels the ordinary dead-tree timer;
|
||||
this remains the timer/death race guard and cannot be staged deterministically. */
|
||||
if (!treeAlive()) return
|
||||
signalTree(platform, pid, sig, child, taskkill)
|
||||
}
|
||||
|
||||
const terminate = (): void => {
|
||||
if (graceTimer !== undefined) return // escalation already in flight
|
||||
if (!treeAlive()) return
|
||||
if (treeExitObserved || graceTimer !== undefined) return
|
||||
// Observe from the first termination tier onward, even when inherited
|
||||
// pipes delay `done` and no consumer has begun its own teardown wait.
|
||||
void observeTreeExit()
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await.
|
||||
if (treeExitObserved) return
|
||||
kill('SIGTERM')
|
||||
// The escalation must survive direct-child settlement — the leader dying
|
||||
// does not mean the tree died — so settle does not clear this timer, and
|
||||
@@ -423,7 +454,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
}
|
||||
|
||||
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
|
||||
let pipeDrainTimer: NodeJS.Timeout | undefined
|
||||
let pipeDrainTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
@@ -446,7 +477,9 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
// A surviving descendant that inherited a pipe must not hold the
|
||||
// outcome open indefinitely: after exit, the same bounded grace that
|
||||
// governs kills also bounds the close wait.
|
||||
pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs)
|
||||
pipeDrainTimer = setTimeout(() => {
|
||||
settle(exitCode, signal)
|
||||
}, spec.graceMs)
|
||||
})
|
||||
child.on('close', settle)
|
||||
function cleanup(): void {
|
||||
@@ -458,11 +491,23 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
|
||||
})
|
||||
|
||||
const waitForExit = async (signal?: AbortSignal): Promise<boolean> => {
|
||||
while (treeAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await sleepTick()
|
||||
const observed = observeTreeExit()
|
||||
if (treeExitObserved) return true
|
||||
if (signal?.aborted) return false
|
||||
if (signal === undefined) {
|
||||
await observed
|
||||
return true
|
||||
}
|
||||
const aborted = Promise.withResolvers<boolean>()
|
||||
const onAbort = (): void => { aborted.resolve(false) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
/* v8 ignore next -- closes the event-loop race between the preceding aborted check and listener registration. */
|
||||
if (signal.aborted) onAbort()
|
||||
try {
|
||||
return await Promise.race([observed.then(() => true), aborted.promise])
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user