File size: 6,746 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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import { constants as osConstants } from "node:os";
import process from "node:process";
import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js";
import { getFileLockProcessStartTime } from "../shared/pid-alive.js";
import { isChildProcessTreeAlive } from "./child-process-tree.js";
import {
  COMMAND_PROCESS_TREE_KILL_GRACE_MS,
  runOutsideCommandProcessScope,
  spawnCommand,
} from "./exec-spawn.js";
import { killProcessTree as terminateProcessTree } from "./kill-tree.js";

const WINDOWS_TASKKILL_TIMEOUT_MS = 5_000;

type TerminationChild = {
  pid?: number;
  exitCode: number | null;
  signalCode: NodeJS.Signals | null;
};

export function createCommandTerminationController(params: {
  child: TerminationChild;
  cancelController: AbortController;
  baseEnv?: NodeJS.ProcessEnv;
  env?: NodeJS.ProcessEnv;
  processTree?: { mode: "graceful" } | { mode: "force" };
  killGraceMs: number;
  killSignal?: NodeJS.Signals | number;
  isChildExited: () => boolean;
  isCommandSettled: () => boolean;
}): {
  terminate: () => boolean;
  settle: () => Promise<"normal" | "cooperative" | "forced" | "uncertain">;
} {
  let processTreeSettlement: Promise<void> | undefined;
  let cleanup: "normal" | "cooperative" | "forced" | "uncertain" = "normal";
  const originalStart =
    params.processTree && params.child.pid && process.platform !== "win32"
      ? getFileLockProcessStartTime(params.child.pid)
      : null;
  let windowsTerminationPromise: Promise<void> | undefined;

  const isDirectChildAlive = () =>
    !params.isChildExited() && params.child.exitCode == null && params.child.signalCode == null;
  const spawnTaskkill = async (args: string[]) => {
    try {
      await runOutsideCommandProcessScope(() =>
        spawnCommand([getWindowsSystem32ExePath("taskkill.exe"), ...args], {
          baseEnv: params.baseEnv,
          env: params.env,
          forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS,
          reject: false,
          stdio: "ignore",
          timeout: WINDOWS_TASKKILL_TIMEOUT_MS,
        }),
      );
    } catch {
      // Best-effort Windows cleanup still joins every attempted helper.
    }
  };
  const startWindowsTermination = (childPid: number, graceful: boolean): void => {
    const taskkills: Promise<unknown>[] = [];
    windowsTerminationPromise = (async () => {
      if (graceful) {
        taskkills.push(spawnTaskkill(["/PID", String(childPid), "/T"]));
        await new Promise<void>((resolve) => {
          const timer = setTimeout(resolve, params.killGraceMs);
          timer.unref();
        });
        if (isDirectChildAlive()) {
          taskkills.push(spawnTaskkill(["/PID", String(childPid), "/T", "/F"]));
        }
      } else {
        taskkills.push(spawnTaskkill(["/PID", String(childPid), "/T", "/F"]));
      }
      // Failed helpers still join here before root cancellation; a sibling taskkill
      // may still be enumerating descendants through that live PID.
      await Promise.allSettled(taskkills);
      if (!params.isCommandSettled()) {
        params.cancelController.abort();
      }
    })();
  };

  const terminate = (): boolean => {
    const childPid = params.child.pid;
    const directChildAlive = isDirectChildAlive();
    if (process.platform === "win32" && !directChildAlive) {
      // taskkill /T requires a live root PID. Retrying a dead, reusable PID can
      // target an unrelated tree; stronger ownership requires a spawn-time Job Object.
      return false;
    }
    if (params.processTree && typeof childPid === "number") {
      if (process.platform === "win32") {
        startWindowsTermination(childPid, params.processTree.mode !== "force");
        return true;
      }
      const force =
        params.processTree.mode === "force" ||
        params.killSignal === "SIGKILL" ||
        params.killSignal === osConstants.signals.SIGKILL;
      if (processTreeSettlement) {
        return !force;
      }
      const groupAlive = () => isChildProcessTreeAlive({ pid: childPid });
      const forceAndObserve = async () => {
        const start = getFileLockProcessStartTime(childPid);
        if (start !== null && start !== originalStart) {
          cleanup = "uncertain";
          if (isDirectChildAlive()) {
            params.cancelController.abort();
          }
          return;
        }
        cleanup = "forced";
        terminateProcessTree(childPid, { force: true, detached: true });
        const deadline = Date.now() + COMMAND_PROCESS_TREE_KILL_GRACE_MS;
        // Signal delivery is not exit. Observe only this group; never re-signal a retired PID.
        while (groupAlive()) {
          const currentStart = getFileLockProcessStartTime(childPid);
          const remaining = deadline - Date.now();
          if ((currentStart !== null && currentStart !== originalStart) || remaining <= 0) {
            cleanup = "uncertain";
            return;
          }
          await new Promise<void>((resolve) => {
            setTimeout(resolve, Math.min(25, remaining));
          });
        }
      };
      if (force) {
        processTreeSettlement = forceAndObserve();
        return false;
      }
      // Failed roots can finish without descendants. Record graceful cleanup only
      // when this invocation still owns a live or unproven tree to terminate.
      if (!directChildAlive && !groupAlive()) {
        return false;
      }
      cleanup = "cooperative";
      try {
        process.kill(-childPid, params.killSignal ?? "SIGTERM");
      } catch (error) {
        // SAFETY: Node's kill error carries errno; every non-ESRCH result stays uncertain.
        if ((error as NodeJS.ErrnoException).code !== "ESRCH") {
          cleanup = "uncertain";
        }
      }
      processTreeSettlement = new Promise<void>((resolve) => {
        const deadline = Date.now() + params.killGraceMs;
        const check = () => {
          if (!groupAlive()) {
            resolve();
            return;
          }
          if (Date.now() < deadline) {
            setTimeout(check, Math.min(25, deadline - Date.now()));
            return;
          }
          void forceAndObserve().then(resolve);
        };
        check();
      });
      return true;
    }
    if (!directChildAlive) {
      return false;
    }
    if (process.platform === "win32" && typeof childPid === "number") {
      startWindowsTermination(childPid, false);
      return true;
    }
    return false;
  };

  const settle = async (): Promise<"normal" | "cooperative" | "forced" | "uncertain"> => {
    if (windowsTerminationPromise) {
      await windowsTerminationPromise;
      return "forced";
    }
    await processTreeSettlement;
    return cleanup;
  };

  return { terminate, settle };
}