@gobing-ai/ts-runtime 0.4.57 → 0.4.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -598,17 +598,41 @@ unsub();
598
598
  ```
599
599
 
600
600
  `rejectOnError: true` throws on non-zero exits. `OutputPolicy` controls
601
- buffered vs streamed output. `ProcessOptions` supports timeout, env, cwd,
601
+ buffered vs streamed output. `ProcessOptions` supports `timeout`, env, cwd,
602
602
  maxOutput, forceBuffered, an optional synchronous `onOutput({ stream, chunk,
603
603
  timestamp })` observer, and registry fields (`source`, `teamId`, `agentId`).
604
604
  `onOutput` observes live chunks while the returned `ProcessResult` retains the
605
605
  complete buffered stdout/stderr; observers must enqueue and return, and thrown
606
- observer errors are isolated from the child. When `signal` is supplied, one-shot
607
- commands run in an isolated process group on Unix so abort terminates descendants
608
- that could otherwise retain the output pipes; Windows falls back to direct-child
609
- cancellation. Inject the same `ProcessRegistry` into every executor that should
606
+ observer errors are isolated from the child. Inject the same `ProcessRegistry` into every executor that should
610
607
  appear in one watch list; without a registry, behavior is unchanged.
611
608
 
609
+ #### Deadlines, cancellation, and process-group containment (Unix)
610
+
611
+ On Unix, a finite `timeout` or a supplied `signal` puts the run under executor-owned
612
+ process-group containment: the child is spawned detached as a process-group leader, the
613
+ executor runs one termination sequence (group `SIGTERM`, a `killGraceMs` grace — default
614
+ 5000 ms — then group `SIGKILL`), and escalation is driven by group liveness, never by the
615
+ leader exiting first. Completion therefore reaps descendants that survive the leader while
616
+ holding inherited output pipes or a SQLite write transaction, so `run()` completion is a
617
+ containment barrier: after it resolves, no owned descendant remains (best-effort for
618
+ SIGKILL-immune members — escalation gives up after a bounded settle). `runStreaming` keeps
619
+ direct-child semantics and provides no group containment.
620
+
621
+ - `timeout: number` — positive-integer deadline in ms; expiry reports `outcome: 'timeout'`.
622
+ - `timeout: null` — unlimited; overrides a finite `defaultTimeout` from `ProcessExecutorConfig`.
623
+ - omitted — inherits `defaultTimeout` (which may itself be `null`/unlimited).
624
+ - `timeout: 0`, negative, fractional, `NaN`, `±Infinity`, or beyond the native timer range —
625
+ rejected with `TypeError` before spawn; `killGraceMs` must be a non-negative integer.
626
+ - abort `signal` — reports `outcome: 'cancelled'`; a deadline is never a cancellation and
627
+ vice versa. Deadline and abort feed one escalation path, so callers must not arm a
628
+ competing watchdog over the same child.
629
+
630
+ `ProcessResult.outcome` (`'exit' | 'timeout' | 'cancelled' | 'signal' | 'error'`, aliased as
631
+ `ProcessExitReason`) states why the run ended; `process.exited` events carry the same reason.
632
+ On Windows (and when no deadline or signal is supplied), execa's direct-child timeout and
633
+ cancellation semantics apply unchanged: only the direct child is killed, and the outcome is
634
+ classified from the result.
635
+
612
636
  Cloudflare Workers do not expose process execution; check
613
637
  `factory.capabilities.hasProcessExecution` first.
614
638
 
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { createNodeFileSystem, findProjectRoot } from './file-system-node';
8
8
  export { atomicWriteFile, atomicWriteJson, createLogStream, ensureDirForFile, readJsonFile, walkDir, writeJsonFile, } from './fs';
9
9
  export * from './path';
10
10
  export { _resetRuntimeFactory, isCloudflareWorkerRuntime, loadRuntimeFactory } from './platform';
11
- export type { OutputPolicy, PipeProcess, PipeProcessOptions, ProcessEventDetail, ProcessEventSink, ProcessEvents, ProcessExecutorConfig, ProcessExitReason, ProcessOptions, ProcessOutputChunk, ProcessResult, ProcessSignal, TracerPort, } from './process-executor';
11
+ export type { OutputPolicy, PipeProcess, PipeProcessOptions, ProcessEventDetail, ProcessEventSink, ProcessEvents, ProcessExecutorConfig, ProcessExitReason, ProcessOptions, ProcessOutcome, ProcessOutputChunk, ProcessResult, ProcessSignal, TracerPort, } from './process-executor';
12
12
  export { NodeProcessExecutor, ProcessExecutor } from './process-executor';
13
13
  export type { InMemoryProcessRegistryOptions, ProcessExecution, ProcessExecutionBegin, ProcessExecutionComplete, ProcessExecutionFilter, ProcessExecutionSource, ProcessExecutionStatus, ProcessRegistry, ProcessRegistryEvent, } from './process-registry';
14
14
  export { createInMemoryProcessRegistry, InMemoryProcessRegistry } from './process-registry';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,+BAA+B,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAC9E,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,EACH,eAAe,EACf,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,OAAO,EACP,aAAa,GAChB,MAAM,MAAM,CAAC;AACd,cAAc,QAAQ,CAAC;AACvB,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACjG,YAAY,EACR,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,EACjB,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,aAAa,EACb,UAAU,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC1E,YAAY,EACR,8BAA8B,EAC9B,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,sBAAsB,EACtB,sBAAsB,EACtB,eAAe,EACf,oBAAoB,GACvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,6BAA6B,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AAC5F,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AACxD,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC1E,YAAY,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,cAAc,qBAAqB,CAAC;AACpC,cAAc,SAAS,CAAC;AAIxB,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAEnF;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,sBAAsB,CAAC,CAAC;AAE3G;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,qBAAqB,CAAC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,OAAO,EAAE,+BAA+B,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAC9E,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,EACH,eAAe,EACf,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,YAAY,EACZ,OAAO,EACP,aAAa,GAChB,MAAM,MAAM,CAAC;AACd,cAAc,QAAQ,CAAC;AACvB,OAAO,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACjG,YAAY,EACR,YAAY,EACZ,WAAW,EACX,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,aAAa,EACb,qBAAqB,EACrB,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,aAAa,EACb,UAAU,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC1E,YAAY,EACR,8BAA8B,EAC9B,gBAAgB,EAChB,qBAAqB,EACrB,wBAAwB,EACxB,sBAAsB,EACtB,sBAAsB,EACtB,sBAAsB,EACtB,eAAe,EACf,oBAAoB,GACvB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,6BAA6B,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AAC5F,OAAO,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AACxD,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAC1E,YAAY,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACtD,cAAc,qBAAqB,CAAC;AACpC,cAAc,SAAS,CAAC;AAIxB,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAEnF;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,sBAAsB,CAAC,CAAC;AAE3G;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,qBAAqB,CAAC,CAAC"}
@@ -24,7 +24,11 @@ export type OutputPolicy = {
24
24
  };
25
25
  /** Shared configuration for a process executor (default timeout, output buffering, output policy). */
26
26
  export interface ProcessExecutorConfig {
27
- defaultTimeout?: number;
27
+ /**
28
+ * Default execution deadline inherited by runs that omit `timeout`. `null` makes the
29
+ * default policy unlimited; value validation follows `ProcessOptions.timeout`.
30
+ */
31
+ defaultTimeout?: number | null;
28
32
  defaultMaxOutput?: number;
29
33
  output?: OutputPolicy;
30
34
  events?: ProcessEventSink;
@@ -55,7 +59,23 @@ export interface ProcessOptions {
55
59
  * `run` and `runStreaming` share one contract.
56
60
  */
57
61
  envMode?: 'merge' | 'replace';
58
- timeout?: number;
62
+ /**
63
+ * Execution deadline in milliseconds. Omitted inherits
64
+ * {@link ProcessExecutorConfig.defaultTimeout}; explicit `null` runs without a deadline
65
+ * (a supplied `signal` still cancels). On Unix, a finite deadline activates owned
66
+ * process-group containment: the executor arms its own timer — callers must not race a
67
+ * second watchdog — and expiry reaps the whole group with SIGTERM-to-SIGKILL escalation.
68
+ * Zero, negative, fractional, non-finite, or beyond-timer-range values are rejected
69
+ * before spawn; `null` replaces the previous `0`-disables behavior.
70
+ */
71
+ timeout?: number | null;
72
+ /**
73
+ * Grace in milliseconds between the owned group SIGTERM and the SIGKILL escalation
74
+ * (Unix group containment; see {@link ProcessOptions.timeout}). `0` escalates
75
+ * immediately; omitted defaults to 5000 ms. Out-of-range values are rejected before
76
+ * spawn. Without a finite deadline or `signal`, this option has no effect.
77
+ */
78
+ killGraceMs?: number;
59
79
  maxOutput?: number;
60
80
  label?: string;
61
81
  rejectOnError?: boolean;
@@ -98,6 +118,12 @@ export interface ProcessOutputChunk {
98
118
  readonly chunk: string;
99
119
  readonly timestamp: string;
100
120
  }
121
+ /**
122
+ * Terminal classification of how a run ended: normal `exit`, deadline `timeout`, external
123
+ * `cancelled` abort, termination by an outside `signal`, or `error` (spawn/runtime failure).
124
+ * Reaping surviving owned descendants after a natural leader exit keeps the outcome `exit`.
125
+ */
126
+ export type ProcessOutcome = 'exit' | 'timeout' | 'cancelled' | 'signal' | 'error';
101
127
  /** Result of a completed child process, including exit code, captured output, and duration. */
102
128
  export interface ProcessResult {
103
129
  command: string;
@@ -107,9 +133,15 @@ export interface ProcessResult {
107
133
  stderr: string;
108
134
  signal?: string;
109
135
  durationMs: number;
136
+ /**
137
+ * How the run terminated, distinguishing deadline expiry from external cancellation,
138
+ * an outside signal, runtime failure, and normal completion. Optional for backward
139
+ * compatibility with hand-built results; `NodeProcessExecutor` always sets it.
140
+ */
141
+ outcome?: ProcessOutcome;
110
142
  }
111
- /** Reason a process completion event was emitted. */
112
- export type ProcessExitReason = 'exit' | 'signal' | 'timeout' | 'error';
143
+ /** Reason a process completion event was emitted (mirrors {@link ProcessOutcome}). */
144
+ export type ProcessExitReason = ProcessOutcome;
113
145
  /** Payload emitted for process execution observability. */
114
146
  export interface ProcessEventDetail {
115
147
  command: string;
@@ -1 +1 @@
1
- {"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"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"}
@@ -4,7 +4,7 @@ import { getProcessEnv } from './config.js';
4
4
  function processEventSeverity(reason, exitCode) {
5
5
  if (reason === 'error')
6
6
  return 'error';
7
- if (reason === 'timeout')
7
+ if (reason === 'timeout' || reason === 'cancelled')
8
8
  return 'warning';
9
9
  if (exitCode !== null && exitCode !== 0)
10
10
  return 'warning';
@@ -48,15 +48,25 @@ export class NodeProcessExecutor {
48
48
  }
49
49
  async runUntraced(options) {
50
50
  const args = options.args ?? [];
51
+ // Validate before spawn and before any registry/event side effects: an invalid
52
+ // deadline or grace is caller configuration error, not a process outcome.
53
+ const deadline = resolveDeadline(options.timeout, this.config.defaultTimeout);
54
+ const killGraceMs = resolveKillGraceMs(options.killGraceMs);
55
+ // Unix group containment activates on a finite deadline or a caller abort signal:
56
+ // the executor then owns group isolation, SIGTERM, escalation, output settlement
57
+ // and the final outcome, so callers must not arm a competing watchdog. Other
58
+ // platforms keep execa's direct-child timeout/cancellation semantics explicit.
59
+ const groupOwned = process.platform !== 'win32' && (typeof deadline === 'number' || options.signal !== undefined);
51
60
  const execaOptions = buildExecaOptions({
52
61
  cwd: options.cwd ?? this.config.paths?.cwd,
53
62
  env: options.env,
54
- timeout: options.timeout ?? this.config.defaultTimeout,
63
+ timeout: typeof deadline === 'number' ? deadline : undefined,
55
64
  maxOutput: options.maxOutput ?? this.config.defaultMaxOutput,
56
65
  rejectOnError: options.rejectOnError ?? false,
57
66
  outputPolicy: this.config.output,
58
67
  forceBuffered: options.forceBuffered ?? false,
59
68
  signal: options.signal,
69
+ groupOwned,
60
70
  });
61
71
  const startedAt = Date.now();
62
72
  const startedIso = new Date(startedAt).toISOString();
@@ -77,8 +87,17 @@ export class NodeProcessExecutor {
77
87
  severity: 'info',
78
88
  ...(options.label !== undefined ? { label: options.label } : {}),
79
89
  });
90
+ let ownership;
80
91
  try {
81
92
  const subprocess = execa(options.command, args, execaOptions);
93
+ if (groupOwned && subprocess.pid !== undefined) {
94
+ ownership = ownProcessGroupLifecycle({
95
+ pid: subprocess.pid,
96
+ deadlineMs: typeof deadline === 'number' ? deadline : undefined,
97
+ signal: options.signal,
98
+ graceMs: killGraceMs,
99
+ });
100
+ }
82
101
  // execa@9's resolved Result carries no pid, but the live handle does.
83
102
  // Publish it while the child is running so observers can identify the
84
103
  // subprocess, and record it against the registry entry opened above.
@@ -93,10 +112,14 @@ export class NodeProcessExecutor {
93
112
  }
94
113
  }
95
114
  }
96
- const stopGroupCancellation = observeProcessGroupCancellation(subprocess, options.signal);
115
+ const stopOwnedTermination = () => ownership?.stop();
97
116
  observeOutput(subprocess.stdout, 'stdout', options.onOutput);
98
117
  observeOutput(subprocess.stderr, 'stderr', options.onOutput);
99
- const result = await subprocess.finally(stopGroupCancellation);
118
+ const result = await subprocess.finally(stopOwnedTermination);
119
+ // Settle the owned termination path before reporting: a deadline or abort that
120
+ // fired keeps escalating until the group is empty, and a natural leader exit
121
+ // reaps surviving owned descendants so completion cannot leak pipes or locks.
122
+ const ownedOutcome = ownership !== undefined ? await ownership.finish() : undefined;
100
123
  const processResult = {
101
124
  command: options.command,
102
125
  args,
@@ -105,6 +128,7 @@ export class NodeProcessExecutor {
105
128
  stderr: asString(result.stderr),
106
129
  ...(result.signalDescription !== undefined ? { signal: result.signalDescription } : {}),
107
130
  durationMs: result.durationMs,
131
+ outcome: ownedOutcome ?? (result.signalDescription !== undefined ? 'signal' : 'exit'),
108
132
  };
109
133
  this.completeRegistry(registryId, processResult.exitCode);
110
134
  this.emitExitedFromResult(options, processResult, result);
@@ -112,6 +136,9 @@ export class NodeProcessExecutor {
112
136
  }
113
137
  catch (error) {
114
138
  const failed = error;
139
+ // Termination containment settles before the failure is reported — and before
140
+ // rejectOnError rethrows — so a rejected run never leaks owned descendants.
141
+ const ownedOutcome = ownership !== undefined ? await ownership.finish() : undefined;
115
142
  const processResult = {
116
143
  command: options.command,
117
144
  args,
@@ -124,6 +151,7 @@ export class NodeProcessExecutor {
124
151
  ? { signal: failed.signal }
125
152
  : {}),
126
153
  durationMs: failed.durationMs ?? Date.now() - startedAt,
154
+ outcome: ownedOutcome ?? classifyFailedCompletion(failed),
127
155
  };
128
156
  this.completeRegistry(registryId, processResult.exitCode);
129
157
  this.emitExitedFromResult(options, processResult, error, error);
@@ -227,13 +255,8 @@ export class NodeProcessExecutor {
227
255
  });
228
256
  }
229
257
  emitExitedFromResult(options, result, completion, error) {
230
- const reason = isTimedOut(completion)
231
- ? 'timeout'
232
- : result.signal !== undefined
233
- ? 'signal'
234
- : error
235
- ? 'error'
236
- : 'exit';
258
+ const reason = result.outcome ??
259
+ (isTimedOut(completion) ? 'timeout' : result.signal !== undefined ? 'signal' : error ? 'error' : 'exit');
237
260
  this.emitProcessEvent('process.exited', {
238
261
  command: result.command,
239
262
  args: result.args,
@@ -401,37 +424,158 @@ function buildExecaOptions(opts) {
401
424
  : { all: true }),
402
425
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
403
426
  ...(opts.env !== undefined ? { env: opts.env } : {}),
404
- ...(opts.timeout !== undefined ? { timeout: opts.timeout } : {}),
427
+ ...(opts.timeout !== undefined && !opts.groupOwned ? { timeout: opts.timeout } : {}),
405
428
  ...(opts.maxOutput !== undefined ? { maxBuffer: opts.maxOutput } : {}),
406
- ...(opts.signal !== undefined
407
- ? {
408
- cancelSignal: opts.signal,
409
- // Node's negative-pid group signal requires the child to lead
410
- // a distinct process group. Windows has no equivalent.
411
- ...(process.platform !== 'win32' ? { detached: true } : {}),
412
- }
413
- : {}),
429
+ // Group-owned runs make the child lead a distinct process group so negative-pid
430
+ // signaling can reach the whole tree; termination itself runs through the
431
+ // executor's own escalation path (deadline and abort feed one sequence).
432
+ // Platforms without Unix group semantics cancel the direct child via execa.
433
+ ...(opts.groupOwned ? { detached: true } : {}),
434
+ ...(opts.signal !== undefined && !opts.groupOwned ? { cancelSignal: opts.signal } : {}),
414
435
  };
415
436
  }
416
- function observeProcessGroupCancellation(subprocess, signal) {
417
- const pid = subprocess.pid;
418
- if (signal === undefined || process.platform === 'win32' || pid === undefined)
419
- return () => { };
420
- const abort = () => {
421
- try {
422
- process.kill(-pid, 'SIGTERM');
423
- }
424
- catch {
425
- // The leader may already have exited through execa's cancelSignal.
426
- // Fall back to the direct child without changing cancellation semantics.
427
- subprocess.kill('SIGTERM');
428
- }
437
+ // ── Deadline and process-group containment ───────────────────────────
438
+ /**
439
+ * Largest delay accepted by native timers; anything larger would overflow onto an
440
+ * immediate timer, so deadline/grace values beyond it are rejected before spawn.
441
+ */
442
+ const MAX_TIMEOUT_MS = 2_147_483_647;
443
+ /** Default grace between the owned group SIGTERM and the SIGKILL escalation. */
444
+ const DEFAULT_KILL_GRACE_MS = 5000;
445
+ /** Poll cadence while waiting for an owned process group to empty. */
446
+ const GROUP_POLL_INTERVAL_MS = 25;
447
+ /** Bounded patience after group SIGKILL before giving up on unkillable (D-state) members. */
448
+ const GROUP_KILL_PATIENCE_MS = 2000;
449
+ /**
450
+ * Resolve the effective deadline: omitted inherits the executor default (which may itself
451
+ * be unlimited), explicit `null` is unlimited and overrides any finite default, and a
452
+ * finite value must be a positive integer within the native timer range. Invalid values
453
+ * are rejected before spawn — never clamped and never overflowed onto a timer.
454
+ */
455
+ function resolveDeadline(optionTimeout, defaultTimeout) {
456
+ if (optionTimeout === null)
457
+ return null;
458
+ const candidate = optionTimeout === undefined ? defaultTimeout : optionTimeout;
459
+ if (candidate === undefined || candidate === null)
460
+ return candidate;
461
+ if (!Number.isInteger(candidate) || candidate <= 0 || candidate > MAX_TIMEOUT_MS) {
462
+ throw new TypeError(`Process timeout must be a positive integer within the native timer range (1-${MAX_TIMEOUT_MS} ms), got ${String(candidate)}`);
463
+ }
464
+ return candidate;
465
+ }
466
+ /**
467
+ * Resolve the SIGTERM→SIGKILL escalation grace: omitted uses {@link DEFAULT_KILL_GRACE_MS},
468
+ * `0` escalates immediately, and anything outside the native timer range is rejected.
469
+ */
470
+ function resolveKillGraceMs(optionGraceMs) {
471
+ if (optionGraceMs === undefined)
472
+ return DEFAULT_KILL_GRACE_MS;
473
+ if (!Number.isInteger(optionGraceMs) || optionGraceMs < 0 || optionGraceMs > MAX_TIMEOUT_MS) {
474
+ throw new TypeError(`Process killGraceMs must be a non-negative integer within the native timer range (0-${MAX_TIMEOUT_MS} ms), got ${String(optionGraceMs)}`);
475
+ }
476
+ return optionGraceMs;
477
+ }
478
+ /** Whether any member of the owned process group is still alive (signal 0 probe on `-pid`). */
479
+ function groupExists(pid) {
480
+ try {
481
+ process.kill(-pid, 0);
482
+ return true;
483
+ }
484
+ catch (error) {
485
+ // EPERM means the group exists but is not signallable from here.
486
+ return error.code === 'EPERM';
487
+ }
488
+ }
489
+ /** Signal the owned process group; `false` when the group is already gone (ESRCH). */
490
+ function signalGroup(pid, signal) {
491
+ try {
492
+ process.kill(-pid, signal);
493
+ return true;
494
+ }
495
+ catch {
496
+ return false;
497
+ }
498
+ }
499
+ async function waitForGroupExit(pid, budgetMs) {
500
+ const giveUpAt = Date.now() + budgetMs;
501
+ while (groupExists(pid)) {
502
+ if (Date.now() >= giveUpAt)
503
+ return false;
504
+ await new Promise((resolve) => setTimeout(resolve, GROUP_POLL_INTERVAL_MS));
505
+ }
506
+ return true;
507
+ }
508
+ /**
509
+ * The one termination sequence for an owned Unix process group: group SIGTERM, wait the
510
+ * grace for the whole group to empty, then group SIGKILL and a bounded settle wait.
511
+ * Group liveness — not direct-child exit — decides completion, so escalation is never
512
+ * cancelled because the leader exited first while descendants retain pipes or locks.
513
+ * Resolves `true` only when a group signal was actually delivered.
514
+ */
515
+ async function reapProcessGroup(pid, graceMs) {
516
+ if (!signalGroup(pid, 'SIGTERM'))
517
+ return false;
518
+ if (await waitForGroupExit(pid, graceMs))
519
+ return true;
520
+ signalGroup(pid, 'SIGKILL');
521
+ await waitForGroupExit(pid, GROUP_KILL_PATIENCE_MS);
522
+ return true;
523
+ }
524
+ /**
525
+ * Own one detached child's process group on Unix. A deadline timer and the caller's abort
526
+ * signal both feed a single termination path — group SIGTERM, grace, SIGKILL — and after a
527
+ * natural leader exit surviving owned descendants are reaped with the same sequence, so
528
+ * completion cannot leak group members holding inherited pipes or database write locks.
529
+ */
530
+ function ownProcessGroupLifecycle(ownership) {
531
+ const { pid, graceMs } = ownership;
532
+ let trigger;
533
+ let termination;
534
+ let timer;
535
+ const initiate = (reason) => {
536
+ if (trigger !== undefined)
537
+ return;
538
+ trigger = reason;
539
+ termination = reapProcessGroup(pid, graceMs);
429
540
  };
430
- if (signal.aborted)
431
- abort();
432
- else
433
- signal.addEventListener('abort', abort, { once: true });
434
- return () => signal.removeEventListener('abort', abort);
541
+ if (ownership.deadlineMs !== undefined) {
542
+ timer = setTimeout(() => initiate('timeout'), ownership.deadlineMs);
543
+ }
544
+ const onAbort = () => initiate('cancelled');
545
+ if (ownership.signal !== undefined) {
546
+ if (ownership.signal.aborted)
547
+ onAbort();
548
+ else
549
+ ownership.signal.addEventListener('abort', onAbort, { once: true });
550
+ }
551
+ return {
552
+ finish: async () => {
553
+ if (trigger === undefined) {
554
+ // Natural completion: reap stragglers (no-op when the group is already empty).
555
+ await reapProcessGroup(pid, graceMs);
556
+ return undefined;
557
+ }
558
+ // An undeliverable trigger (group already gone — the child finished first)
559
+ // yields undefined so the outcome falls back to the natural classification.
560
+ return (await termination) ? trigger : undefined;
561
+ },
562
+ stop: () => {
563
+ if (timer !== undefined)
564
+ clearTimeout(timer);
565
+ ownership.signal?.removeEventListener('abort', onAbort);
566
+ },
567
+ };
568
+ }
569
+ /** Classify an execa rejection on paths where the executor did not own the termination. */
570
+ function classifyFailedCompletion(error) {
571
+ if (error.timedOut === true)
572
+ return 'timeout';
573
+ if (error.isCanceled === true)
574
+ return 'cancelled';
575
+ if (error.isTerminated === true || error.signalDescription !== undefined || error.signal !== undefined) {
576
+ return 'signal';
577
+ }
578
+ return 'error';
435
579
  }
436
580
  function observeOutput(stream, name, observer) {
437
581
  if (!stream || !observer)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-runtime",
3
- "version": "0.4.57",
3
+ "version": "0.4.58",
4
4
  "description": "@gobing-ai/ts-runtime — Runtime abstractions for Bun, Node, and Cloudflare Workers.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -58,17 +58,17 @@
58
58
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-runtime-v<version> && git push --tags' && exit 1"
59
59
  },
60
60
  "dependencies": {
61
- "@gobing-ai/ts-utils": "^0.4.57",
61
+ "@gobing-ai/ts-utils": "^0.4.58",
62
62
  "execa": "^9.5.0",
63
63
  "yaml": "^2.7.0",
64
64
  "zod": "^4.1.0"
65
65
  },
66
66
  "devDependencies": {
67
- "@gobing-ai/ts-db": "^0.4.57",
67
+ "@gobing-ai/ts-db": "^0.4.58",
68
68
  "@types/bun": "1.3.14"
69
69
  },
70
70
  "peerDependencies": {
71
- "@gobing-ai/ts-db": "^0.4.57"
71
+ "@gobing-ai/ts-db": "^0.4.58"
72
72
  },
73
73
  "peerDependenciesMeta": {
74
74
  "@gobing-ai/ts-db": {
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export type {
26
26
  ProcessExecutorConfig,
27
27
  ProcessExitReason,
28
28
  ProcessOptions,
29
+ ProcessOutcome,
29
30
  ProcessOutputChunk,
30
31
  ProcessResult,
31
32
  ProcessSignal,
@@ -24,7 +24,11 @@ export type OutputPolicy = { mode: 'buffered' } | { mode: 'stream'; isTTY?: bool
24
24
 
25
25
  /** Shared configuration for a process executor (default timeout, output buffering, output policy). */
26
26
  export interface ProcessExecutorConfig {
27
- defaultTimeout?: number;
27
+ /**
28
+ * Default execution deadline inherited by runs that omit `timeout`. `null` makes the
29
+ * default policy unlimited; value validation follows `ProcessOptions.timeout`.
30
+ */
31
+ defaultTimeout?: number | null;
28
32
  defaultMaxOutput?: number;
29
33
  output?: OutputPolicy;
30
34
  events?: ProcessEventSink;
@@ -56,7 +60,23 @@ export interface ProcessOptions {
56
60
  * `run` and `runStreaming` share one contract.
57
61
  */
58
62
  envMode?: 'merge' | 'replace';
59
- timeout?: number;
63
+ /**
64
+ * Execution deadline in milliseconds. Omitted inherits
65
+ * {@link ProcessExecutorConfig.defaultTimeout}; explicit `null` runs without a deadline
66
+ * (a supplied `signal` still cancels). On Unix, a finite deadline activates owned
67
+ * process-group containment: the executor arms its own timer — callers must not race a
68
+ * second watchdog — and expiry reaps the whole group with SIGTERM-to-SIGKILL escalation.
69
+ * Zero, negative, fractional, non-finite, or beyond-timer-range values are rejected
70
+ * before spawn; `null` replaces the previous `0`-disables behavior.
71
+ */
72
+ timeout?: number | null;
73
+ /**
74
+ * Grace in milliseconds between the owned group SIGTERM and the SIGKILL escalation
75
+ * (Unix group containment; see {@link ProcessOptions.timeout}). `0` escalates
76
+ * immediately; omitted defaults to 5000 ms. Out-of-range values are rejected before
77
+ * spawn. Without a finite deadline or `signal`, this option has no effect.
78
+ */
79
+ killGraceMs?: number;
60
80
  maxOutput?: number;
61
81
  label?: string;
62
82
  rejectOnError?: boolean;
@@ -101,6 +121,13 @@ export interface ProcessOutputChunk {
101
121
  readonly timestamp: string;
102
122
  }
103
123
 
124
+ /**
125
+ * Terminal classification of how a run ended: normal `exit`, deadline `timeout`, external
126
+ * `cancelled` abort, termination by an outside `signal`, or `error` (spawn/runtime failure).
127
+ * Reaping surviving owned descendants after a natural leader exit keeps the outcome `exit`.
128
+ */
129
+ export type ProcessOutcome = 'exit' | 'timeout' | 'cancelled' | 'signal' | 'error';
130
+
104
131
  /** Result of a completed child process, including exit code, captured output, and duration. */
105
132
  export interface ProcessResult {
106
133
  command: string;
@@ -110,14 +137,20 @@ export interface ProcessResult {
110
137
  stderr: string;
111
138
  signal?: string;
112
139
  durationMs: number;
140
+ /**
141
+ * How the run terminated, distinguishing deadline expiry from external cancellation,
142
+ * an outside signal, runtime failure, and normal completion. Optional for backward
143
+ * compatibility with hand-built results; `NodeProcessExecutor` always sets it.
144
+ */
145
+ outcome?: ProcessOutcome;
113
146
  }
114
147
 
115
- /** Reason a process completion event was emitted. */
116
- export type ProcessExitReason = 'exit' | 'signal' | 'timeout' | 'error';
148
+ /** Reason a process completion event was emitted (mirrors {@link ProcessOutcome}). */
149
+ export type ProcessExitReason = ProcessOutcome;
117
150
 
118
151
  function processEventSeverity(reason: ProcessExitReason, exitCode: number | null): ProcessEventDetail['severity'] {
119
152
  if (reason === 'error') return 'error';
120
- if (reason === 'timeout') return 'warning';
153
+ if (reason === 'timeout' || reason === 'cancelled') return 'warning';
121
154
  if (exitCode !== null && exitCode !== 0) return 'warning';
122
155
  return 'info';
123
156
  }
@@ -263,15 +296,26 @@ export class NodeProcessExecutor implements ProcessExecutor {
263
296
 
264
297
  private async runUntraced(options: ProcessOptions): Promise<ProcessResult> {
265
298
  const args = options.args ?? [];
299
+ // Validate before spawn and before any registry/event side effects: an invalid
300
+ // deadline or grace is caller configuration error, not a process outcome.
301
+ const deadline = resolveDeadline(options.timeout, this.config.defaultTimeout);
302
+ const killGraceMs = resolveKillGraceMs(options.killGraceMs);
303
+ // Unix group containment activates on a finite deadline or a caller abort signal:
304
+ // the executor then owns group isolation, SIGTERM, escalation, output settlement
305
+ // and the final outcome, so callers must not arm a competing watchdog. Other
306
+ // platforms keep execa's direct-child timeout/cancellation semantics explicit.
307
+ const groupOwned =
308
+ process.platform !== 'win32' && (typeof deadline === 'number' || options.signal !== undefined);
266
309
  const execaOptions = buildExecaOptions({
267
310
  cwd: options.cwd ?? this.config.paths?.cwd,
268
311
  env: options.env,
269
- timeout: options.timeout ?? this.config.defaultTimeout,
312
+ timeout: typeof deadline === 'number' ? deadline : undefined,
270
313
  maxOutput: options.maxOutput ?? this.config.defaultMaxOutput,
271
314
  rejectOnError: options.rejectOnError ?? false,
272
315
  outputPolicy: this.config.output,
273
316
  forceBuffered: options.forceBuffered ?? false,
274
317
  signal: options.signal,
318
+ groupOwned,
275
319
  });
276
320
  const startedAt = Date.now();
277
321
  const startedIso = new Date(startedAt).toISOString();
@@ -293,8 +337,17 @@ export class NodeProcessExecutor implements ProcessExecutor {
293
337
  ...(options.label !== undefined ? { label: options.label } : {}),
294
338
  });
295
339
 
340
+ let ownership: ProcessGroupOwnership | undefined;
296
341
  try {
297
342
  const subprocess = execa(options.command, args, execaOptions);
343
+ if (groupOwned && subprocess.pid !== undefined) {
344
+ ownership = ownProcessGroupLifecycle({
345
+ pid: subprocess.pid,
346
+ deadlineMs: typeof deadline === 'number' ? deadline : undefined,
347
+ signal: options.signal,
348
+ graceMs: killGraceMs,
349
+ });
350
+ }
298
351
  // execa@9's resolved Result carries no pid, but the live handle does.
299
352
  // Publish it while the child is running so observers can identify the
300
353
  // subprocess, and record it against the registry entry opened above.
@@ -308,10 +361,14 @@ export class NodeProcessExecutor implements ProcessExecutor {
308
361
  }
309
362
  }
310
363
  }
311
- const stopGroupCancellation = observeProcessGroupCancellation(subprocess, options.signal);
364
+ const stopOwnedTermination = (): void => ownership?.stop();
312
365
  observeOutput(subprocess.stdout, 'stdout', options.onOutput);
313
366
  observeOutput(subprocess.stderr, 'stderr', options.onOutput);
314
- const result = await subprocess.finally(stopGroupCancellation);
367
+ const result = await subprocess.finally(stopOwnedTermination);
368
+ // Settle the owned termination path before reporting: a deadline or abort that
369
+ // fired keeps escalating until the group is empty, and a natural leader exit
370
+ // reaps surviving owned descendants so completion cannot leak pipes or locks.
371
+ const ownedOutcome = ownership !== undefined ? await ownership.finish() : undefined;
315
372
  const processResult = {
316
373
  command: options.command,
317
374
  args,
@@ -320,6 +377,8 @@ export class NodeProcessExecutor implements ProcessExecutor {
320
377
  stderr: asString(result.stderr),
321
378
  ...(result.signalDescription !== undefined ? { signal: result.signalDescription } : {}),
322
379
  durationMs: result.durationMs,
380
+ outcome:
381
+ ownedOutcome ?? (result.signalDescription !== undefined ? ('signal' as const) : ('exit' as const)),
323
382
  };
324
383
  this.completeRegistry(registryId, processResult.exitCode);
325
384
  this.emitExitedFromResult(options, processResult, result);
@@ -333,8 +392,13 @@ export class NodeProcessExecutor implements ProcessExecutor {
333
392
  signal?: string;
334
393
  durationMs?: number;
335
394
  timedOut?: boolean;
395
+ isCanceled?: boolean;
396
+ isTerminated?: boolean;
336
397
  message?: string;
337
398
  };
399
+ // Termination containment settles before the failure is reported — and before
400
+ // rejectOnError rethrows — so a rejected run never leaks owned descendants.
401
+ const ownedOutcome = ownership !== undefined ? await ownership.finish() : undefined;
338
402
  const processResult = {
339
403
  command: options.command,
340
404
  args,
@@ -347,6 +411,7 @@ export class NodeProcessExecutor implements ProcessExecutor {
347
411
  ? { signal: failed.signal }
348
412
  : {}),
349
413
  durationMs: failed.durationMs ?? Date.now() - startedAt,
414
+ outcome: ownedOutcome ?? classifyFailedCompletion(failed),
350
415
  };
351
416
  this.completeRegistry(registryId, processResult.exitCode);
352
417
  this.emitExitedFromResult(options, processResult, error, error);
@@ -465,13 +530,9 @@ export class NodeProcessExecutor implements ProcessExecutor {
465
530
  completion: unknown,
466
531
  error?: unknown,
467
532
  ): void {
468
- const reason = isTimedOut(completion)
469
- ? 'timeout'
470
- : result.signal !== undefined
471
- ? 'signal'
472
- : error
473
- ? 'error'
474
- : 'exit';
533
+ const reason =
534
+ result.outcome ??
535
+ (isTimedOut(completion) ? 'timeout' : result.signal !== undefined ? 'signal' : error ? 'error' : 'exit');
475
536
  this.emitProcessEvent('process.exited', {
476
537
  command: result.command,
477
538
  args: result.args,
@@ -678,6 +739,7 @@ function buildExecaOptions(opts: {
678
739
  outputPolicy: OutputPolicy | undefined;
679
740
  forceBuffered: boolean;
680
741
  signal?: AbortSignal;
742
+ groupOwned: boolean;
681
743
  }): ExecaOptions {
682
744
  const mode = opts.forceBuffered ? 'buffered' : (opts.outputPolicy?.mode ?? 'buffered');
683
745
  const streamToTerminal =
@@ -695,37 +757,191 @@ function buildExecaOptions(opts: {
695
757
  : { all: true }),
696
758
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
697
759
  ...(opts.env !== undefined ? { env: opts.env } : {}),
698
- ...(opts.timeout !== undefined ? { timeout: opts.timeout } : {}),
760
+ ...(opts.timeout !== undefined && !opts.groupOwned ? { timeout: opts.timeout } : {}),
699
761
  ...(opts.maxOutput !== undefined ? { maxBuffer: opts.maxOutput } : {}),
700
- ...(opts.signal !== undefined
701
- ? {
702
- cancelSignal: opts.signal,
703
- // Node's negative-pid group signal requires the child to lead
704
- // a distinct process group. Windows has no equivalent.
705
- ...(process.platform !== 'win32' ? { detached: true } : {}),
706
- }
707
- : {}),
762
+ // Group-owned runs make the child lead a distinct process group so negative-pid
763
+ // signaling can reach the whole tree; termination itself runs through the
764
+ // executor's own escalation path (deadline and abort feed one sequence).
765
+ // Platforms without Unix group semantics cancel the direct child via execa.
766
+ ...(opts.groupOwned ? { detached: true } : {}),
767
+ ...(opts.signal !== undefined && !opts.groupOwned ? { cancelSignal: opts.signal } : {}),
708
768
  };
709
769
  }
710
770
 
711
- function observeProcessGroupCancellation(
712
- subprocess: { pid?: number; kill(signal?: NodeJS.Signals | number): boolean },
713
- signal: AbortSignal | undefined,
714
- ): () => void {
715
- const pid = subprocess.pid;
716
- if (signal === undefined || process.platform === 'win32' || pid === undefined) return () => {};
717
- const abort = (): void => {
718
- try {
719
- process.kill(-pid, 'SIGTERM');
720
- } catch {
721
- // The leader may already have exited through execa's cancelSignal.
722
- // Fall back to the direct child without changing cancellation semantics.
723
- subprocess.kill('SIGTERM');
724
- }
771
+ // ── Deadline and process-group containment ───────────────────────────
772
+
773
+ /**
774
+ * Largest delay accepted by native timers; anything larger would overflow onto an
775
+ * immediate timer, so deadline/grace values beyond it are rejected before spawn.
776
+ */
777
+ const MAX_TIMEOUT_MS = 2_147_483_647;
778
+
779
+ /** Default grace between the owned group SIGTERM and the SIGKILL escalation. */
780
+ const DEFAULT_KILL_GRACE_MS = 5000;
781
+
782
+ /** Poll cadence while waiting for an owned process group to empty. */
783
+ const GROUP_POLL_INTERVAL_MS = 25;
784
+
785
+ /** Bounded patience after group SIGKILL before giving up on unkillable (D-state) members. */
786
+ const GROUP_KILL_PATIENCE_MS = 2000;
787
+
788
+ /**
789
+ * Resolve the effective deadline: omitted inherits the executor default (which may itself
790
+ * be unlimited), explicit `null` is unlimited and overrides any finite default, and a
791
+ * finite value must be a positive integer within the native timer range. Invalid values
792
+ * are rejected before spawn — never clamped and never overflowed onto a timer.
793
+ */
794
+ function resolveDeadline(
795
+ optionTimeout: number | null | undefined,
796
+ defaultTimeout: number | null | undefined,
797
+ ): number | null | undefined {
798
+ if (optionTimeout === null) return null;
799
+ const candidate = optionTimeout === undefined ? defaultTimeout : optionTimeout;
800
+ if (candidate === undefined || candidate === null) return candidate;
801
+ if (!Number.isInteger(candidate) || candidate <= 0 || candidate > MAX_TIMEOUT_MS) {
802
+ throw new TypeError(
803
+ `Process timeout must be a positive integer within the native timer range (1-${MAX_TIMEOUT_MS} ms), got ${String(candidate)}`,
804
+ );
805
+ }
806
+ return candidate;
807
+ }
808
+
809
+ /**
810
+ * Resolve the SIGTERM→SIGKILL escalation grace: omitted uses {@link DEFAULT_KILL_GRACE_MS},
811
+ * `0` escalates immediately, and anything outside the native timer range is rejected.
812
+ */
813
+ function resolveKillGraceMs(optionGraceMs: number | undefined): number {
814
+ if (optionGraceMs === undefined) return DEFAULT_KILL_GRACE_MS;
815
+ if (!Number.isInteger(optionGraceMs) || optionGraceMs < 0 || optionGraceMs > MAX_TIMEOUT_MS) {
816
+ throw new TypeError(
817
+ `Process killGraceMs must be a non-negative integer within the native timer range (0-${MAX_TIMEOUT_MS} ms), got ${String(optionGraceMs)}`,
818
+ );
819
+ }
820
+ return optionGraceMs;
821
+ }
822
+
823
+ /** Whether any member of the owned process group is still alive (signal 0 probe on `-pid`). */
824
+ function groupExists(pid: number): boolean {
825
+ try {
826
+ process.kill(-pid, 0);
827
+ return true;
828
+ } catch (error) {
829
+ // EPERM means the group exists but is not signallable from here.
830
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
831
+ }
832
+ }
833
+
834
+ /** Signal the owned process group; `false` when the group is already gone (ESRCH). */
835
+ function signalGroup(pid: number, signal: NodeJS.Signals | number): boolean {
836
+ try {
837
+ process.kill(-pid, signal);
838
+ return true;
839
+ } catch {
840
+ return false;
841
+ }
842
+ }
843
+
844
+ async function waitForGroupExit(pid: number, budgetMs: number): Promise<boolean> {
845
+ const giveUpAt = Date.now() + budgetMs;
846
+ while (groupExists(pid)) {
847
+ if (Date.now() >= giveUpAt) return false;
848
+ await new Promise<void>((resolve) => setTimeout(resolve, GROUP_POLL_INTERVAL_MS));
849
+ }
850
+ return true;
851
+ }
852
+
853
+ /**
854
+ * The one termination sequence for an owned Unix process group: group SIGTERM, wait the
855
+ * grace for the whole group to empty, then group SIGKILL and a bounded settle wait.
856
+ * Group liveness — not direct-child exit — decides completion, so escalation is never
857
+ * cancelled because the leader exited first while descendants retain pipes or locks.
858
+ * Resolves `true` only when a group signal was actually delivered.
859
+ */
860
+ async function reapProcessGroup(pid: number, graceMs: number): Promise<boolean> {
861
+ if (!signalGroup(pid, 'SIGTERM')) return false;
862
+ if (await waitForGroupExit(pid, graceMs)) return true;
863
+ signalGroup(pid, 'SIGKILL');
864
+ await waitForGroupExit(pid, GROUP_KILL_PATIENCE_MS);
865
+ return true;
866
+ }
867
+
868
+ /** Handle for the executor-owned termination of one spawned process group. */
869
+ interface ProcessGroupOwnership {
870
+ /**
871
+ * Settle the run's termination: resolve the outcome delivered by the executor's own
872
+ * path (deadline `timeout` or external `cancelled`) after the escalation completed, or
873
+ * reap surviving owned descendants after a natural leader exit (outcome stays `exit`).
874
+ * Call exactly once, after the execa promise has settled; bounded, never hangs.
875
+ */
876
+ finish(): Promise<ProcessOutcome | undefined>;
877
+ /** Release the deadline timer and abort listener once the execa promise has settled. */
878
+ stop(): void;
879
+ }
880
+
881
+ /**
882
+ * Own one detached child's process group on Unix. A deadline timer and the caller's abort
883
+ * signal both feed a single termination path — group SIGTERM, grace, SIGKILL — and after a
884
+ * natural leader exit surviving owned descendants are reaped with the same sequence, so
885
+ * completion cannot leak group members holding inherited pipes or database write locks.
886
+ */
887
+ function ownProcessGroupLifecycle(ownership: {
888
+ pid: number;
889
+ deadlineMs: number | undefined;
890
+ signal: AbortSignal | undefined;
891
+ graceMs: number;
892
+ }): ProcessGroupOwnership {
893
+ const { pid, graceMs } = ownership;
894
+ let trigger: 'timeout' | 'cancelled' | undefined;
895
+ let termination: Promise<boolean> | undefined;
896
+ let timer: ReturnType<typeof setTimeout> | undefined;
897
+
898
+ const initiate = (reason: 'timeout' | 'cancelled'): void => {
899
+ if (trigger !== undefined) return;
900
+ trigger = reason;
901
+ termination = reapProcessGroup(pid, graceMs);
902
+ };
903
+
904
+ if (ownership.deadlineMs !== undefined) {
905
+ timer = setTimeout(() => initiate('timeout'), ownership.deadlineMs);
906
+ }
907
+ const onAbort = (): void => initiate('cancelled');
908
+ if (ownership.signal !== undefined) {
909
+ if (ownership.signal.aborted) onAbort();
910
+ else ownership.signal.addEventListener('abort', onAbort, { once: true });
911
+ }
912
+
913
+ return {
914
+ finish: async () => {
915
+ if (trigger === undefined) {
916
+ // Natural completion: reap stragglers (no-op when the group is already empty).
917
+ await reapProcessGroup(pid, graceMs);
918
+ return undefined;
919
+ }
920
+ // An undeliverable trigger (group already gone — the child finished first)
921
+ // yields undefined so the outcome falls back to the natural classification.
922
+ return (await termination) ? trigger : undefined;
923
+ },
924
+ stop: () => {
925
+ if (timer !== undefined) clearTimeout(timer);
926
+ ownership.signal?.removeEventListener('abort', onAbort);
927
+ },
725
928
  };
726
- if (signal.aborted) abort();
727
- else signal.addEventListener('abort', abort, { once: true });
728
- return () => signal.removeEventListener('abort', abort);
929
+ }
930
+
931
+ /** Classify an execa rejection on paths where the executor did not own the termination. */
932
+ function classifyFailedCompletion(error: {
933
+ timedOut?: boolean;
934
+ isCanceled?: boolean;
935
+ isTerminated?: boolean;
936
+ signalDescription?: string;
937
+ signal?: string;
938
+ }): ProcessOutcome {
939
+ if (error.timedOut === true) return 'timeout';
940
+ if (error.isCanceled === true) return 'cancelled';
941
+ if (error.isTerminated === true || error.signalDescription !== undefined || error.signal !== undefined) {
942
+ return 'signal';
943
+ }
944
+ return 'error';
729
945
  }
730
946
 
731
947
  function observeOutput(