@twelvehart/orcats 0.2.3 → 0.3.0

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.
Files changed (35) hide show
  1. package/README.md +3 -3
  2. package/dist/backends/claude-run.d.ts.map +1 -1
  3. package/dist/backends/codex-jsonl.d.ts +2 -1
  4. package/dist/backends/codex-jsonl.d.ts.map +1 -1
  5. package/dist/backends/codex-run.d.ts +2 -1
  6. package/dist/backends/codex-run.d.ts.map +1 -1
  7. package/dist/backends/codex.d.ts.map +1 -1
  8. package/dist/backends/pi-run.d.ts.map +1 -1
  9. package/dist/backends/select.d.ts +1 -1
  10. package/dist/backends/select.d.ts.map +1 -1
  11. package/dist/backends/subprocess-run.d.ts +4 -5
  12. package/dist/backends/subprocess-run.d.ts.map +1 -1
  13. package/dist/backends/subprocess-termination.d.ts +4 -0
  14. package/dist/backends/subprocess-termination.d.ts.map +1 -0
  15. package/dist/cli/version.d.ts +1 -1
  16. package/dist/conversation/conversation.d.ts +2 -0
  17. package/dist/conversation/conversation.d.ts.map +1 -1
  18. package/dist/conversation/settlement-reservation.d.ts +10 -0
  19. package/dist/conversation/settlement-reservation.d.ts.map +1 -0
  20. package/dist/model/backend-config.d.ts +2 -0
  21. package/dist/model/backend-config.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/backends/claude-run.ts +7 -2
  24. package/src/backends/codex-jsonl.ts +5 -0
  25. package/src/backends/codex-run.ts +15 -1
  26. package/src/backends/codex.ts +4 -1
  27. package/src/backends/pi-run.ts +8 -2
  28. package/src/backends/select.ts +4 -1
  29. package/src/backends/subprocess-run.ts +534 -58
  30. package/src/backends/subprocess-termination.ts +56 -0
  31. package/src/cli/main.ts +2 -1
  32. package/src/cli/version.ts +1 -1
  33. package/src/conversation/conversation.ts +92 -7
  34. package/src/conversation/settlement-reservation.ts +127 -0
  35. package/src/model/backend-config.ts +8 -0
@@ -4,6 +4,15 @@ import {
4
4
  type BackendTag
5
5
  } from "../model/index.ts";
6
6
  import type { StreamConversation } from "../conversation/index.ts";
7
+ import {
8
+ observeConversationCancellationCompletion,
9
+ reportConversationCancellationFailure,
10
+ reserveConversationSettlement
11
+ } from "../conversation/settlement-reservation.ts";
12
+ import {
13
+ registerSubprocessExitWaitCancellation,
14
+ terminateSubprocess
15
+ } from "./subprocess-termination.ts";
7
16
 
8
17
  const DefaultSubprocessInactivityTimeoutMs = 120_000;
9
18
 
@@ -79,17 +88,35 @@ interface SubprocessTimeout {
79
88
  readonly kind: SubprocessTimeoutKind;
80
89
  }
81
90
 
