File size: 4,724 Bytes
c0af099
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { spawnSync } from "node:child_process";
import process from "node:process";

/**
 * Return true while Node still considers the child process active.
 *
 * Do not use ChildProcess#killed for cleanup decisions. In Node, `killed`
 * only means a signal was sent successfully; it does not mean the process has
 * exited. That distinction matters for dev launchers because uvx/npm
 * wrappers can receive SIGTERM while their long-running child process keeps
 * serving on the original port.
 */
export function isProcessRunning(proc) {
  return proc.exitCode === null && proc.signalCode === null;
}

/**
 * Add spawn options needed for safe service launches and process-tree cleanup.
 *
 * Arguments must bypass shell parsing so values such as version constraints
 * containing `<` are forwarded literally. Callers that need shell behavior
 * must invoke the shell explicitly as the command.
 *
 * On POSIX, `detached: true` makes the spawned service the leader of a new
 * process group. Later we can signal `-pid` to terminate that whole group,
 * including wrapper chains like:
 *
 *   launcher -> uvx -> python agent-server
 *   launcher -> npm -> sh -> Vite
 *
 * Windows does not support POSIX process groups, so callers fall back to
 * signaling the direct child process there.
 */
export function getProcessTreeSpawnOptions(options = {}) {
  return {
    ...options,
    shell: false,
    detached: process.platform !== "win32",
  };
}

/**
 * Resolve a service command to a directly spawnable target on Windows.
 *
 * Services spawn without a shell so argument values reach the child verbatim.
 * Spawning `uvx` via cmd.exe instead makes it parse the args: a constraint like
 * `agent-client-protocol<0.11` is read as `<` input redirection and the spawn
 * dies with "The system cannot find the file specified." Resolving to an
 * absolute path lets callers spawn it shell-free.
 *
 * Returns `command` unchanged off Windows, when already a path, or if the lookup
 * fails.
 */
export function resolveWindowsCommand(
  command,
  platform = process.platform,
  lookup = whereCommandLookup,
) {
  if (platform !== "win32") {
    return command;
  }
  if (command.includes("/") || command.includes("\\")) {
    return command;
  }
  return lookup(command) || command;
}

function whereCommandLookup(command) {
  const result = spawnSync("where.exe", [command], { encoding: "utf8" });
  if (result.status !== 0 || !result.stdout) {
    return null;
  }
  return result.stdout.split(/\r?\n/).find(Boolean)?.trim() || null;
}

/**
 * Signal the whole spawned service tree when possible.
 *
 * POSIX `process.kill(-pid, signal)` targets the process group whose id is
 * `pid`; this only works because services are spawned with
 * `getProcessTreeSpawnOptions()`. Without the negative pid, shutdown would
 * often stop only the wrapper process and leave the actual server child
 * listening on its port.
 */
export function signalProcessTree(proc, signal) {
  if (!isProcessRunning(proc)) {
    return false;
  }

  try {
    if (process.platform === "win32" && proc.pid) {
      killWindowsProcessTree(proc, signal);
    } else if (!proc.pid) {
      proc.kill(signal);
    } else {
      process.kill(-proc.pid, signal);
    }
    return true;
  } catch (err) {
    if (err?.code === "ESRCH") {
      return false;
    }
    throw err;
  }
}

/**
 * Windows has no POSIX process groups: ChildProcess#kill reaches only the
 * direct child (e.g. the uvx wrapper), leaving grandchildren — the actual
 * python agent-server holding its port — running. `taskkill /t` walks the
 * child tree instead. Windows also has no graceful tree signal (taskkill
 * without /f posts WM_CLOSE, which console processes ignore), so SIGTERM and
 * SIGKILL both map to the same forceful /f kill; callers' delayed SIGKILL
 * pass skips already-exited trees via isProcessRunning, so the repeat is a
 * no-op. A non-zero taskkill exit just means the tree already exited — only
 * a failure to spawn taskkill itself falls back to the direct kill.
 */
function killWindowsProcessTree(proc, signal) {
  const result = spawnSync(
    "taskkill",
    ["/pid", String(proc.pid), "/t", "/f"],
    // windowsHide avoids a console window flash when invoked from the
    // packaged (GUI) Electron process.
    { stdio: "ignore", windowsHide: true },
  );
  if (result.error) {
    proc.kill(signal);
  }
}

export function createShutdownHookRegistry(onError) {
  const hooks = new Set();

  return {
    add(hook) {
      hooks.add(hook);
      return () => hooks.delete(hook);
    },

    run() {
      for (const hook of hooks) {
        try {
          hook();
        } catch (err) {
          onError?.(err);
        }
      }
    },
  };
}