File size: 4,301 Bytes
3144483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { execa, type ResultPromise } from "execa";
import { afterEach, describe, expect, it, vi } from "vitest";
import { releaseChildProcessOutputAfterExit } from "./child-process.js";

describe.skipIf(process.platform === "win32")("releaseChildProcessOutputAfterExit", () => {
  let child: ResultPromise | undefined;

  afterEach(() => {
    if (child?.pid) {
      try {
        process.kill(-child.pid, "SIGKILL");
      } catch {}
    }
    child = undefined;
    vi.useRealTimers();
  });

  it.each([250, 1_250])(
    "drains descendant output across a %ims event-loop stall",
    async (stallMs) => {
      const command =
        'printf "HEAD\\n"; printf "HEAD\\n" >&2; ( sleep 0.05; printf "TAIL\\n"; printf "TAIL\\n" >&2 ) &';
      child = execa("/bin/sh", ["-c", command], {
        detached: true,
        reject: false,
        stdio: ["ignore", "pipe", "pipe"],
        stripFinalNewline: false,
      });
      const releaseOutput = releaseChildProcessOutputAfterExit(child.nodeChildProcess);

      // Simulate a contended worker after the direct child exits. The descendant
      // writes while JS is parked past the idle or hard deadline, so buffered
      // pipe data and release timers are ready when the event loop resumes.
      await new Promise<void>((resolve) => {
        child?.nodeChildProcess.once("exit", () => {
          Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, stallMs);
          resolve();
        });
      });
      expect(await child.finally(releaseOutput)).toMatchObject({
        exitCode: 0,
        stdout: "HEAD\nTAIL\n",
        stderr: "HEAD\nTAIL\n",
      });
    },
  );

  it("releases a quiet inherited pipe after the idle grace", async () => {
    child = execa("/bin/sh", ["-c", 'printf "DONE\\n"; ( sleep 30 ) &'], {
      buffer: false,
      detached: true,
      reject: false,
      stdio: ["ignore", "pipe", "pipe"],
    });
    const releaseOutput = releaseChildProcessOutputAfterExit(child.nodeChildProcess);
    let output = "";
    child.stdout?.on("data", (chunk: Buffer) => {
      output += chunk.toString();
    });

    const startedAt = Date.now();
    await child.finally(releaseOutput);
    expect(output).toContain("DONE");
    expect(Date.now() - startedAt).toBeLessThan(2_000);
  });

  it.each([false, true])(
    "bounds continuously writing descendants when drain starts after exit=%s",
    async (alreadyExited) => {
      vi.useFakeTimers();
      const stdout = new PassThrough();
      const stderr = new PassThrough();
      const fakeChild = Object.assign(new EventEmitter(), {
        stdout,
        stderr,
        exitCode: alreadyExited ? 0 : null,
        signalCode: null,
      }) as unknown as ChildProcess;
      const cleanup = releaseChildProcessOutputAfterExit(fakeChild);
      if (!alreadyExited) {
        fakeChild.emit("exit", 0);
      }
      const writer = setInterval(() => stdout.write("TICK\n"), 30);

      try {
        await vi.advanceTimersByTimeAsync(999);
        expect(stdout.destroyed).toBe(false);
        await vi.advanceTimersByTimeAsync(1);
        expect(stdout.destroyed).toBe(false);
        stdout.write("AFTER DEADLINE\n");
        await vi.advanceTimersByTimeAsync(1);
        expect(stdout.destroyed).toBe(true);
        expect(stderr.destroyed).toBe(true);
      } finally {
        clearInterval(writer);
        cleanup();
      }
    },
  );

  it.each(["idle", "hard"])("cancels pending %s release when cleaned up", async (deadline) => {
    vi.useFakeTimers();
    const stdout = new PassThrough();
    const stderr = new PassThrough();
    const fakeChild = Object.assign(new EventEmitter(), {
      stdout,
      stderr,
    }) as unknown as ChildProcess;
    const cleanup = releaseChildProcessOutputAfterExit(fakeChild);
    fakeChild.emit("exit", 0);
    const writer = deadline === "hard" ? setInterval(() => stdout.write("TICK\n"), 30) : undefined;

    await vi.advanceTimersByTimeAsync(deadline === "hard" ? 1_000 : 100);
    cleanup();
    clearInterval(writer);
    await vi.runAllTimersAsync();
    expect(stdout.destroyed).toBe(false);
    expect(stderr.destroyed).toBe(false);
  });
});