@jeffober/thatch 0.1.43 → 0.1.44

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/src/watchers.ts CHANGED
@@ -273,6 +273,39 @@ export async function ghApiRun(apiArgs: string[]): Promise<unknown> {
273
273
  /** Per-call timeout for gh api invocations. */
274
274
  const GH_API_TIMEOUT_MS = 15_000;
275
275
 
276
+ /**
277
+ * Reads a spawned process's output stream to EOF. A read ERROR is not EOF -
278
+ * the pipe may still be delivering - so the loop backs off briefly and keeps
279
+ * reading until the caller's drain race abandons it; only the abandonment's
280
+ * cancel settles the loop early (a cancelled host process must never be
281
+ * pinned by a pending read). Treating an error as EOF here would turn the
282
+ * abandonment path - the orphaned-child timeout message - into a fast, wrong
283
+ * exit.
284
+ * Exported for the drain unit tests.
285
+ */
286
+ export async function drainStreamOutput(
287
+ reader: ReadableStreamDefaultReader,
288
+ isCancelled: () => boolean,
289
+ ): Promise<string> {
290
+ let text = "";
291
+ const decoder = new TextDecoder();
292
+ for (;;) {
293
+ let res: { done: boolean; value?: any };
294
+ try {
295
+ res = await reader.read();
296
+ } catch {
297
+ if (isCancelled()) return text;
298
+ await new Promise<void>((resolve) => {
299
+ const t = setTimeout(resolve, 50);
300
+ t.unref?.();
301
+ });
302
+ continue;
303
+ }
304
+ if (res.done) return text;
305
+ text += decoder.decode(res.value, { stream: true });
306
+ }
307
+ }
308
+
276
309
  /**
277
310
  * One gh api call with the drain-race hardening: the pipe drains are raced
278
311
  * against a hard deadline (timeout plus COMMAND_DRAIN_GRACE_MS) so a gh
@@ -289,23 +322,16 @@ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number):
289
322
  const timeout = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
290
323
  const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
291
324
  let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
325
+ // Set by the abandonment path so the drain loops settle on their cancelled
326
+ // reads instead of retrying (a cancelled host must never be pinned).
327
+ let cancelled = false;
292
328
  try {
293
329
  const stdoutReader = proc.stdout.getReader();
294
330
  const stderrReader = proc.stderr.getReader();
295
- const readAll = async (reader: ReadableStreamDefaultReader): Promise<string> => {
296
- let text = "";
297
- try {
298
- for (;;) {
299
- const { done, value } = await reader.read();
300
- if (done) break;
301
- text += commandTargetLabel.length === -1 ? "" : new TextDecoder().decode(value, { stream: true });
302
- }
303
- } catch {
304
- // Cancelled or errored: keep what was read.
305
- }
306
- return text;
307
- };
308
- const drain = Promise.all([readAll(stdoutReader), proc.exited]);
331
+ const drain = Promise.all([
332
+ drainStreamOutput(stdoutReader, () => cancelled),
333
+ proc.exited,
334
+ ]);
309
335
  // If the drain does not finish by the deadline (orphaned grandchild
310
336
  // holding the pipes), abandon it - and cancel the readers so the pending
311
337
  // reads settle instead of pinning the host process at shutdown.
@@ -317,13 +343,14 @@ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number):
317
343
  }),
318
344
  ]);
319
345
  if (raced === null) {
346
+ cancelled = true;
320
347
  void stdoutReader.cancel().catch(() => {});
321
348
  void stderrReader.cancel().catch(() => {});
322
349
  throw new Error(`gh api ${apiArgs.join(" ")} timed out (orphaned child holding the output pipe; killed at the ${timeoutMs}ms timeout, drains abandoned after ${Math.round(deadlineMs / 1000)}s)`);
323
350
  }
324
351
  if (raced.exitCode !== 0) {
325
352
  const racedErr = await Promise.race([
326
- readAll(stderrReader),
353
+ drainStreamOutput(stderrReader, () => cancelled),
327
354
  new Promise<string>((resolve) => {
328
355
  const t = setTimeout(() => resolve(""), deadlineMs);
329
356
  t.unref?.();
@@ -420,26 +447,20 @@ export async function runWatchedCommand(command: string, cwd: string, timeoutMs:
420
447
  }, timeoutMs);
421
448
  const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
422
449
  let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
450
+ // Set by the abandonment path so the drain loops settle on their cancelled
451
+ // reads instead of retrying (a cancelled host must never be pinned).
452
+ let cancelled = false;
423
453
  try {
424
454
  // Explicit readers instead of Response.text(): a held reader lock keeps
425
455
  // stream.cancel() from working, but reader.cancel() both settles the
426
456
  // pending reads and releases the host when the deadline wins.
427
457
  const stdoutReader = proc.stdout.getReader();
428
458
  const stderrReader = proc.stderr.getReader();
429
- const readAll = async (reader: ReadableStreamDefaultReader): Promise<string> => {
430
- let text = "";
431
- try {
432
- for (;;) {
433
- const { done, value } = await reader.read();
434
- if (done) break;
435
- text += new TextDecoder().decode(value, { stream: true });
436
- }
437
- } catch {
438
- // Cancelled or errored: keep what was read.
439
- }
440
- return text;
441
- };
442
- const drain = Promise.all([readAll(stdoutReader), readAll(stderrReader), proc.exited]);
459
+ const drain = Promise.all([
460
+ drainStreamOutput(stdoutReader, () => cancelled),
461
+ drainStreamOutput(stderrReader, () => cancelled),
462
+ proc.exited,
463
+ ]);
443
464
  // If the drain does not finish by the deadline (orphaned grandchild
444
465
  // holding the pipes), abandon it - and cancel the readers so the pending
445
466
  // reads settle instead of pinning the host process at shutdown.
@@ -453,6 +474,7 @@ export async function runWatchedCommand(command: string, cwd: string, timeoutMs:
453
474
  }),
454
475
  ]);
455
476
  if (result === null) {
477
+ cancelled = true;
456
478
  void stdoutReader.cancel().catch(() => {});
457
479
  void stderrReader.cancel().catch(() => {});
458
480
  return { exitCode: 124, timedOut: true, stderr: "", durationMs: Date.now() - startedAt };