@gobing-ai/ts-runtime 0.5.3 → 0.5.5
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 +7 -0
- package/dist/index.d.ts +3 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/process-executor.d.ts +18 -1
- package/dist/process-executor.d.ts.map +1 -1
- package/dist/process-executor.js +48 -138
- package/dist/process-group.d.ts +44 -0
- package/dist/process-group.d.ts.map +1 -0
- package/dist/process-group.js +137 -0
- package/package.json +4 -4
- package/src/index.ts +3 -7
- package/src/process-executor.ts +66 -167
- package/src/process-group.ts +166 -0
package/README.md
CHANGED
|
@@ -641,6 +641,13 @@ The `ProcessExecutor` const (value alias for `NodeProcessExecutor`), `BunSyncPro
|
|
|
641
641
|
and `BunPipeProcessSpawner` are kept as deprecated backward-compatible wrappers. Prefer
|
|
642
642
|
`NodeProcessExecutor` or `nodeBunFactory.createProcessExecutor()` in new code.
|
|
643
643
|
|
|
644
|
+
**Deprecation status (task 0087 R7):** sync callers now default to the non-deprecated
|
|
645
|
+
`NodeSyncProcessExecutor` (node `spawnSync`) — `identity.ts` was migrated off the
|
|
646
|
+
`BunSyncProcessExecutor` default. `BunSyncProcessExecutor` and `BunPipeProcessSpawner`
|
|
647
|
+
remain exported-deprecated and are deleted in the next major version. The process-group
|
|
648
|
+
containment machinery lives in `src/process-group.ts` (extracted from
|
|
649
|
+
`process-executor.ts`, task 0087 R8 — pure move, no behavior change).
|
|
650
|
+
|
|
644
651
|
### SpanContext (for telemetry)
|
|
645
652
|
|
|
646
653
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -19,12 +19,9 @@ export type { RuntimePaths } from './runtime-paths';
|
|
|
19
19
|
export { ambientRuntimePaths } from './runtime-paths';
|
|
20
20
|
export * from './schema-validation';
|
|
21
21
|
export * from './types';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* Use `Bun.spawnSync` or `child_process.spawnSync` for sync.
|
|
26
|
-
*/
|
|
27
|
-
export type SyncProcessExecutor = InstanceType<typeof import('./process-executor').BunSyncProcessExecutor>;
|
|
22
|
+
/** Sync executor shape (task 0087 R7) — satisfied by NodeSyncProcessExecutor (preferred) and the deprecated BunSyncProcessExecutor. */
|
|
23
|
+
export type { SyncProcessExecutor } from './process-executor';
|
|
24
|
+
export { BunPipeProcessSpawner, BunSyncProcessExecutor, NodeSyncProcessExecutor } from './process-executor';
|
|
28
25
|
/**
|
|
29
26
|
* @deprecated Use {@link ProcessExecutor.runStreaming} instead.
|
|
30
27
|
*/
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,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,
|
|
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,uIAAuI;AACvI,YAAY,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAAE,qBAAqB,EAAE,sBAAsB,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AAE5G;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,YAAY,CAAC,cAAc,oBAAoB,EAAE,qBAAqB,CAAC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -14,5 +14,4 @@ export { _resetNodeFileSystem, nodeBunFactory } from './runtime-node-bun.js';
|
|
|
14
14
|
export { ambientRuntimePaths } from './runtime-paths.js';
|
|
15
15
|
export * from './schema-validation.js';
|
|
16
16
|
export * from './types.js';
|
|
17
|
-
|
|
18
|
-
export { BunPipeProcessSpawner, BunSyncProcessExecutor } from './process-executor.js';
|
|
17
|
+
export { BunPipeProcessSpawner, BunSyncProcessExecutor, NodeSyncProcessExecutor } from './process-executor.js';
|
|
@@ -270,7 +270,24 @@ export declare const ProcessExecutor: typeof NodeProcessExecutor;
|
|
|
270
270
|
* Synchronous process execution is no longer recommended from ts-runtime.
|
|
271
271
|
* This class is kept for backward compatibility.
|
|
272
272
|
*/
|
|
273
|
-
export declare class BunSyncProcessExecutor {
|
|
273
|
+
export declare class BunSyncProcessExecutor implements SyncProcessExecutor {
|
|
274
|
+
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Sync process execution surface shared by the sync executors (task 0087 R7).
|
|
278
|
+
* Not deprecated — the deprecated part is the Bun-specific default, not the shape.
|
|
279
|
+
*/
|
|
280
|
+
export interface SyncProcessExecutor {
|
|
281
|
+
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Sync executor on `node:child_process.spawnSync` — the non-deprecated default
|
|
285
|
+
* for sync callers (task 0087 R7). Contract-compatible with
|
|
286
|
+
* {@link BunSyncProcessExecutor} for success and non-zero exits. One deliberate
|
|
287
|
+
* divergence: a spawn failure (e.g. missing binary) maps to exitCode 1 instead of
|
|
288
|
+
* throwing, so `getGitContextSync`-style probes see a plain non-zero exit.
|
|
289
|
+
*/
|
|
290
|
+
export declare class NodeSyncProcessExecutor implements SyncProcessExecutor {
|
|
274
291
|
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult;
|
|
275
292
|
}
|
|
276
293
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"process-executor.d.ts","sourceRoot":"","sources":["../src/process-executor.ts"],"names":[],"mappings":"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"}
|
package/dist/process-executor.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import { isatty } from 'node:tty';
|
|
2
3
|
import { execa } from 'execa';
|
|
3
4
|
import { getProcessEnv } from './config.js';
|
|
5
|
+
import { ownProcessGroupLifecycle, resolveDeadline, resolveKillGraceMs, } from './process-group.js';
|
|
4
6
|
function processEventSeverity(reason, exitCode) {
|
|
5
7
|
if (reason === 'error')
|
|
6
8
|
return 'error';
|
|
@@ -168,7 +170,8 @@ export class NodeProcessExecutor {
|
|
|
168
170
|
*/
|
|
169
171
|
runStreaming(options) {
|
|
170
172
|
const args = options.args ?? [];
|
|
171
|
-
|
|
173
|
+
// Task 0086 R14: no lifetime span here — the empty fire-and-forget span
|
|
174
|
+
// carried no data and confused span consumers. Registry + events cover it.
|
|
172
175
|
const startedAt = Date.now();
|
|
173
176
|
const startedIso = new Date(startedAt).toISOString();
|
|
174
177
|
// Begin registry before spawn so failed spawns still appear (then complete as error).
|
|
@@ -391,6 +394,36 @@ export class BunSyncProcessExecutor {
|
|
|
391
394
|
};
|
|
392
395
|
}
|
|
393
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* Sync executor on `node:child_process.spawnSync` — the non-deprecated default
|
|
399
|
+
* for sync callers (task 0087 R7). Contract-compatible with
|
|
400
|
+
* {@link BunSyncProcessExecutor} for success and non-zero exits. One deliberate
|
|
401
|
+
* divergence: a spawn failure (e.g. missing binary) maps to exitCode 1 instead of
|
|
402
|
+
* throwing, so `getGitContextSync`-style probes see a plain non-zero exit.
|
|
403
|
+
*/
|
|
404
|
+
export class NodeSyncProcessExecutor {
|
|
405
|
+
runSync(options) {
|
|
406
|
+
const args = options.args ?? [];
|
|
407
|
+
const startedAt = Date.now();
|
|
408
|
+
const result = spawnSync(options.command, args, {
|
|
409
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
410
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
411
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
412
|
+
});
|
|
413
|
+
const exitCode = result.status ?? 1;
|
|
414
|
+
if (options.rejectOnError === true && exitCode !== 0) {
|
|
415
|
+
throw new Error(`${options.command} ${args.join(' ')} failed with exit code ${exitCode}: ${stripFinalNewline(asString(result.stderr))}`);
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
command: options.command,
|
|
419
|
+
args,
|
|
420
|
+
exitCode,
|
|
421
|
+
stdout: stripFinalNewline(asString(result.stdout)),
|
|
422
|
+
stderr: stripFinalNewline(asString(result.stderr)),
|
|
423
|
+
durationMs: Date.now() - startedAt,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
394
427
|
/**
|
|
395
428
|
* @deprecated Use {@link ProcessExecutor.runStreaming} instead.
|
|
396
429
|
* This class is kept for backward compatibility.
|
|
@@ -435,137 +468,6 @@ function buildExecaOptions(opts) {
|
|
|
435
468
|
};
|
|
436
469
|
}
|
|
437
470
|
// ── 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);
|
|
540
|
-
};
|
|
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
471
|
/** Classify an execa rejection on paths where the executor did not own the termination. */
|
|
570
472
|
function classifyFailedCompletion(error) {
|
|
571
473
|
if (error.timedOut === true)
|
|
@@ -580,17 +482,25 @@ function classifyFailedCompletion(error) {
|
|
|
580
482
|
function observeOutput(stream, name, observer) {
|
|
581
483
|
if (!stream || !observer)
|
|
582
484
|
return;
|
|
583
|
-
stream
|
|
485
|
+
// Task 0086 R13: one decoder per stream with `stream: true` keeps multi-byte
|
|
486
|
+
// UTF-8 sequences intact across chunk boundaries; a fresh decoder per chunk
|
|
487
|
+
// would split them into U+FFFD replacement characters.
|
|
488
|
+
const decoder = new TextDecoder();
|
|
489
|
+
const emit = (chunk) => {
|
|
584
490
|
try {
|
|
585
|
-
observer({
|
|
586
|
-
stream: name,
|
|
587
|
-
chunk: asString(chunk),
|
|
588
|
-
timestamp: new Date().toISOString(),
|
|
589
|
-
});
|
|
491
|
+
observer({ stream: name, chunk, timestamp: new Date().toISOString() });
|
|
590
492
|
}
|
|
591
493
|
catch {
|
|
592
494
|
// Observability is best-effort and must never interrupt child I/O.
|
|
593
495
|
}
|
|
496
|
+
};
|
|
497
|
+
stream.on('data', (chunk) => {
|
|
498
|
+
emit(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
|
|
499
|
+
});
|
|
500
|
+
stream.on('end', () => {
|
|
501
|
+
const tail = decoder.decode();
|
|
502
|
+
if (tail.length > 0)
|
|
503
|
+
emit(tail);
|
|
594
504
|
});
|
|
595
505
|
}
|
|
596
506
|
function asString(value) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor-owned process-group containment (task 0087 R8): deadline resolution,
|
|
3
|
+
* group liveness probes, SIGTERM→SIGKILL escalation, and reap lifecycle for
|
|
4
|
+
* process groups spawned by NodeProcessExecutor. Extracted from
|
|
5
|
+
* process-executor.ts as a pure move — no behavior change.
|
|
6
|
+
*/
|
|
7
|
+
import type { ProcessOutcome } from './process-executor';
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the effective deadline: omitted inherits the executor default (which may itself
|
|
10
|
+
* be unlimited), explicit `null` is unlimited and overrides any finite default, and a
|
|
11
|
+
* finite value must be a positive integer within the native timer range. Invalid values
|
|
12
|
+
* are rejected before spawn — never clamped and never overflowed onto a timer.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveDeadline(optionTimeout: number | null | undefined, defaultTimeout: number | null | undefined): number | null | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the SIGTERM→SIGKILL escalation grace: omitted uses {@link DEFAULT_KILL_GRACE_MS},
|
|
17
|
+
* `0` escalates immediately, and anything outside the native timer range is rejected.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveKillGraceMs(optionGraceMs: number | undefined): number;
|
|
20
|
+
/** Handle for the executor-owned termination of one spawned process group. */
|
|
21
|
+
export interface ProcessGroupOwnership {
|
|
22
|
+
/**
|
|
23
|
+
* Settle the run's termination: resolve the outcome delivered by the executor's own
|
|
24
|
+
* path (deadline `timeout` or external `cancelled`) after the escalation completed, or
|
|
25
|
+
* reap surviving owned descendants after a natural leader exit (outcome stays `exit`).
|
|
26
|
+
* Call exactly once, after the execa promise has settled; bounded, never hangs.
|
|
27
|
+
*/
|
|
28
|
+
finish(): Promise<ProcessOutcome | undefined>;
|
|
29
|
+
/** Release the deadline timer and abort listener once the execa promise has settled. */
|
|
30
|
+
stop(): void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Own one detached child's process group on Unix. A deadline timer and the caller's abort
|
|
34
|
+
* signal both feed a single termination path — group SIGTERM, grace, SIGKILL — and after a
|
|
35
|
+
* natural leader exit surviving owned descendants are reaped with the same sequence, so
|
|
36
|
+
* completion cannot leak group members holding inherited pipes or database write locks.
|
|
37
|
+
*/
|
|
38
|
+
export declare function ownProcessGroupLifecycle(ownership: {
|
|
39
|
+
pid: number;
|
|
40
|
+
deadlineMs: number | undefined;
|
|
41
|
+
signal: AbortSignal | undefined;
|
|
42
|
+
graceMs: number;
|
|
43
|
+
}): ProcessGroupOwnership;
|
|
44
|
+
//# sourceMappingURL=process-group.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"process-group.d.ts","sourceRoot":"","sources":["../src/process-group.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAiBzD;;;;;GAKG;AACH,wBAAgB,eAAe,CAC3B,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,cAAc,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC1C,MAAM,GAAG,IAAI,GAAG,SAAS,CAU3B;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAQ5E;AA+CD,8EAA8E;AAC9E,MAAM,WAAW,qBAAqB;IAClC;;;;;OAKG;IACH,MAAM,IAAI,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAC9C,wFAAwF;IACxF,IAAI,IAAI,IAAI,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE;IAChD,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,MAAM,EAAE,WAAW,GAAG,SAAS,CAAC;IAChC,OAAO,EAAE,MAAM,CAAC;CACnB,GAAG,qBAAqB,CAqCxB"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor-owned process-group containment (task 0087 R8): deadline resolution,
|
|
3
|
+
* group liveness probes, SIGTERM→SIGKILL escalation, and reap lifecycle for
|
|
4
|
+
* process groups spawned by NodeProcessExecutor. Extracted from
|
|
5
|
+
* process-executor.ts as a pure move — no behavior change.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Largest delay accepted by native timers; anything larger would overflow onto an
|
|
9
|
+
* immediate timer, so deadline/grace values beyond it are rejected before spawn.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
12
|
+
/** Default grace between the owned group SIGTERM and the SIGKILL escalation. */
|
|
13
|
+
const DEFAULT_KILL_GRACE_MS = 5000;
|
|
14
|
+
/** Poll cadence while waiting for an owned process group to empty. */
|
|
15
|
+
const GROUP_POLL_INTERVAL_MS = 25;
|
|
16
|
+
/** Bounded patience after group SIGKILL before giving up on unkillable (D-state) members. */
|
|
17
|
+
const GROUP_KILL_PATIENCE_MS = 2000;
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the effective deadline: omitted inherits the executor default (which may itself
|
|
20
|
+
* be unlimited), explicit `null` is unlimited and overrides any finite default, and a
|
|
21
|
+
* finite value must be a positive integer within the native timer range. Invalid values
|
|
22
|
+
* are rejected before spawn — never clamped and never overflowed onto a timer.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveDeadline(optionTimeout, defaultTimeout) {
|
|
25
|
+
if (optionTimeout === null)
|
|
26
|
+
return null;
|
|
27
|
+
const candidate = optionTimeout === undefined ? defaultTimeout : optionTimeout;
|
|
28
|
+
if (candidate === undefined || candidate === null)
|
|
29
|
+
return candidate;
|
|
30
|
+
if (!Number.isInteger(candidate) || candidate <= 0 || candidate > MAX_TIMEOUT_MS) {
|
|
31
|
+
throw new TypeError(`Process timeout must be a positive integer within the native timer range (1-${MAX_TIMEOUT_MS} ms), got ${String(candidate)}`);
|
|
32
|
+
}
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the SIGTERM→SIGKILL escalation grace: omitted uses {@link DEFAULT_KILL_GRACE_MS},
|
|
37
|
+
* `0` escalates immediately, and anything outside the native timer range is rejected.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveKillGraceMs(optionGraceMs) {
|
|
40
|
+
if (optionGraceMs === undefined)
|
|
41
|
+
return DEFAULT_KILL_GRACE_MS;
|
|
42
|
+
if (!Number.isInteger(optionGraceMs) || optionGraceMs < 0 || optionGraceMs > MAX_TIMEOUT_MS) {
|
|
43
|
+
throw new TypeError(`Process killGraceMs must be a non-negative integer within the native timer range (0-${MAX_TIMEOUT_MS} ms), got ${String(optionGraceMs)}`);
|
|
44
|
+
}
|
|
45
|
+
return optionGraceMs;
|
|
46
|
+
}
|
|
47
|
+
/** Whether any member of the owned process group is still alive (signal 0 probe on `-pid`). */
|
|
48
|
+
function groupExists(pid) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(-pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// EPERM means the group exists but is not signallable from here.
|
|
55
|
+
return error.code === 'EPERM';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Signal the owned process group; `false` when the group is already gone (ESRCH). */
|
|
59
|
+
function signalGroup(pid, signal) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(-pid, signal);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function waitForGroupExit(pid, budgetMs) {
|
|
69
|
+
const giveUpAt = Date.now() + budgetMs;
|
|
70
|
+
while (groupExists(pid)) {
|
|
71
|
+
if (Date.now() >= giveUpAt)
|
|
72
|
+
return false;
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, GROUP_POLL_INTERVAL_MS));
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The one termination sequence for an owned Unix process group: group SIGTERM, wait the
|
|
79
|
+
* grace for the whole group to empty, then group SIGKILL and a bounded settle wait.
|
|
80
|
+
* Group liveness — not direct-child exit — decides completion, so escalation is never
|
|
81
|
+
* cancelled because the leader exited first while descendants retain pipes or locks.
|
|
82
|
+
* Resolves `true` only when a group signal was actually delivered.
|
|
83
|
+
*/
|
|
84
|
+
async function reapProcessGroup(pid, graceMs) {
|
|
85
|
+
if (!signalGroup(pid, 'SIGTERM'))
|
|
86
|
+
return false;
|
|
87
|
+
if (await waitForGroupExit(pid, graceMs))
|
|
88
|
+
return true;
|
|
89
|
+
signalGroup(pid, 'SIGKILL');
|
|
90
|
+
await waitForGroupExit(pid, GROUP_KILL_PATIENCE_MS);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Own one detached child's process group on Unix. A deadline timer and the caller's abort
|
|
95
|
+
* signal both feed a single termination path — group SIGTERM, grace, SIGKILL — and after a
|
|
96
|
+
* natural leader exit surviving owned descendants are reaped with the same sequence, so
|
|
97
|
+
* completion cannot leak group members holding inherited pipes or database write locks.
|
|
98
|
+
*/
|
|
99
|
+
export function ownProcessGroupLifecycle(ownership) {
|
|
100
|
+
const { pid, graceMs } = ownership;
|
|
101
|
+
let trigger;
|
|
102
|
+
let termination;
|
|
103
|
+
let timer;
|
|
104
|
+
const initiate = (reason) => {
|
|
105
|
+
if (trigger !== undefined)
|
|
106
|
+
return;
|
|
107
|
+
trigger = reason;
|
|
108
|
+
termination = reapProcessGroup(pid, graceMs);
|
|
109
|
+
};
|
|
110
|
+
if (ownership.deadlineMs !== undefined) {
|
|
111
|
+
timer = setTimeout(() => initiate('timeout'), ownership.deadlineMs);
|
|
112
|
+
}
|
|
113
|
+
const onAbort = () => initiate('cancelled');
|
|
114
|
+
if (ownership.signal !== undefined) {
|
|
115
|
+
if (ownership.signal.aborted)
|
|
116
|
+
onAbort();
|
|
117
|
+
else
|
|
118
|
+
ownership.signal.addEventListener('abort', onAbort, { once: true });
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
finish: async () => {
|
|
122
|
+
if (trigger === undefined) {
|
|
123
|
+
// Natural completion: reap stragglers (no-op when the group is already empty).
|
|
124
|
+
await reapProcessGroup(pid, graceMs);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
// An undeliverable trigger (group already gone — the child finished first)
|
|
128
|
+
// yields undefined so the outcome falls back to the natural classification.
|
|
129
|
+
return (await termination) ? trigger : undefined;
|
|
130
|
+
},
|
|
131
|
+
stop: () => {
|
|
132
|
+
if (timer !== undefined)
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
ownership.signal?.removeEventListener('abort', onAbort);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-runtime",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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.5.
|
|
61
|
+
"@gobing-ai/ts-utils": "^0.5.5",
|
|
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.5.
|
|
67
|
+
"@gobing-ai/ts-db": "^0.5.5",
|
|
68
68
|
"@types/bun": "1.3.14"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@gobing-ai/ts-db": "^0.5.
|
|
71
|
+
"@gobing-ai/ts-db": "^0.5.5"
|
|
72
72
|
},
|
|
73
73
|
"peerDependenciesMeta": {
|
|
74
74
|
"@gobing-ai/ts-db": {
|
package/src/index.ts
CHANGED
|
@@ -55,13 +55,9 @@ export * from './types';
|
|
|
55
55
|
|
|
56
56
|
// ── Deprecated re-exports (backward compatibility) ──────────────────────
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* @deprecated Use {@link ProcessExecutor} directly for async execution.
|
|
62
|
-
* Use `Bun.spawnSync` or `child_process.spawnSync` for sync.
|
|
63
|
-
*/
|
|
64
|
-
export type SyncProcessExecutor = InstanceType<typeof import('./process-executor').BunSyncProcessExecutor>;
|
|
58
|
+
/** Sync executor shape (task 0087 R7) — satisfied by NodeSyncProcessExecutor (preferred) and the deprecated BunSyncProcessExecutor. */
|
|
59
|
+
export type { SyncProcessExecutor } from './process-executor';
|
|
60
|
+
export { BunPipeProcessSpawner, BunSyncProcessExecutor, NodeSyncProcessExecutor } from './process-executor';
|
|
65
61
|
|
|
66
62
|
/**
|
|
67
63
|
* @deprecated Use {@link ProcessExecutor.runStreaming} instead.
|
package/src/process-executor.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import { isatty } from 'node:tty';
|
|
2
3
|
import { type Options as ExecaOptions, execa } from 'execa';
|
|
3
4
|
import { getProcessEnv } from './config';
|
|
5
|
+
import {
|
|
6
|
+
ownProcessGroupLifecycle,
|
|
7
|
+
type ProcessGroupOwnership,
|
|
8
|
+
resolveDeadline,
|
|
9
|
+
resolveKillGraceMs,
|
|
10
|
+
} from './process-group';
|
|
4
11
|
import type { ProcessExecutionSource, ProcessRegistry } from './process-registry';
|
|
5
12
|
import type { RuntimePaths } from './runtime-paths';
|
|
6
13
|
|
|
@@ -428,7 +435,8 @@ export class NodeProcessExecutor implements ProcessExecutor {
|
|
|
428
435
|
*/
|
|
429
436
|
runStreaming(options: PipeProcessOptions): PipeProcess {
|
|
430
437
|
const args = options.args ?? [];
|
|
431
|
-
|
|
438
|
+
// Task 0086 R14: no lifetime span here — the empty fire-and-forget span
|
|
439
|
+
// carried no data and confused span consumers. Registry + events cover it.
|
|
432
440
|
const startedAt = Date.now();
|
|
433
441
|
const startedIso = new Date(startedAt).toISOString();
|
|
434
442
|
// Begin registry before spawn so failed spawns still appear (then complete as error).
|
|
@@ -680,7 +688,7 @@ export const ProcessExecutor = NodeProcessExecutor;
|
|
|
680
688
|
* Synchronous process execution is no longer recommended from ts-runtime.
|
|
681
689
|
* This class is kept for backward compatibility.
|
|
682
690
|
*/
|
|
683
|
-
export class BunSyncProcessExecutor {
|
|
691
|
+
export class BunSyncProcessExecutor implements SyncProcessExecutor {
|
|
684
692
|
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult {
|
|
685
693
|
const args = options.args ?? [];
|
|
686
694
|
const startedAt = Date.now();
|
|
@@ -710,6 +718,49 @@ export class BunSyncProcessExecutor {
|
|
|
710
718
|
}
|
|
711
719
|
}
|
|
712
720
|
|
|
721
|
+
/**
|
|
722
|
+
* Sync process execution surface shared by the sync executors (task 0087 R7).
|
|
723
|
+
* Not deprecated — the deprecated part is the Bun-specific default, not the shape.
|
|
724
|
+
*/
|
|
725
|
+
export interface SyncProcessExecutor {
|
|
726
|
+
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Sync executor on `node:child_process.spawnSync` — the non-deprecated default
|
|
731
|
+
* for sync callers (task 0087 R7). Contract-compatible with
|
|
732
|
+
* {@link BunSyncProcessExecutor} for success and non-zero exits. One deliberate
|
|
733
|
+
* divergence: a spawn failure (e.g. missing binary) maps to exitCode 1 instead of
|
|
734
|
+
* throwing, so `getGitContextSync`-style probes see a plain non-zero exit.
|
|
735
|
+
*/
|
|
736
|
+
export class NodeSyncProcessExecutor implements SyncProcessExecutor {
|
|
737
|
+
runSync(options: Omit<ProcessOptions, 'timeout'>): ProcessResult {
|
|
738
|
+
const args = options.args ?? [];
|
|
739
|
+
const startedAt = Date.now();
|
|
740
|
+
const result = spawnSync(options.command, args, {
|
|
741
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
742
|
+
...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
|
|
743
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
744
|
+
});
|
|
745
|
+
const exitCode = result.status ?? 1;
|
|
746
|
+
if (options.rejectOnError === true && exitCode !== 0) {
|
|
747
|
+
throw new Error(
|
|
748
|
+
`${options.command} ${args.join(' ')} failed with exit code ${exitCode}: ${stripFinalNewline(
|
|
749
|
+
asString(result.stderr),
|
|
750
|
+
)}`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
command: options.command,
|
|
755
|
+
args,
|
|
756
|
+
exitCode,
|
|
757
|
+
stdout: stripFinalNewline(asString(result.stdout)),
|
|
758
|
+
stderr: stripFinalNewline(asString(result.stderr)),
|
|
759
|
+
durationMs: Date.now() - startedAt,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
713
764
|
/**
|
|
714
765
|
* @deprecated Use {@link ProcessExecutor.runStreaming} instead.
|
|
715
766
|
* This class is kept for backward compatibility.
|
|
@@ -769,165 +820,6 @@ function buildExecaOptions(opts: {
|
|
|
769
820
|
}
|
|
770
821
|
|
|
771
822
|
// ── 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
|
-
},
|
|
928
|
-
};
|
|
929
|
-
}
|
|
930
|
-
|
|
931
823
|
/** Classify an execa rejection on paths where the executor did not own the termination. */
|
|
932
824
|
function classifyFailedCompletion(error: {
|
|
933
825
|
timedOut?: boolean;
|
|
@@ -950,16 +842,23 @@ function observeOutput(
|
|
|
950
842
|
observer: ProcessOptions['onOutput'],
|
|
951
843
|
): void {
|
|
952
844
|
if (!stream || !observer) return;
|
|
953
|
-
|
|
845
|
+
// Task 0086 R13: one decoder per stream with `stream: true` keeps multi-byte
|
|
846
|
+
// UTF-8 sequences intact across chunk boundaries; a fresh decoder per chunk
|
|
847
|
+
// would split them into U+FFFD replacement characters.
|
|
848
|
+
const decoder = new TextDecoder();
|
|
849
|
+
const emit = (chunk: string) => {
|
|
954
850
|
try {
|
|
955
|
-
observer({
|
|
956
|
-
stream: name,
|
|
957
|
-
chunk: asString(chunk),
|
|
958
|
-
timestamp: new Date().toISOString(),
|
|
959
|
-
});
|
|
851
|
+
observer({ stream: name, chunk, timestamp: new Date().toISOString() });
|
|
960
852
|
} catch {
|
|
961
853
|
// Observability is best-effort and must never interrupt child I/O.
|
|
962
854
|
}
|
|
855
|
+
};
|
|
856
|
+
stream.on('data', (chunk: string | Uint8Array) => {
|
|
857
|
+
emit(typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }));
|
|
858
|
+
});
|
|
859
|
+
stream.on('end', () => {
|
|
860
|
+
const tail = decoder.decode();
|
|
861
|
+
if (tail.length > 0) emit(tail);
|
|
963
862
|
});
|
|
964
863
|
}
|
|
965
864
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor-owned process-group containment (task 0087 R8): deadline resolution,
|
|
3
|
+
* group liveness probes, SIGTERM→SIGKILL escalation, and reap lifecycle for
|
|
4
|
+
* process groups spawned by NodeProcessExecutor. Extracted from
|
|
5
|
+
* process-executor.ts as a pure move — no behavior change.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ProcessOutcome } from './process-executor';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Largest delay accepted by native timers; anything larger would overflow onto an
|
|
12
|
+
* immediate timer, so deadline/grace values beyond it are rejected before spawn.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
15
|
+
|
|
16
|
+
/** Default grace between the owned group SIGTERM and the SIGKILL escalation. */
|
|
17
|
+
const DEFAULT_KILL_GRACE_MS = 5000;
|
|
18
|
+
|
|
19
|
+
/** Poll cadence while waiting for an owned process group to empty. */
|
|
20
|
+
const GROUP_POLL_INTERVAL_MS = 25;
|
|
21
|
+
|
|
22
|
+
/** Bounded patience after group SIGKILL before giving up on unkillable (D-state) members. */
|
|
23
|
+
const GROUP_KILL_PATIENCE_MS = 2000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the effective deadline: omitted inherits the executor default (which may itself
|
|
27
|
+
* be unlimited), explicit `null` is unlimited and overrides any finite default, and a
|
|
28
|
+
* finite value must be a positive integer within the native timer range. Invalid values
|
|
29
|
+
* are rejected before spawn — never clamped and never overflowed onto a timer.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveDeadline(
|
|
32
|
+
optionTimeout: number | null | undefined,
|
|
33
|
+
defaultTimeout: number | null | undefined,
|
|
34
|
+
): number | null | undefined {
|
|
35
|
+
if (optionTimeout === null) return null;
|
|
36
|
+
const candidate = optionTimeout === undefined ? defaultTimeout : optionTimeout;
|
|
37
|
+
if (candidate === undefined || candidate === null) return candidate;
|
|
38
|
+
if (!Number.isInteger(candidate) || candidate <= 0 || candidate > MAX_TIMEOUT_MS) {
|
|
39
|
+
throw new TypeError(
|
|
40
|
+
`Process timeout must be a positive integer within the native timer range (1-${MAX_TIMEOUT_MS} ms), got ${String(candidate)}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return candidate;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the SIGTERM→SIGKILL escalation grace: omitted uses {@link DEFAULT_KILL_GRACE_MS},
|
|
48
|
+
* `0` escalates immediately, and anything outside the native timer range is rejected.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveKillGraceMs(optionGraceMs: number | undefined): number {
|
|
51
|
+
if (optionGraceMs === undefined) return DEFAULT_KILL_GRACE_MS;
|
|
52
|
+
if (!Number.isInteger(optionGraceMs) || optionGraceMs < 0 || optionGraceMs > MAX_TIMEOUT_MS) {
|
|
53
|
+
throw new TypeError(
|
|
54
|
+
`Process killGraceMs must be a non-negative integer within the native timer range (0-${MAX_TIMEOUT_MS} ms), got ${String(optionGraceMs)}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return optionGraceMs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether any member of the owned process group is still alive (signal 0 probe on `-pid`). */
|
|
61
|
+
function groupExists(pid: number): boolean {
|
|
62
|
+
try {
|
|
63
|
+
process.kill(-pid, 0);
|
|
64
|
+
return true;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// EPERM means the group exists but is not signallable from here.
|
|
67
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Signal the owned process group; `false` when the group is already gone (ESRCH). */
|
|
72
|
+
function signalGroup(pid: number, signal: NodeJS.Signals | number): boolean {
|
|
73
|
+
try {
|
|
74
|
+
process.kill(-pid, signal);
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function waitForGroupExit(pid: number, budgetMs: number): Promise<boolean> {
|
|
82
|
+
const giveUpAt = Date.now() + budgetMs;
|
|
83
|
+
while (groupExists(pid)) {
|
|
84
|
+
if (Date.now() >= giveUpAt) return false;
|
|
85
|
+
await new Promise<void>((resolve) => setTimeout(resolve, GROUP_POLL_INTERVAL_MS));
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The one termination sequence for an owned Unix process group: group SIGTERM, wait the
|
|
92
|
+
* grace for the whole group to empty, then group SIGKILL and a bounded settle wait.
|
|
93
|
+
* Group liveness — not direct-child exit — decides completion, so escalation is never
|
|
94
|
+
* cancelled because the leader exited first while descendants retain pipes or locks.
|
|
95
|
+
* Resolves `true` only when a group signal was actually delivered.
|
|
96
|
+
*/
|
|
97
|
+
async function reapProcessGroup(pid: number, graceMs: number): Promise<boolean> {
|
|
98
|
+
if (!signalGroup(pid, 'SIGTERM')) return false;
|
|
99
|
+
if (await waitForGroupExit(pid, graceMs)) return true;
|
|
100
|
+
signalGroup(pid, 'SIGKILL');
|
|
101
|
+
await waitForGroupExit(pid, GROUP_KILL_PATIENCE_MS);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Handle for the executor-owned termination of one spawned process group. */
|
|
106
|
+
export interface ProcessGroupOwnership {
|
|
107
|
+
/**
|
|
108
|
+
* Settle the run's termination: resolve the outcome delivered by the executor's own
|
|
109
|
+
* path (deadline `timeout` or external `cancelled`) after the escalation completed, or
|
|
110
|
+
* reap surviving owned descendants after a natural leader exit (outcome stays `exit`).
|
|
111
|
+
* Call exactly once, after the execa promise has settled; bounded, never hangs.
|
|
112
|
+
*/
|
|
113
|
+
finish(): Promise<ProcessOutcome | undefined>;
|
|
114
|
+
/** Release the deadline timer and abort listener once the execa promise has settled. */
|
|
115
|
+
stop(): void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Own one detached child's process group on Unix. A deadline timer and the caller's abort
|
|
120
|
+
* signal both feed a single termination path — group SIGTERM, grace, SIGKILL — and after a
|
|
121
|
+
* natural leader exit surviving owned descendants are reaped with the same sequence, so
|
|
122
|
+
* completion cannot leak group members holding inherited pipes or database write locks.
|
|
123
|
+
*/
|
|
124
|
+
export function ownProcessGroupLifecycle(ownership: {
|
|
125
|
+
pid: number;
|
|
126
|
+
deadlineMs: number | undefined;
|
|
127
|
+
signal: AbortSignal | undefined;
|
|
128
|
+
graceMs: number;
|
|
129
|
+
}): ProcessGroupOwnership {
|
|
130
|
+
const { pid, graceMs } = ownership;
|
|
131
|
+
let trigger: 'timeout' | 'cancelled' | undefined;
|
|
132
|
+
let termination: Promise<boolean> | undefined;
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
134
|
+
|
|
135
|
+
const initiate = (reason: 'timeout' | 'cancelled'): void => {
|
|
136
|
+
if (trigger !== undefined) return;
|
|
137
|
+
trigger = reason;
|
|
138
|
+
termination = reapProcessGroup(pid, graceMs);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (ownership.deadlineMs !== undefined) {
|
|
142
|
+
timer = setTimeout(() => initiate('timeout'), ownership.deadlineMs);
|
|
143
|
+
}
|
|
144
|
+
const onAbort = (): void => initiate('cancelled');
|
|
145
|
+
if (ownership.signal !== undefined) {
|
|
146
|
+
if (ownership.signal.aborted) onAbort();
|
|
147
|
+
else ownership.signal.addEventListener('abort', onAbort, { once: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
finish: async () => {
|
|
152
|
+
if (trigger === undefined) {
|
|
153
|
+
// Natural completion: reap stragglers (no-op when the group is already empty).
|
|
154
|
+
await reapProcessGroup(pid, graceMs);
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
// An undeliverable trigger (group already gone — the child finished first)
|
|
158
|
+
// yields undefined so the outcome falls back to the natural classification.
|
|
159
|
+
return (await termination) ? trigger : undefined;
|
|
160
|
+
},
|
|
161
|
+
stop: () => {
|
|
162
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
163
|
+
ownership.signal?.removeEventListener('abort', onAbort);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|