File size: 6,383 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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import { spawn } from "node:child_process";
import { once } from "node:events";
import process from "node:process";
import { describe, expect, it, vi } from "vitest";
import {
  inspectNodeWorkerProcessIdentity,
  requireNodeWorkerProcessIdentity,
  type NodeWorkerProcessIdentity,
} from "../node-host/node-worker-process-identity.js";
import { createDeferredCore } from "../shared/deferred.js";
import { runUtf8CommandWithTimeout } from "./exec.js";
import { killProcessTree } from "./kill-tree.js";

describe("runUtf8CommandWithTimeout Windows integration", () => {
  it.runIf(process.platform === "win32")(
    "closes a nested Node descendant after an inner spawnSync timeout and outer failure",
    async () => {
      const descendantSource = 'setInterval(() => {}, 1000); process.send("ready");';
      const innerSource = [
        'const { spawn } = require("node:child_process");',
        `const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendantSource)}], { stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true });`,
        'child.once("message", () => process.stdout.write(String(child.pid) + "\\n"));',
      ].join("\n");
      // Match the finalization fence's real child deadline. Each non-detached Node
      // child has libuv job ownership; this does not cover arbitrary grandchildren
      // or hosts that deny job assignment.
      const outerSource = [
        'const { spawnSync } = require("node:child_process");',
        `const result = spawnSync(process.execPath, ["-"], { input: ${JSON.stringify(innerSource)}, stdio: ["pipe", "inherit", "pipe"], timeout: 60_000, killSignal: "SIGKILL", encoding: "utf8", windowsHide: true });`,
        "if (result.error) process.stderr.write(result.error.message);",
        "process.exitCode = result.error || result.status !== 0 ? 1 : 0;",
      ].join("\n");
      const ready = createDeferredCore<NodeWorkerProcessIdentity>();
      const controller = new AbortController();
      let child: NodeWorkerProcessIdentity | undefined;
      let output = "";
      const pending = runUtf8CommandWithTimeout([process.execPath, "-e", outerSource], {
        timeoutMs: 75_000,
        killProcessTree: true,
        signal: controller.signal,
        onOutputChunk: (chunk, stream) => {
          if (stream !== "stdout" || child) {
            return;
          }
          output += chunk.toString("utf8");
          if (output.includes("\n")) {
            child = requireNodeWorkerProcessIdentity(Number(output.trim()));
            ready.resolve(child);
          }
        },
      });
      try {
        const descendant = await Promise.race([
          ready.promise,
          pending.then(() => {
            throw new Error("inner command ended without descendant readiness");
          }),
        ]);
        expect(inspectNodeWorkerProcessIdentity(descendant)).toBe("live");
        await expect(pending).resolves.toMatchObject({
          code: 1,
          termination: "exit",
          stderr: expect.stringContaining("ETIMEDOUT"),
        });
        await vi.waitFor(
          () => {
            expect(inspectNodeWorkerProcessIdentity(descendant)).toBe("dead");
          },
          { timeout: 5_000, interval: 50 },
        );
      } finally {
        controller.abort();
        await pending.catch(() => undefined);
        // Failed assertions still clean only the exact observed fixture process.
        if (child && inspectNodeWorkerProcessIdentity(child) === "live") {
          process.kill(child.pid, "SIGKILL");
          await vi.waitFor(
            () => {
              expect(inspectNodeWorkerProcessIdentity(child!)).toBe("dead");
            },
            { timeout: 5_000, interval: 50 },
          );
        }
      }
    },
    90_000,
  );

  it.runIf(process.platform === "win32")(
    "keeps truncated UTF-8 head output on a code point boundary",
    async () => {
      const result = await runUtf8CommandWithTimeout(
        [process.execPath, "-e", "process.stdout.write('a😀z'); process.stderr.write('b😀y')"],
        {
          maxOutputBytes: 3,
          outputCapture: "head",
          timeoutMs: 3_000,
        },
      );

      expect(result.stdout).toBe("a");
      expect(result.stderr).toBe("b");
      expect(result.stdoutTruncatedBytes).toBe(5);
      expect(result.stderrTruncatedBytes).toBe(5);
    },
  );

  it.runIf(process.platform === "win32")(
    "force-kills a real Windows process tree when graceful taskkill refuses it",
    async () => {
      const program = [
        'const { spawn } = require("node:child_process");',
        'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", windowsHide: true });',
        'child.once("spawn", () => process.stdout.write(String(child.pid) + "\\n"));',
        'child.once("error", () => process.exit(1));',
        "setInterval(() => {}, 1000);",
      ].join("\n");
      const parent = spawn(process.execPath, ["-e", program], {
        stdio: ["ignore", "pipe", "ignore"],
        windowsHide: true,
      });
      const parentPid = parent.pid;
      const parentStdout = parent.stdout;

      if (parentPid === undefined || parentStdout === null) {
        parent.kill();
        throw new Error("Could not start the Windows process tree");
      }

      try {
        const [output] = await once(parentStdout, "data");
        const childPid = Number.parseInt(String(output).trim(), 10);
        expect(Number.isSafeInteger(childPid)).toBe(true);
        expect(() => process.kill(parentPid, 0)).not.toThrow();
        expect(() => process.kill(childPid, 0)).not.toThrow();

        // An unforced taskkill refuses Node console processes. Cleanup must not
        // depend on this unref'd timer surviving an application shutdown.
        killProcessTree(parentPid, { graceMs: 30_000 });

        await vi.waitFor(
          () => {
            expect(() => process.kill(parentPid, 0)).toThrow();
            expect(() => process.kill(childPid, 0)).toThrow();
          },
          { timeout: 5_000, interval: 50 },
        );
      } finally {
        // The retained child handle is safe after exit; taskkill of its reusable
        // PID is not. This fixture's non-detached Node child is in its libuv job.
        parent.kill("SIGKILL");
        parentStdout.destroy();
      }
    },
    15_000,
  );
});