copilot-hub 0.1.31 → 0.1.32
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/package.json +1 -1
- package/scripts/dist/daemon.mjs +6 -24
- package/scripts/dist/process-identity.mjs +226 -0
- package/scripts/dist/supervisor.mjs +7 -26
- package/scripts/src/daemon.mts +6 -25
- package/scripts/src/process-identity.mts +296 -0
- package/scripts/src/supervisor.mts +7 -28
- package/scripts/test/process-identity.test.mjs +61 -0
package/package.json
CHANGED
package/scripts/dist/daemon.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { codexInstallPackageSpec } from "./codex-version.mjs";
|
|
9
9
|
import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
|
|
10
|
+
import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
|
|
10
11
|
import { buildCodexCompatibilityError, probeCodexVersion, resolveCodexBinForStart, resolveCompatibleInstalledCodexBin, } from "./codex-runtime.mjs";
|
|
11
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
13
|
const __dirname = path.dirname(__filename);
|
|
@@ -124,6 +125,8 @@ async function runDaemonLoop() {
|
|
|
124
125
|
pid: process.pid,
|
|
125
126
|
startedAt: new Date().toISOString(),
|
|
126
127
|
command: `${nodeBin} ${daemonScriptPath} run`,
|
|
128
|
+
executablePath: nodeBin,
|
|
129
|
+
entryScript: daemonScriptPath,
|
|
127
130
|
});
|
|
128
131
|
const state = { stopping: false, shuttingDown: false };
|
|
129
132
|
setupSignalHandlers(state);
|
|
@@ -191,8 +194,9 @@ async function stopDaemonProcess() {
|
|
|
191
194
|
console.log("[daemon] not running.");
|
|
192
195
|
return;
|
|
193
196
|
}
|
|
197
|
+
const state = readDaemonState();
|
|
194
198
|
await terminateProcess(pid);
|
|
195
|
-
if (
|
|
199
|
+
if (isManagedProcessRunning(state)) {
|
|
196
200
|
throw new Error(`Daemon did not stop cleanly (pid ${pid}).`);
|
|
197
201
|
}
|
|
198
202
|
removeDaemonState();
|
|
@@ -278,7 +282,7 @@ function getRunningDaemonPid() {
|
|
|
278
282
|
if (pid <= 0) {
|
|
279
283
|
return 0;
|
|
280
284
|
}
|
|
281
|
-
return
|
|
285
|
+
return isManagedProcessRunning(state) ? pid : 0;
|
|
282
286
|
}
|
|
283
287
|
function readDaemonState() {
|
|
284
288
|
if (!fs.existsSync(daemonStatePath)) {
|
|
@@ -301,28 +305,6 @@ function removeDaemonState() {
|
|
|
301
305
|
}
|
|
302
306
|
fs.rmSync(daemonStatePath, { force: true });
|
|
303
307
|
}
|
|
304
|
-
function normalizePid(value) {
|
|
305
|
-
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
306
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
307
|
-
return 0;
|
|
308
|
-
}
|
|
309
|
-
return pid;
|
|
310
|
-
}
|
|
311
|
-
function isProcessRunning(pid) {
|
|
312
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
313
|
-
return false;
|
|
314
|
-
}
|
|
315
|
-
try {
|
|
316
|
-
process.kill(pid, 0);
|
|
317
|
-
return true;
|
|
318
|
-
}
|
|
319
|
-
catch (error) {
|
|
320
|
-
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
|
321
|
-
return true;
|
|
322
|
-
}
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
308
|
async function terminateProcess(pid) {
|
|
327
309
|
if (process.platform === "win32") {
|
|
328
310
|
await killTreeWindows(pid);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
const PROCESS_START_TOLERANCE_MS = 120_000;
|
|
5
|
+
export function normalizePid(value) {
|
|
6
|
+
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
7
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
return pid;
|
|
11
|
+
}
|
|
12
|
+
export function isManagedProcessRunning(state, platform = process.platform) {
|
|
13
|
+
const pid = normalizePid(state?.pid);
|
|
14
|
+
if (pid <= 0) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (!isProcessRunning(pid)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
const identity = inspectProcess(pid, platform);
|
|
21
|
+
if (!identity) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return matchesManagedProcessState(state, identity, platform);
|
|
25
|
+
}
|
|
26
|
+
export function matchesManagedProcessState(state, identity, platform = process.platform) {
|
|
27
|
+
const pid = normalizePid(state?.pid);
|
|
28
|
+
if (pid <= 0 || !identity) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (normalizePid(identity.pid) !== pid) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const checks = [];
|
|
35
|
+
const expectedScript = normalizeCommandToken(state?.entryScript ?? extractScriptToken(state?.command), platform);
|
|
36
|
+
if (expectedScript) {
|
|
37
|
+
checks.push(commandLineContains(identity.commandLine, expectedScript, platform));
|
|
38
|
+
}
|
|
39
|
+
const expectedExecutablePath = normalizeCommandToken(state?.executablePath ?? extractExecutableToken(state?.command), platform);
|
|
40
|
+
if (expectedExecutablePath) {
|
|
41
|
+
const actualExecutablePath = normalizeCommandToken(identity.executablePath, platform);
|
|
42
|
+
if (actualExecutablePath) {
|
|
43
|
+
checks.push(actualExecutablePath === expectedExecutablePath ||
|
|
44
|
+
pathBasename(actualExecutablePath, platform) ===
|
|
45
|
+
pathBasename(expectedExecutablePath, platform));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
checks.push(commandLineContains(identity.commandLine, expectedExecutablePath, platform));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const expectedStartedAt = parseTimestamp(state?.startedAt);
|
|
52
|
+
const actualStartedAt = parseTimestamp(identity.startedAt);
|
|
53
|
+
if (expectedStartedAt !== null && actualStartedAt !== null) {
|
|
54
|
+
checks.push(Math.abs(actualStartedAt - expectedStartedAt) <= PROCESS_START_TOLERANCE_MS);
|
|
55
|
+
}
|
|
56
|
+
if (checks.length === 0) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return checks.every(Boolean);
|
|
60
|
+
}
|
|
61
|
+
export function isProcessRunning(pid) {
|
|
62
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
process.kill(pid, 0);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function inspectProcess(pid, platform) {
|
|
77
|
+
if (platform === "win32") {
|
|
78
|
+
return inspectWindowsProcess(pid);
|
|
79
|
+
}
|
|
80
|
+
if (platform === "linux" || platform === "darwin") {
|
|
81
|
+
return inspectPosixProcess(pid);
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function inspectWindowsProcess(pid) {
|
|
86
|
+
const script = [
|
|
87
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue`,
|
|
88
|
+
"if ($null -eq $p) { exit 3 }",
|
|
89
|
+
"$created = ''",
|
|
90
|
+
"if ($p.CreationDate) {",
|
|
91
|
+
" try { $created = $p.CreationDate.ToString('o') } catch { $created = '' }",
|
|
92
|
+
"}",
|
|
93
|
+
"[pscustomobject]@{",
|
|
94
|
+
" pid = $p.ProcessId",
|
|
95
|
+
" commandLine = $p.CommandLine",
|
|
96
|
+
" executablePath = $p.ExecutablePath",
|
|
97
|
+
" startedAt = $created",
|
|
98
|
+
"} | ConvertTo-Json -Compress",
|
|
99
|
+
].join("\n");
|
|
100
|
+
for (const shell of resolveWindowsPowerShellCandidates()) {
|
|
101
|
+
const result = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
windowsHide: true,
|
|
104
|
+
shell: false,
|
|
105
|
+
});
|
|
106
|
+
if (result.error || result.status !== 0) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const stdout = String(result.stdout ?? "").trim();
|
|
110
|
+
if (!stdout) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(stdout);
|
|
115
|
+
return {
|
|
116
|
+
pid: normalizePid(parsed.pid),
|
|
117
|
+
commandLine: String(parsed.commandLine ?? ""),
|
|
118
|
+
executablePath: normalizeOptionalString(parsed.executablePath),
|
|
119
|
+
startedAt: normalizeOptionalString(parsed.startedAt),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
function inspectPosixProcess(pid) {
|
|
129
|
+
const commandResult = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
shell: false,
|
|
132
|
+
});
|
|
133
|
+
if (commandResult.error || commandResult.status !== 0) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const commandLine = String(commandResult.stdout ?? "").trim();
|
|
137
|
+
if (!commandLine) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const startedAtResult = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
141
|
+
encoding: "utf8",
|
|
142
|
+
shell: false,
|
|
143
|
+
});
|
|
144
|
+
const executablePathResult = spawnSync("ps", ["-p", String(pid), "-o", "comm="], {
|
|
145
|
+
encoding: "utf8",
|
|
146
|
+
shell: false,
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
pid,
|
|
150
|
+
commandLine,
|
|
151
|
+
executablePath: normalizeOptionalString(executablePathResult.stdout),
|
|
152
|
+
startedAt: normalizeOptionalString(startedAtResult.stdout),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function resolveWindowsPowerShellCandidates() {
|
|
156
|
+
const candidates = [
|
|
157
|
+
path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
158
|
+
"powershell.exe",
|
|
159
|
+
"pwsh.exe",
|
|
160
|
+
];
|
|
161
|
+
return [...new Set(candidates)];
|
|
162
|
+
}
|
|
163
|
+
function extractScriptToken(value) {
|
|
164
|
+
const tokens = tokenizeCommandLine(value);
|
|
165
|
+
for (const token of tokens) {
|
|
166
|
+
if (/\.(?:[cm]?js|mts)$/i.test(token)) {
|
|
167
|
+
return token;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function extractExecutableToken(value) {
|
|
173
|
+
const [first] = tokenizeCommandLine(value);
|
|
174
|
+
return first ? normalizeOptionalString(first) : null;
|
|
175
|
+
}
|
|
176
|
+
function tokenizeCommandLine(value) {
|
|
177
|
+
const text = String(value ?? "").trim();
|
|
178
|
+
if (!text) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
const tokens = [];
|
|
182
|
+
const pattern = /"([^"]*)"|'([^']*)'|[^\s]+/g;
|
|
183
|
+
let match = null;
|
|
184
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
185
|
+
const token = match[1] ?? match[2] ?? match[0] ?? "";
|
|
186
|
+
const normalized = normalizeOptionalString(token);
|
|
187
|
+
if (normalized) {
|
|
188
|
+
tokens.push(normalized);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return tokens;
|
|
192
|
+
}
|
|
193
|
+
function parseTimestamp(value) {
|
|
194
|
+
const text = normalizeOptionalString(value);
|
|
195
|
+
if (!text) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const timestamp = Date.parse(text);
|
|
199
|
+
if (!Number.isFinite(timestamp)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return timestamp;
|
|
203
|
+
}
|
|
204
|
+
function normalizeOptionalString(value) {
|
|
205
|
+
const normalized = String(value ?? "").trim();
|
|
206
|
+
return normalized ? normalized : null;
|
|
207
|
+
}
|
|
208
|
+
function normalizeCommandToken(value, platform) {
|
|
209
|
+
const normalized = normalizeOptionalString(value);
|
|
210
|
+
if (!normalized) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
const unquoted = normalized.replace(/^["']+|["']+$/g, "");
|
|
214
|
+
const slashed = platform === "win32" ? unquoted.replace(/\\/g, "/") : unquoted;
|
|
215
|
+
return platform === "win32" ? slashed.toLowerCase() : slashed;
|
|
216
|
+
}
|
|
217
|
+
function commandLineContains(commandLine, expectedToken, platform) {
|
|
218
|
+
const normalizedCommandLine = normalizeCommandToken(commandLine, platform);
|
|
219
|
+
if (!normalizedCommandLine) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return normalizedCommandLine.includes(expectedToken);
|
|
223
|
+
}
|
|
224
|
+
function pathBasename(value, platform) {
|
|
225
|
+
return (platform === "win32" ? path.win32.basename(value) : path.posix.basename(value)).toLowerCase();
|
|
226
|
+
}
|
|
@@ -5,6 +5,7 @@ import process from "node:process";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
|
|
8
|
+
import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = path.dirname(__filename);
|
|
10
11
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
|
@@ -106,7 +107,7 @@ function showStatus() {
|
|
|
106
107
|
for (const service of SERVICES) {
|
|
107
108
|
const state = readState(service);
|
|
108
109
|
const pid = normalizePid(state?.pid);
|
|
109
|
-
const running = pid > 0 &&
|
|
110
|
+
const running = pid > 0 && isManagedProcessRunning(state);
|
|
110
111
|
if (state && !running) {
|
|
111
112
|
removeState(service);
|
|
112
113
|
}
|
|
@@ -127,7 +128,7 @@ async function startService(service, options = {}) {
|
|
|
127
128
|
const suppressAlreadyRunning = options?.suppressAlreadyRunning === true;
|
|
128
129
|
const existing = readState(service);
|
|
129
130
|
const existingPid = normalizePid(existing?.pid);
|
|
130
|
-
if (existingPid > 0 &&
|
|
131
|
+
if (existingPid > 0 && isManagedProcessRunning(existing)) {
|
|
131
132
|
if (!suppressAlreadyRunning) {
|
|
132
133
|
console.log(`[${service.id}] already running (pid ${existingPid})`);
|
|
133
134
|
}
|
|
@@ -163,9 +164,11 @@ async function startService(service, options = {}) {
|
|
|
163
164
|
pid,
|
|
164
165
|
startedAt: new Date().toISOString(),
|
|
165
166
|
command: `${process.execPath} ${service.entryScript}`,
|
|
167
|
+
executablePath: process.execPath,
|
|
168
|
+
entryScript: service.entryScript,
|
|
166
169
|
});
|
|
167
170
|
await sleep(250);
|
|
168
|
-
if (!
|
|
171
|
+
if (!isManagedProcessRunning(readState(service))) {
|
|
169
172
|
removeState(service);
|
|
170
173
|
console.error(`[${service.id}] exited immediately. Check logs: ${service.logFile}`);
|
|
171
174
|
return false;
|
|
@@ -185,7 +188,7 @@ async function stopService(service) {
|
|
|
185
188
|
console.log(`[${service.id}] removed invalid pid file`);
|
|
186
189
|
return;
|
|
187
190
|
}
|
|
188
|
-
if (!
|
|
191
|
+
if (!isManagedProcessRunning(state)) {
|
|
189
192
|
removeState(service);
|
|
190
193
|
console.log(`[${service.id}] not running (stale pid ${pid})`);
|
|
191
194
|
return;
|
|
@@ -267,28 +270,6 @@ function removeState(service) {
|
|
|
267
270
|
fs.rmSync(service.pidFile, { force: true });
|
|
268
271
|
}
|
|
269
272
|
}
|
|
270
|
-
function normalizePid(value) {
|
|
271
|
-
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
272
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
273
|
-
return 0;
|
|
274
|
-
}
|
|
275
|
-
return pid;
|
|
276
|
-
}
|
|
277
|
-
function isProcessRunning(pid) {
|
|
278
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
try {
|
|
282
|
-
process.kill(pid, 0);
|
|
283
|
-
return true;
|
|
284
|
-
}
|
|
285
|
-
catch (error) {
|
|
286
|
-
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
|
287
|
-
return true;
|
|
288
|
-
}
|
|
289
|
-
return false;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
273
|
function ensureRuntimeDirs() {
|
|
293
274
|
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
294
275
|
fs.mkdirSync(pidsDir, { recursive: true });
|
package/scripts/src/daemon.mts
CHANGED
|
@@ -7,6 +7,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { codexInstallPackageSpec } from "./codex-version.mjs";
|
|
9
9
|
import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
|
|
10
|
+
import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
|
|
10
11
|
import {
|
|
11
12
|
buildCodexCompatibilityError,
|
|
12
13
|
probeCodexVersion,
|
|
@@ -147,6 +148,8 @@ async function runDaemonLoop() {
|
|
|
147
148
|
pid: process.pid,
|
|
148
149
|
startedAt: new Date().toISOString(),
|
|
149
150
|
command: `${nodeBin} ${daemonScriptPath} run`,
|
|
151
|
+
executablePath: nodeBin,
|
|
152
|
+
entryScript: daemonScriptPath,
|
|
150
153
|
});
|
|
151
154
|
|
|
152
155
|
const state = { stopping: false, shuttingDown: false };
|
|
@@ -227,8 +230,9 @@ async function stopDaemonProcess() {
|
|
|
227
230
|
return;
|
|
228
231
|
}
|
|
229
232
|
|
|
233
|
+
const state = readDaemonState();
|
|
230
234
|
await terminateProcess(pid);
|
|
231
|
-
if (
|
|
235
|
+
if (isManagedProcessRunning(state)) {
|
|
232
236
|
throw new Error(`Daemon did not stop cleanly (pid ${pid}).`);
|
|
233
237
|
}
|
|
234
238
|
|
|
@@ -336,7 +340,7 @@ function getRunningDaemonPid() {
|
|
|
336
340
|
if (pid <= 0) {
|
|
337
341
|
return 0;
|
|
338
342
|
}
|
|
339
|
-
return
|
|
343
|
+
return isManagedProcessRunning(state) ? pid : 0;
|
|
340
344
|
}
|
|
341
345
|
|
|
342
346
|
function readDaemonState() {
|
|
@@ -362,29 +366,6 @@ function removeDaemonState() {
|
|
|
362
366
|
fs.rmSync(daemonStatePath, { force: true });
|
|
363
367
|
}
|
|
364
368
|
|
|
365
|
-
function normalizePid(value) {
|
|
366
|
-
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
367
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
368
|
-
return 0;
|
|
369
|
-
}
|
|
370
|
-
return pid;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function isProcessRunning(pid) {
|
|
374
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
375
|
-
return false;
|
|
376
|
-
}
|
|
377
|
-
try {
|
|
378
|
-
process.kill(pid, 0);
|
|
379
|
-
return true;
|
|
380
|
-
} catch (error) {
|
|
381
|
-
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
|
382
|
-
return true;
|
|
383
|
-
}
|
|
384
|
-
return false;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
369
|
async function terminateProcess(pid) {
|
|
389
370
|
if (process.platform === "win32") {
|
|
390
371
|
await killTreeWindows(pid);
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
const PROCESS_START_TOLERANCE_MS = 120_000;
|
|
6
|
+
|
|
7
|
+
export type ManagedProcessState = {
|
|
8
|
+
pid?: unknown;
|
|
9
|
+
startedAt?: unknown;
|
|
10
|
+
command?: unknown;
|
|
11
|
+
executablePath?: unknown;
|
|
12
|
+
entryScript?: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type ProcessIdentity = {
|
|
16
|
+
pid: number;
|
|
17
|
+
commandLine: string;
|
|
18
|
+
executablePath: string | null;
|
|
19
|
+
startedAt: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function normalizePid(value: unknown): number {
|
|
23
|
+
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
24
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
return pid;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isManagedProcessRunning(
|
|
31
|
+
state: ManagedProcessState | null,
|
|
32
|
+
platform: NodeJS.Platform = process.platform,
|
|
33
|
+
): boolean {
|
|
34
|
+
const pid = normalizePid(state?.pid);
|
|
35
|
+
if (pid <= 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
if (!isProcessRunning(pid)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const identity = inspectProcess(pid, platform);
|
|
43
|
+
if (!identity) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return matchesManagedProcessState(state, identity, platform);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function matchesManagedProcessState(
|
|
51
|
+
state: ManagedProcessState | null,
|
|
52
|
+
identity: ProcessIdentity | null,
|
|
53
|
+
platform: NodeJS.Platform = process.platform,
|
|
54
|
+
): boolean {
|
|
55
|
+
const pid = normalizePid(state?.pid);
|
|
56
|
+
if (pid <= 0 || !identity) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
if (normalizePid(identity.pid) !== pid) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const checks: boolean[] = [];
|
|
64
|
+
const expectedScript = normalizeCommandToken(
|
|
65
|
+
state?.entryScript ?? extractScriptToken(state?.command),
|
|
66
|
+
platform,
|
|
67
|
+
);
|
|
68
|
+
if (expectedScript) {
|
|
69
|
+
checks.push(commandLineContains(identity.commandLine, expectedScript, platform));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const expectedExecutablePath = normalizeCommandToken(
|
|
73
|
+
state?.executablePath ?? extractExecutableToken(state?.command),
|
|
74
|
+
platform,
|
|
75
|
+
);
|
|
76
|
+
if (expectedExecutablePath) {
|
|
77
|
+
const actualExecutablePath = normalizeCommandToken(identity.executablePath, platform);
|
|
78
|
+
if (actualExecutablePath) {
|
|
79
|
+
checks.push(
|
|
80
|
+
actualExecutablePath === expectedExecutablePath ||
|
|
81
|
+
pathBasename(actualExecutablePath, platform) ===
|
|
82
|
+
pathBasename(expectedExecutablePath, platform),
|
|
83
|
+
);
|
|
84
|
+
} else {
|
|
85
|
+
checks.push(commandLineContains(identity.commandLine, expectedExecutablePath, platform));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const expectedStartedAt = parseTimestamp(state?.startedAt);
|
|
90
|
+
const actualStartedAt = parseTimestamp(identity.startedAt);
|
|
91
|
+
if (expectedStartedAt !== null && actualStartedAt !== null) {
|
|
92
|
+
checks.push(Math.abs(actualStartedAt - expectedStartedAt) <= PROCESS_START_TOLERANCE_MS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (checks.length === 0) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return checks.every(Boolean);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isProcessRunning(pid: number): boolean {
|
|
102
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
process.kill(pid, 0);
|
|
108
|
+
return true;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function inspectProcess(pid: number, platform: NodeJS.Platform): ProcessIdentity | null {
|
|
118
|
+
if (platform === "win32") {
|
|
119
|
+
return inspectWindowsProcess(pid);
|
|
120
|
+
}
|
|
121
|
+
if (platform === "linux" || platform === "darwin") {
|
|
122
|
+
return inspectPosixProcess(pid);
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function inspectWindowsProcess(pid: number): ProcessIdentity | null {
|
|
128
|
+
const script = [
|
|
129
|
+
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue`,
|
|
130
|
+
"if ($null -eq $p) { exit 3 }",
|
|
131
|
+
"$created = ''",
|
|
132
|
+
"if ($p.CreationDate) {",
|
|
133
|
+
" try { $created = $p.CreationDate.ToString('o') } catch { $created = '' }",
|
|
134
|
+
"}",
|
|
135
|
+
"[pscustomobject]@{",
|
|
136
|
+
" pid = $p.ProcessId",
|
|
137
|
+
" commandLine = $p.CommandLine",
|
|
138
|
+
" executablePath = $p.ExecutablePath",
|
|
139
|
+
" startedAt = $created",
|
|
140
|
+
"} | ConvertTo-Json -Compress",
|
|
141
|
+
].join("\n");
|
|
142
|
+
|
|
143
|
+
for (const shell of resolveWindowsPowerShellCandidates()) {
|
|
144
|
+
const result = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
145
|
+
encoding: "utf8",
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
shell: false,
|
|
148
|
+
});
|
|
149
|
+
if (result.error || result.status !== 0) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const stdout = String(result.stdout ?? "").trim();
|
|
153
|
+
if (!stdout) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
|
158
|
+
return {
|
|
159
|
+
pid: normalizePid(parsed.pid),
|
|
160
|
+
commandLine: String(parsed.commandLine ?? ""),
|
|
161
|
+
executablePath: normalizeOptionalString(parsed.executablePath),
|
|
162
|
+
startedAt: normalizeOptionalString(parsed.startedAt),
|
|
163
|
+
};
|
|
164
|
+
} catch {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function inspectPosixProcess(pid: number): ProcessIdentity | null {
|
|
173
|
+
const commandResult = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
shell: false,
|
|
176
|
+
});
|
|
177
|
+
if (commandResult.error || commandResult.status !== 0) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const commandLine = String(commandResult.stdout ?? "").trim();
|
|
182
|
+
if (!commandLine) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const startedAtResult = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
187
|
+
encoding: "utf8",
|
|
188
|
+
shell: false,
|
|
189
|
+
});
|
|
190
|
+
const executablePathResult = spawnSync("ps", ["-p", String(pid), "-o", "comm="], {
|
|
191
|
+
encoding: "utf8",
|
|
192
|
+
shell: false,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
pid,
|
|
197
|
+
commandLine,
|
|
198
|
+
executablePath: normalizeOptionalString(executablePathResult.stdout),
|
|
199
|
+
startedAt: normalizeOptionalString(startedAtResult.stdout),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function resolveWindowsPowerShellCandidates(): string[] {
|
|
204
|
+
const candidates = [
|
|
205
|
+
path.join(
|
|
206
|
+
process.env.SystemRoot ?? "C:\\Windows",
|
|
207
|
+
"System32",
|
|
208
|
+
"WindowsPowerShell",
|
|
209
|
+
"v1.0",
|
|
210
|
+
"powershell.exe",
|
|
211
|
+
),
|
|
212
|
+
"powershell.exe",
|
|
213
|
+
"pwsh.exe",
|
|
214
|
+
];
|
|
215
|
+
return [...new Set(candidates)];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function extractScriptToken(value: unknown): string | null {
|
|
219
|
+
const tokens = tokenizeCommandLine(value);
|
|
220
|
+
for (const token of tokens) {
|
|
221
|
+
if (/\.(?:[cm]?js|mts)$/i.test(token)) {
|
|
222
|
+
return token;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function extractExecutableToken(value: unknown): string | null {
|
|
229
|
+
const [first] = tokenizeCommandLine(value);
|
|
230
|
+
return first ? normalizeOptionalString(first) : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function tokenizeCommandLine(value: unknown): string[] {
|
|
234
|
+
const text = String(value ?? "").trim();
|
|
235
|
+
if (!text) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tokens: string[] = [];
|
|
240
|
+
const pattern = /"([^"]*)"|'([^']*)'|[^\s]+/g;
|
|
241
|
+
let match: RegExpExecArray | null = null;
|
|
242
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
243
|
+
const token = match[1] ?? match[2] ?? match[0] ?? "";
|
|
244
|
+
const normalized = normalizeOptionalString(token);
|
|
245
|
+
if (normalized) {
|
|
246
|
+
tokens.push(normalized);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return tokens;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseTimestamp(value: unknown): number | null {
|
|
253
|
+
const text = normalizeOptionalString(value);
|
|
254
|
+
if (!text) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const timestamp = Date.parse(text);
|
|
258
|
+
if (!Number.isFinite(timestamp)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return timestamp;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeOptionalString(value: unknown): string | null {
|
|
265
|
+
const normalized = String(value ?? "").trim();
|
|
266
|
+
return normalized ? normalized : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function normalizeCommandToken(value: unknown, platform: NodeJS.Platform): string | null {
|
|
270
|
+
const normalized = normalizeOptionalString(value);
|
|
271
|
+
if (!normalized) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const unquoted = normalized.replace(/^["']+|["']+$/g, "");
|
|
276
|
+
const slashed = platform === "win32" ? unquoted.replace(/\\/g, "/") : unquoted;
|
|
277
|
+
return platform === "win32" ? slashed.toLowerCase() : slashed;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function commandLineContains(
|
|
281
|
+
commandLine: string,
|
|
282
|
+
expectedToken: string,
|
|
283
|
+
platform: NodeJS.Platform,
|
|
284
|
+
): boolean {
|
|
285
|
+
const normalizedCommandLine = normalizeCommandToken(commandLine, platform);
|
|
286
|
+
if (!normalizedCommandLine) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
return normalizedCommandLine.includes(expectedToken);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function pathBasename(value: string, platform: NodeJS.Platform): string {
|
|
293
|
+
return (
|
|
294
|
+
platform === "win32" ? path.win32.basename(value) : path.posix.basename(value)
|
|
295
|
+
).toLowerCase();
|
|
296
|
+
}
|
|
@@ -5,6 +5,7 @@ import process from "node:process";
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
|
|
8
|
+
import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
|
|
8
9
|
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = path.dirname(__filename);
|
|
@@ -121,7 +122,7 @@ function showStatus() {
|
|
|
121
122
|
for (const service of SERVICES) {
|
|
122
123
|
const state = readState(service);
|
|
123
124
|
const pid = normalizePid(state?.pid);
|
|
124
|
-
const running = pid > 0 &&
|
|
125
|
+
const running = pid > 0 && isManagedProcessRunning(state);
|
|
125
126
|
|
|
126
127
|
if (state && !running) {
|
|
127
128
|
removeState(service);
|
|
@@ -147,7 +148,7 @@ async function startService(service, options: { suppressAlreadyRunning?: boolean
|
|
|
147
148
|
const suppressAlreadyRunning = options?.suppressAlreadyRunning === true;
|
|
148
149
|
const existing = readState(service);
|
|
149
150
|
const existingPid = normalizePid(existing?.pid);
|
|
150
|
-
if (existingPid > 0 &&
|
|
151
|
+
if (existingPid > 0 && isManagedProcessRunning(existing)) {
|
|
151
152
|
if (!suppressAlreadyRunning) {
|
|
152
153
|
console.log(`[${service.id}] already running (pid ${existingPid})`);
|
|
153
154
|
}
|
|
@@ -188,11 +189,13 @@ async function startService(service, options: { suppressAlreadyRunning?: boolean
|
|
|
188
189
|
pid,
|
|
189
190
|
startedAt: new Date().toISOString(),
|
|
190
191
|
command: `${process.execPath} ${service.entryScript}`,
|
|
192
|
+
executablePath: process.execPath,
|
|
193
|
+
entryScript: service.entryScript,
|
|
191
194
|
});
|
|
192
195
|
|
|
193
196
|
await sleep(250);
|
|
194
197
|
|
|
195
|
-
if (!
|
|
198
|
+
if (!isManagedProcessRunning(readState(service))) {
|
|
196
199
|
removeState(service);
|
|
197
200
|
console.error(`[${service.id}] exited immediately. Check logs: ${service.logFile}`);
|
|
198
201
|
return false;
|
|
@@ -216,7 +219,7 @@ async function stopService(service) {
|
|
|
216
219
|
return;
|
|
217
220
|
}
|
|
218
221
|
|
|
219
|
-
if (!
|
|
222
|
+
if (!isManagedProcessRunning(state)) {
|
|
220
223
|
removeState(service);
|
|
221
224
|
console.log(`[${service.id}] not running (stale pid ${pid})`);
|
|
222
225
|
return;
|
|
@@ -311,30 +314,6 @@ function removeState(service) {
|
|
|
311
314
|
}
|
|
312
315
|
}
|
|
313
316
|
|
|
314
|
-
function normalizePid(value) {
|
|
315
|
-
const pid = Number.parseInt(String(value ?? ""), 10);
|
|
316
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
317
|
-
return 0;
|
|
318
|
-
}
|
|
319
|
-
return pid;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function isProcessRunning(pid) {
|
|
323
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
324
|
-
return false;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
try {
|
|
328
|
-
process.kill(pid, 0);
|
|
329
|
-
return true;
|
|
330
|
-
} catch (error) {
|
|
331
|
-
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
|
|
332
|
-
return true;
|
|
333
|
-
}
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
317
|
function ensureRuntimeDirs() {
|
|
339
318
|
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
340
319
|
fs.mkdirSync(pidsDir, { recursive: true });
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { matchesManagedProcessState, normalizePid } from "../dist/process-identity.mjs";
|
|
4
|
+
|
|
5
|
+
test("normalizePid accepts valid positive integers only", () => {
|
|
6
|
+
assert.equal(normalizePid("123"), 123);
|
|
7
|
+
assert.equal(normalizePid(0), 0);
|
|
8
|
+
assert.equal(normalizePid(-1), 0);
|
|
9
|
+
assert.equal(normalizePid("abc"), 0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("matchesManagedProcessState accepts a matching managed daemon process", () => {
|
|
13
|
+
const state = {
|
|
14
|
+
pid: 2444,
|
|
15
|
+
startedAt: "2026-03-14T16:13:37.301Z",
|
|
16
|
+
executablePath: "C:/Program Files/nodejs/node.exe",
|
|
17
|
+
entryScript: "C:/Users/amine/Desktop/copilot_hub/scripts/dist/daemon.mjs",
|
|
18
|
+
};
|
|
19
|
+
const identity = {
|
|
20
|
+
pid: 2444,
|
|
21
|
+
executablePath: "C:\\Program Files\\nodejs\\node.exe",
|
|
22
|
+
commandLine:
|
|
23
|
+
'"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\amine\\Desktop\\copilot_hub\\scripts\\dist\\daemon.mjs" run',
|
|
24
|
+
startedAt: "2026-03-14T16:13:37.900Z",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
assert.equal(matchesManagedProcessState(state, identity, "win32"), true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("matchesManagedProcessState rejects a reused pid for an unrelated process", () => {
|
|
31
|
+
const state = {
|
|
32
|
+
pid: 2444,
|
|
33
|
+
startedAt: "2026-03-14T16:13:37.301Z",
|
|
34
|
+
command:
|
|
35
|
+
"C:\\Program Files\\nodejs\\node.exe C:\\Users\\amine\\Desktop\\copilot_hub\\scripts\\dist\\daemon.mjs run",
|
|
36
|
+
};
|
|
37
|
+
const identity = {
|
|
38
|
+
pid: 2444,
|
|
39
|
+
executablePath: "C:\\Windows\\System32\\IntelCpHDCPSvc.exe",
|
|
40
|
+
commandLine: "C:\\Windows\\System32\\IntelCpHDCPSvc.exe",
|
|
41
|
+
startedAt: "2026-03-14T16:55:00.000Z",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
assert.equal(matchesManagedProcessState(state, identity, "win32"), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("matchesManagedProcessState supports legacy states that only stored command", () => {
|
|
48
|
+
const state = {
|
|
49
|
+
pid: 17044,
|
|
50
|
+
startedAt: "2026-03-14T12:00:00.000Z",
|
|
51
|
+
command: "/usr/bin/node /opt/copilot-hub/scripts/dist/daemon.mjs run",
|
|
52
|
+
};
|
|
53
|
+
const identity = {
|
|
54
|
+
pid: 17044,
|
|
55
|
+
executablePath: "/usr/bin/node",
|
|
56
|
+
commandLine: "/usr/bin/node /opt/copilot-hub/scripts/dist/daemon.mjs run",
|
|
57
|
+
startedAt: "Fri Mar 14 12:00:00 2026",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
assert.equal(matchesManagedProcessState(state, identity, "linux"), true);
|
|
61
|
+
});
|