91
+ interface SubprocessTerminationFailed {
92
+ readonly type: "termination_failed";
93
+ readonly error: unknown;
94
+ }
95
+
96
+ type SubprocessTimeoutSettlement = SubprocessTimeout | SubprocessTerminationFailed;
97
+
98
+ type SubprocessTerminal =
99
+ | { readonly type: "cancelled" }
100
+ | { readonly type: "consumer" }
101
+ | { readonly type: "timeout"; readonly settlement: SubprocessTimeoutSettlement }
102
+ | {
103
+ readonly type: "exited";
104
+ readonly consumer: SubprocessConsumer;
105
+ readonly exit: number | null;
106
+ readonly stderr: string;
107
+ }
108
+ | { readonly type: "error"; readonly error: unknown };
109
+
82
110
  /** Shared spawn → stdout-line-stream → consumer → outcome plumbing for
83
111
  * subprocess-stream backends (codex, claude, pi). Owns process spawn, line
84
112
  * splitting, stderr capture, non-zero-exit failure, cancellation checks, and
85
113
  * timeout settlement; the per-backend command/args builder and line consumer
86
114
  * plug in.
87
115
  *
88
- * The helper deliberately does NOT catch spawn / stream / consumer exceptions
89
- * each driver wraps the call in its own try/catch/finally so spawn-error→fail and
90
- * resource teardown stay synchronous with the failure (matters for deterministic
91
- * cleanup ordering; see codex's ask_user bridge). Use {@link errorMessage} +
92
- * {@link backendFailed} in that catch. */
116
+ * The helper propagates spawn / stream / consumer exceptions unless a timeout
117
+ * has already reserved settlement. In that case timeout termination owns the
118
+ * outcome. Each driver wraps propagated errors so failure and resource teardown
119
+ * stay ordered; use {@link errorMessage} + {@link backendFailed} there. */
93
120
  export async function runSubprocessConversation<B extends BackendTag>(
94
121
  options: RunSubprocessOptions<B>
95
122
  ): Promise<void> {
@@ -107,24 +134,73 @@ export async function runSubprocessConversation<B extends BackendTag>(
107
134
  }
108
135
 
109
136
  const process = spawnProcess(options.command, options.args, spawnOptions);
110
- options.setProcess?.(process);
111
137
 
112
- const stderr = collectText(process.stderr);
113
- await options.onStart?.(process);
114
-
115
- const timeout: Deferred<SubprocessTimeout> = Promise.withResolvers<SubprocessTimeout>();
138
+ const timeout: Deferred<SubprocessTimeoutSettlement> =
139
+ Promise.withResolvers<SubprocessTimeoutSettlement>();
116
140
  const inactivityMs = options.inactivityTimeoutMs ?? DefaultSubprocessInactivityTimeoutMs;
117
141
  const wallClockMs = options.wallClockTimeoutMs ?? DefaultSubprocessWallClockTimeoutMs;
118
- let timeoutSettled = false;
142
+ let streamTeardownDeadline: number | undefined;
143
+ let timeoutStarted = false;
144
+ let releaseTimeoutSettlement: (() => void) | undefined;
119
145
  let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
120
146
 
121
147
  const settleTimeout = (kind: SubprocessTimeoutKind): void => {
122
- if (timeoutSettled) {
148
+ if (timeoutStarted) {
149
+ return;
150
+ }
151
+ timeoutStarted = true;
152
+ releaseTimeoutSettlement = reserveConversationSettlement(conversation);
153
+ void terminateSubprocess(process).then(
154
+ () => {
155
+ timeout.resolve({ type: "timeout", kind });
156
+ },
157
+ (error: unknown) => {
158
+ timeout.resolve({ type: "termination_failed", error });
159
+ }
160
+ );
161
+ };
162
+ const hasTimeoutStarted = (): boolean => timeoutStarted;
163
+ const isConversationAborted = (): boolean => conversation.signal.aborted;
164
+ const awaitStreamTeardown = async <T>(operation: PromiseLike<T> | T): Promise<T> => {
165
+ streamTeardownDeadline ??= Date.now() + Math.max(wallClockMs, 0);
166
+ const remainingMs = streamTeardownDeadline - Date.now();
167
+ let timer: ReturnType<typeof setTimeout> | undefined;
168
+ const deadline = new Promise<never>((_resolve, reject) => {
169
+ const fail = (): void => {
170
+ reject(
171
+ new Error(
172
+ `subprocess stream teardown exceeded ${String(wallClockMs)}ms wall-clock limit`
173
+ )
174
+ );
175
+ };
176
+ if (remainingMs <= 0) {
177
+ fail();
178
+ return;
179
+ }
180
+ timer = setTimeout(fail, remainingMs);
181
+ });
182
+ try {
183
+ return await Promise.race([Promise.resolve(operation), deadline]);
184
+ } finally {
185
+ clearTimeout(timer);
186
+ }
187
+ };
188
+ const failStreamCleanup = (error: unknown): void => {
189
+ if (
190
+ isConversationAborted() &&
191
+ reportConversationCancellationFailure(conversation, error)
192
+ ) {
123
193
  return;
124
194
  }
125
- timeoutSettled = true;
126
- process.kill("SIGTERM");
127
- timeout.resolve({ type: "timeout", kind });
195
+ conversation.fail(backendFailed(backend, errorMessage(error)));
196
+ };
197
+ const settleSubprocessTimeout = (settlement: SubprocessTimeoutSettlement): void => {
198
+ if (settlement.type === "termination_failed") {
199
+ conversation.fail(backendFailed(backend, errorMessage(settlement.error)));
200
+ } else {
201
+ failSubprocessTimeout(conversation, backend, settlement.kind, inactivityMs, wallClockMs);
202
+ }
203
+ releaseTimeoutSettlement?.();
128
204
  };
129
205
  const resetInactivityTimer = (): void => {
130
206
  clearTimeout(inactivityTimer);
@@ -138,52 +214,265 @@ export async function runSubprocessConversation<B extends BackendTag>(
138
214
  }, Math.max(wallClockMs, 0));
139
215
  resetInactivityTimer();
140
216
 
141
- try {
142
- const consumer = options.createConsumer();
143
- const lines = splitLines(process.stdout)[Symbol.asyncIterator]();
217
+ const releaseRunSettlement = reserveConversationSettlement(conversation);
218
+ const conversationAborted = Symbol("conversation aborted during subprocess work");
219
+ const observedConversationAbort = Promise.withResolvers<typeof conversationAborted>();
220
+ const onConversationAbort = (): void => {
221
+ observedConversationAbort.resolve(conversationAborted);
222
+ };
223
+ if (conversation.signal.aborted) {
224
+ onConversationAbort();
225
+ } else {
226
+ conversation.signal.addEventListener("abort", onConversationAbort, { once: true });
227
+ }
228
+ const conversationSettled = Symbol("conversation settled during subprocess read");
229
+ const observedConversationSettlement: Promise<typeof conversationSettled> =
230
+ conversation.awaitResult().then(() => conversationSettled);
231
+ const observedCancellationCompletion =
232
+ observeConversationCancellationCompletion(conversation);
233
+ let stderr: TextCollector | undefined;
234
+ let stdoutIterator: AsyncIterator<string | Uint8Array> | undefined;
235
+ let lines: AsyncIterator<string> | undefined;
236
+
237
+ const cancelStdout = async (): Promise<void> => {
238
+ const cleanupErrors: unknown[] = [];
239
+ try {
240
+ if (isDestroyable(process.stdout)) {
241
+ process.stdout.destroy();
242
+ }
243
+ } catch (error) {
244
+ cleanupErrors.push(error);
245
+ }
246
+ try {
247
+ await awaitStreamTeardown(stdoutIterator?.return?.());
248
+ } catch (error) {
249
+ cleanupErrors.push(error);
250
+ }
251
+ try {
252
+ await awaitStreamTeardown(lines?.return?.());
253
+ } catch (error) {
254
+ cleanupErrors.push(error);
255
+ }
256
+ if (cleanupErrors.length > 0) {
257
+ throw cleanupErrors[0];
258
+ }
259
+ };
260
+ const cancelStderr = async (): Promise<void> => {
261
+ if (stderr) {
262
+ await awaitStreamTeardown(stderr.cancel());
263
+ return;
264
+ }
265
+ if (process.stderr && isDestroyable(process.stderr)) {
266
+ process.stderr.destroy();
267
+ }
268
+ };
269
+ const cleanupStreams = async (): Promise<readonly unknown[]> => {
270
+ const cleanupErrors: unknown[] = [];
271
+ await Promise.all([
272
+ cancelStdout().catch((error: unknown) => {
273
+ cleanupErrors.push(error);
274
+ }),
275
+ cancelStderr().catch((error: unknown) => {
276
+ cleanupErrors.push(error);
277
+ })
278
+ ]);
279
+ return cleanupErrors;
280
+ };
281
+ const awaitConversationStop = async (): Promise<void> => {
282
+ if (isConversationAborted()) {
283
+ await observedCancellationCompletion;
284
+ return;
285
+ }
286
+ await Promise.race([
287
+ process.exit.catch(() => null),
288
+ observedConversationSettlement
289
+ ]);
290
+ };
291
+ const terminateAndCleanup = async (
292
+ completeStderr: boolean
293
+ ): Promise<readonly unknown[]> => {
294
+ const cleanupErrors: unknown[] = [];
295
+ let terminationSucceeded = false;
296
+ try {
297
+ await terminateSubprocess(process);
298
+ terminationSucceeded = true;
299
+ } catch (error) {
300
+ cleanupErrors.push(error);
301
+ }
302
+ try {
303
+ await cancelStdout();
304
+ } catch (error) {
305
+ cleanupErrors.push(error);
306
+ }
307
+ if (completeStderr && terminationSucceeded && stderr) {
308
+ try {
309
+ await awaitStreamTeardown(stderr.result);
310
+ } catch (error) {
311
+ cleanupErrors.push(error);
312
+ }
313
+ } else {
314
+ try {
315
+ await cancelStderr();
316
+ } catch (error) {
317
+ cleanupErrors.push(error);
318
+ }
319
+ }
320
+ return cleanupErrors;
321
+ };
322
+ const observeSubprocess = async (
323
+ consumer: SubprocessConsumer,
324
+ lineIterator: AsyncIterator<string>,
325
+ stderrCollector: TextCollector
326
+ ): Promise<SubprocessTerminal> => {
144
327
  for (;;) {
145
- const next: IteratorResult<string> | SubprocessTimeout = await Promise.race([lines.next(), timeout.promise]);
146
- if (isSubprocessTimeout(next)) {
147
- failSubprocessTimeout(conversation, backend, next.kind, inactivityMs, wallClockMs);
148
- return;
328
+ const next:
329
+ | IteratorResult<string>
330
+ | SubprocessTimeoutSettlement
331
+ | typeof conversationAborted
332
+ | typeof conversationSettled = await Promise.race([
333
+ lineIterator.next(),
334
+ timeout.promise,
335
+ observedConversationAbort.promise,
336
+ observedConversationSettlement
337
+ ]);
338
+ if (next === conversationAborted || next === conversationSettled) {
339
+ return { type: "cancelled" };
340
+ }
341
+ if (isSubprocessTimeoutSettlement(next)) {
342
+ return { type: "timeout", settlement: next };
343
+ }
344
+ if (hasTimeoutStarted()) {
345
+ return { type: "timeout", settlement: await timeout.promise };
149
346
  }
150
347
  if (next.done) {
151
348
  break;
152
349
  }
153
350
  resetInactivityTimer();
154
- if (conversation.signal.aborted) {
155
- return;
351
+ if (isConversationAborted()) {
352
+ return { type: "cancelled" };
353
+ }
354
+ const consumed: undefined | SubprocessTimeoutSettlement | typeof conversationAborted =
355
+ await Promise.race([
356
+ consumer.consume(next.value).then(() => undefined),
357
+ timeout.promise,
358
+ observedConversationAbort.promise
359
+ ]);
360
+ if (consumed === conversationAborted) {
361
+ return { type: "cancelled" };
362
+ }
363
+ if (consumed !== undefined) {
364
+ return { type: "timeout", settlement: consumed };
365
+ }
366
+ if (hasTimeoutStarted()) {
367
+ return { type: "timeout", settlement: await timeout.promise };
368
+ }
369
+ if (isConversationAborted()) {
370
+ return { type: "cancelled" };
156
371
  }
157
- await consumer.consume(next.value);
158
372
  if (consumer.signal.aborted) {
159
- // Kill the child once the consumer has settled the conversation on a
160
- // terminal event (success, modeled failure, or early parse/tool error).
161
- // Safe because we only reach here after consuming that event — the agent's
162
- // session rollout is already flushed — and it stops a persistent process
163
- // (pi rpc) or a stalled CLI from lingering after Orca has the outcome.
164
- process.kill();
165
- break;
373
+ return { type: "consumer" };
166
374
  }
167
375
  }
168
376
 
169
- // The consumer already settled the conversation (success/failure); the
170
- // exit-code path below is only for a stream that ended without one.
171
377
  if (consumer.signal.aborted) {
172
- return;
378
+ return { type: "consumer" };
173
379
  }
174
380
 
175
- const exit: number | null | SubprocessTimeout = await Promise.race([process.exit, timeout.promise]);
176
- if (isSubprocessTimeout(exit)) {
177
- failSubprocessTimeout(conversation, backend, exit.kind, inactivityMs, wallClockMs);
381
+ const exit:
382
+ | number
383
+ | null
384
+ | SubprocessTimeoutSettlement
385
+ | typeof conversationAborted
386
+ | typeof conversationSettled = await Promise.race([
387
+ process.exit,
388
+ timeout.promise,
389
+ observedConversationAbort.promise,
390
+ observedConversationSettlement
391
+ ]);
392
+ if (exit === conversationAborted || exit === conversationSettled) {
393
+ return { type: "cancelled" };
394
+ }
395
+ if (isSubprocessTimeoutSettlement(exit)) {
396
+ return { type: "timeout", settlement: exit };
397
+ }
398
+
399
+ const stderrResult:
400
+ | string
401
+ | SubprocessTimeoutSettlement
402
+ | typeof conversationAborted
403
+ | typeof conversationSettled = await Promise.race([
404
+ stderrCollector.result,
405
+ timeout.promise,
406
+ observedConversationAbort.promise,
407
+ observedConversationSettlement
408
+ ]);
409
+ if (stderrResult === conversationAborted || stderrResult === conversationSettled) {
410
+ return { type: "cancelled" };
411
+ }
412
+ if (isSubprocessTimeoutSettlement(stderrResult)) {
413
+ return { type: "timeout", settlement: stderrResult };
414
+ }
415
+ if (hasTimeoutStarted()) {
416
+ return { type: "timeout", settlement: await timeout.promise };
417
+ }
418
+ if (isConversationAborted()) {
419
+ return { type: "cancelled" };
420
+ }
421
+ return { type: "exited", consumer, exit, stderr: stderrResult };
422
+ };
423
+ const finalizeSubprocess = async (terminal: SubprocessTerminal): Promise<void> => {
424
+ clearTimeout(inactivityTimer);
425
+ clearTimeout(wallClockTimer);
426
+ streamTeardownDeadline = Date.now() + Math.max(wallClockMs, 0);
427
+
428
+ if (terminal.type === "consumer") {
429
+ const cleanupErrors = await terminateAndCleanup(true);
430
+ if (cleanupErrors.length > 0) {
431
+ failStreamCleanup(cleanupErrors[0]);
432
+ }
433
+ return;
434
+ }
435
+ if (terminal.type === "timeout") {
436
+ const cleanupErrors = await cleanupStreams();
437
+ if (cleanupErrors.length > 0) {
438
+ failStreamCleanup(cleanupErrors[0]);
439
+ }
440
+ settleSubprocessTimeout(terminal.settlement);
441
+ return;
442
+ }
443
+ if (terminal.type === "cancelled") {
444
+ await awaitConversationStop();
445
+ const cleanupErrors = await cleanupStreams();
446
+ if (cleanupErrors.length > 0) {
447
+ reportConversationCancellationFailure(conversation, cleanupErrors[0]);
448
+ }
449
+ if (hasTimeoutStarted()) {
450
+ settleSubprocessTimeout(await timeout.promise);
451
+ }
178
452
  return;
179
453
  }
180
- const stderrText = (await stderr).trim();
181
- if (conversation.signal.aborted) {
454
+ if (terminal.type === "error") {
455
+ const cleanupErrors = await terminateAndCleanup(false);
456
+ if (isConversationAborted() && cleanupErrors.length > 0) {
457
+ reportConversationCancellationFailure(conversation, cleanupErrors[0]);
458
+ }
459
+ throw terminal.error;
460
+ }
461
+ if (isConversationAborted()) {
462
+ await awaitConversationStop();
463
+ const cleanupErrors = await cleanupStreams();
464
+ if (cleanupErrors.length > 0) {
465
+ reportConversationCancellationFailure(conversation, cleanupErrors[0]);
466
+ }
467
+ if (hasTimeoutStarted()) {
468
+ settleSubprocessTimeout(await timeout.promise);
469
+ }
182
470
  return;
183
471
  }
184
472
 
185
- if (exit !== 0) {
186
- const exitCodeText = exit === null ? "unknown" : String(exit);
473
+ const stderrText = terminal.stderr.trim();
474
+ if (terminal.exit !== 0) {
475
+ const exitCodeText = terminal.exit === null ? "unknown" : String(terminal.exit);
187
476
  conversation.fail(
188
477
  backendFailed(
189
478
  backend,
@@ -192,11 +481,56 @@ export async function runSubprocessConversation<B extends BackendTag>(
192
481
  );
193
482
  return;
194
483
  }
484
+ terminal.consumer.finish();
485
+ };
195
486
 
196
- consumer.finish();
487
+ try {
488
+ let terminal: SubprocessTerminal;
489
+ try {
490
+ options.setProcess?.(process);
491
+ stderr = startTextCollector(process.stderr);
492
+ stdoutIterator = process.stdout[Symbol.asyncIterator]();
493
+ const stdoutChunks: AsyncIterable<string | Uint8Array> = {
494
+ [Symbol.asyncIterator]: () => stdoutIterator as AsyncIterator<string | Uint8Array>
495
+ };
496
+ lines = splitLines(stdoutChunks)[Symbol.asyncIterator]();
497
+ const startup:
498
+ | undefined
499
+ | SubprocessTimeoutSettlement
500
+ | typeof conversationAborted
501
+ | typeof conversationSettled =
502
+ options.onStart === undefined
503
+ ? undefined
504
+ : await Promise.race([
505
+ Promise.resolve(options.onStart(process)).then(() => undefined),
506
+ timeout.promise,
507
+ observedConversationAbort.promise,
508
+ observedConversationSettlement
509
+ ]);
510
+ if (
511
+ startup === conversationAborted ||
512
+ startup === conversationSettled ||
513
+ isConversationAborted()
514
+ ) {
515
+ terminal = { type: "cancelled" };
516
+ } else if (isSubprocessTimeoutSettlement(startup)) {
517
+ terminal = { type: "timeout", settlement: startup };
518
+ } else if (hasTimeoutStarted()) {
519
+ terminal = { type: "timeout", settlement: await timeout.promise };
520
+ } else {
521
+ terminal = await observeSubprocess(options.createConsumer(), lines, stderr);
522
+ }
523
+ } catch (error) {
524
+ terminal = hasTimeoutStarted()
525
+ ? { type: "timeout", settlement: await timeout.promise }
526
+ : { type: "error", error };
527
+ }
528
+ await finalizeSubprocess(terminal);
197
529
  } finally {
198
530
  clearTimeout(inactivityTimer);
199
531
  clearTimeout(wallClockTimer);
532
+ conversation.signal.removeEventListener("abort", onConversationAbort);
533
+ releaseRunSettlement();
200
534
  }
201
535
  }
202
536
 
@@ -206,9 +540,11 @@ export function spawnSubprocess(
206
540
  options: SubprocessSpawnOptions
207
541
  ): SubprocessProcess {
208
542
  const stdinMode = options.stdin ?? "ignore";
543
+ const useProcessGroup = process.platform !== "win32";
209
544
  const child = spawn(command, [...args], {
210
545
  cwd: options.cwd,
211
546
  env: options.env,
547
+ detached: useProcessGroup,
212
548
  stdio: [stdinMode === "pipe" ? "pipe" : "ignore", "pipe", "pipe"]
213
549
  });
214
550
 
@@ -216,16 +552,29 @@ export function spawnSubprocess(
216
552
  throw new Error(`failed to capture stdout for ${command}`);
217
553
  }
218
554
 
219
- const exit = Promise.withResolvers<number | null>();
220
- child.on("error", exit.reject);
221
- child.on("close", exit.resolve);
222
-
223
- return {
555
+ const leaderExit = Promise.withResolvers<number | null>();
556
+ child.on("error", leaderExit.reject);
557
+ child.on("close", leaderExit.resolve);
558
+ const processGroupExit =
559
+ useProcessGroup && child.pid !== undefined
560
+ ? waitForProcessGroupExit(child.pid, leaderExit.promise)
561
+ : undefined;
562
+ const subprocess: SubprocessProcess = {
224
563
  stdout: child.stdout,
225
564
  ...(child.stderr ? { stderr: child.stderr } : {}),
226
- exit: exit.promise,
565
+ exit: processGroupExit?.promise ?? leaderExit.promise,
227
566
  kill(signal?: NodeJS.Signals) {
228
- child.kill(signal);
567
+ if (!useProcessGroup || child.pid === undefined) {
568
+ child.kill(signal);
569
+ return;
570
+ }
571
+ try {
572
+ process.kill(-child.pid, signal ?? "SIGTERM");
573
+ } catch (error) {
574
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") {
575
+ throw error;
576
+ }
577
+ }
229
578
  },
230
579
  write(data: string) {
231
580
  child.stdin?.write(data);
@@ -234,6 +583,72 @@ export function spawnSubprocess(
234
583
  child.stdin?.end();
235
584
  }
236
585
  };
586
+ if (processGroupExit !== undefined) {
587
+ registerSubprocessExitWaitCancellation(subprocess, (error: unknown) => {
588
+ processGroupExit.cancel(error);
589
+ });
590
+ }
591
+ return subprocess;
592
+ }
593
+
594
+ interface ProcessGroupExitWait {
595
+ readonly promise: Promise<number | null>;
596
+ cancel(error: unknown): void;
597
+ }
598
+
599
+ function waitForProcessGroupExit(
600
+ processGroupId: number,
601
+ leaderExit: Promise<number | null>
602
+ ): ProcessGroupExitWait {
603
+ const completion = Promise.withResolvers<number | null>();
604
+ let settled = false;
605
+ let timer: ReturnType<typeof setTimeout> | undefined;
606
+ const reject = (error: unknown): void => {
607
+ if (settled) {
608
+ return;
609
+ }
610
+ settled = true;
611
+ clearTimeout(timer);
612
+ timer = undefined;
613
+ completion.reject(error);
614
+ };
615
+ const poll = (exitCode: number | null): void => {
616
+ if (settled) {
617
+ return;
618
+ }
619
+ try {
620
+ if (!isProcessGroupAlive(processGroupId)) {
621
+ settled = true;
622
+ completion.resolve(exitCode);
623
+ return;
624
+ }
625
+ } catch (error) {
626
+ reject(error);
627
+ return;
628
+ }
629
+ timer = setTimeout(() => {
630
+ timer = undefined;
631
+ poll(exitCode);
632
+ }, 10);
633
+ };
634
+ void leaderExit.then(poll, reject);
635
+ void completion.promise.catch(() => undefined);
636
+ return {
637
+ promise: completion.promise,
638
+ cancel: reject
639
+ };
640
+ }
641
+
642
+ function isProcessGroupAlive(processGroupId: number): boolean {
643
+ try {
644
+ process.kill(-processGroupId, 0);
645
+ return true;
646
+ } catch (error) {
647
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") {
648
+ return false;
649
+ }
650
+ throw error;
651
+ }
237
652
  }
238
653
 
239
654
  export async function* splitLines(
@@ -262,25 +677,86 @@ export async function* splitLines(
262
677
  export async function collectText(
263
678
  chunks: AsyncIterable<string | Uint8Array> | undefined
264
679
  ): Promise<string> {
680
+ return await startTextCollector(chunks).result;
681
+ }
682
+
683
+ interface TextCollector {
684
+ readonly result: Promise<string>;
685
+ cancel(): Promise<void>;
686
+ }
687
+
688
+ function startTextCollector(
689
+ chunks: AsyncIterable<string | Uint8Array> | undefined
690
+ ): TextCollector {
265
691
  if (!chunks) {
266
- return "";
692
+ return {
693
+ result: Promise.resolve(""),
694
+ cancel: () => Promise.resolve()
695
+ };
267
696
  }
268
697
 
269
698
  const decoder = new TextDecoder();
270
699
  const text: string[] = [];
271
- for await (const chunk of chunks) {
272
- text.push(decodeChunk(decoder, chunk));
273
- }
274
- text.push(decoder.decode());
275
- return text.join("");
700
+ const iterator = chunks[Symbol.asyncIterator]();
701
+ const result = (async (): Promise<string> => {
702
+ for (;;) {
703
+ const next = await iterator.next();
704
+ if (next.done) {
705
+ break;
706
+ }
707
+ text.push(decodeChunk(decoder, next.value));
708
+ }
709
+ text.push(decoder.decode());
710
+ return text.join("");
711
+ })();
712
+ void result.catch(() => undefined);
713
+ let cancellation: Promise<void> | undefined;
714
+
715
+ return {
716
+ result,
717
+ cancel() {
718
+ cancellation ??= (async (): Promise<void> => {
719
+ const cleanupErrors: unknown[] = [];
720
+ try {
721
+ if (isDestroyable(chunks)) {
722
+ chunks.destroy();
723
+ }
724
+ } catch (error) {
725
+ cleanupErrors.push(error);
726
+ }
727
+ try {
728
+ await iterator.return?.();
729
+ } catch (error) {
730
+ cleanupErrors.push(error);
731
+ }
732
+ if (cleanupErrors.length > 0) {
733
+ throw cleanupErrors[0];
734
+ }
735
+ })();
736
+ return cancellation;
737
+ }
738
+ };
739
+ }
740
+
741
+ function isDestroyable(value: unknown): value is { destroy(): unknown } {
742
+ return (
743
+ typeof value === "object" &&
744
+ value !== null &&
745
+ "destroy" in value &&
746
+ typeof value.destroy === "function"
747
+ );
276
748
  }
277
749
 
278
750
  function decodeChunk(decoder: TextDecoder, chunk: string | Uint8Array): string {
279
751
  return typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
280
752
  }
281
753
 
282
- function isSubprocessTimeout(value: IteratorResult<string> | number | null | SubprocessTimeout): value is SubprocessTimeout {
283
- return typeof value === "object" && value !== null && (value as { readonly type?: unknown }).type === "timeout";
754
+ function isSubprocessTimeoutSettlement(value: unknown): value is SubprocessTimeoutSettlement {
755
+ if (typeof value !== "object" || value === null) {
756
+ return false;
757
+ }
758
+ const type = (value as { readonly type?: unknown }).type;
759
+ return type === "timeout" || type === "termination_failed";
284
760
  }
285
761
 
286
762
  function failSubprocessTimeout<B extends BackendTag>(