@lifeaitools/clauth 2.15.2 → 2.15.3
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/cli/ops/pm2-adapter.js +147 -4
- package/package.json +1 -1
package/cli/ops/pm2-adapter.js
CHANGED
|
@@ -53,6 +53,116 @@ function callbackCall(pm2, method, args = []) {
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// PM2's own stop/restart/delete callbacks resolve once the daemon has ISSUED
|
|
57
|
+
// the kill, not once the OS has actually reclaimed the process -- confirmed
|
|
58
|
+
// live 2026-09-04, Windows: a `restart` on a real app left its outgoing
|
|
59
|
+
// child alive and still holding its listen port for 1000+ cycles, and a
|
|
60
|
+
// plain `stop` right before an `npm install -g` over that same app's
|
|
61
|
+
// install directory still hit EBUSY because the "stopped" process was, in
|
|
62
|
+
// fact, not. Every caller of this adapter (the ops CLI, ops HTTP surface,
|
|
63
|
+
// and every cutover procedure built on top of it) inherits that lie unless
|
|
64
|
+
// this file closes it once, here, rather than each caller re-discovering it
|
|
65
|
+
// under pressure.
|
|
66
|
+
function isAlive(pid) {
|
|
67
|
+
if (typeof pid !== "number" || pid <= 0) return false;
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// ESRCH: no such process -- genuinely gone. Anything else (EPERM, etc.)
|
|
73
|
+
// means the OS still has an opinion about this pid, so it isn't safe to
|
|
74
|
+
// call it dead.
|
|
75
|
+
return error.code !== "ESRCH";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// pm2's own death check on Windows is also parent-pid-only (God.processIsDead
|
|
80
|
+
// reads pm2_env._tree_pids || [pid], and treekill's taskkill callback reports
|
|
81
|
+
// only the pid it was given) -- so a spawned-child topology like clauth's own
|
|
82
|
+
// next-runner fixture can have its child survive everything pm2 itself
|
|
83
|
+
// thinks killed it. Capture _tree_pids alongside pid so this adapter isn't
|
|
84
|
+
// blind to exactly the shape its own fixtures use.
|
|
85
|
+
function pidsOf(processes) {
|
|
86
|
+
return (Array.isArray(processes) ? processes : [processes])
|
|
87
|
+
.flatMap((entry) => [entry?.pid, ...(entry?.pm2_env?._tree_pids || [])])
|
|
88
|
+
.filter((pid) => typeof pid === "number" && pid > 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const DEATH_TIMEOUT_MS = 3000;
|
|
92
|
+
const KILL_TIMEOUT_MS = 1000;
|
|
93
|
+
|
|
94
|
+
// describe() resolving with [] IS the normal "no such app" case -- do not
|
|
95
|
+
// mask that behind a try/catch that also swallows a real RPC/transport
|
|
96
|
+
// failure. A caller that can't see whether the old process is still alive
|
|
97
|
+
// must not silently report success; let it fail loudly instead.
|
|
98
|
+
function pidsFor(pm2, target) {
|
|
99
|
+
return callbackCall(pm2, "describe", [target]).then(pidsOf);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function waitForDeath(pids, { pollMs = 150, timeoutMs = DEATH_TIMEOUT_MS } = {}) {
|
|
103
|
+
const deadline = Date.now() + timeoutMs;
|
|
104
|
+
let survivors = pids.filter(isAlive);
|
|
105
|
+
while (survivors.length > 0 && Date.now() < deadline) {
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
107
|
+
survivors = survivors.filter(isAlive);
|
|
108
|
+
}
|
|
109
|
+
return survivors;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Verifies the pids captured BEFORE a stop/restart/reload/delete are
|
|
113
|
+
// actually gone afterward, escalating to SIGKILL (TerminateProcess on
|
|
114
|
+
// Windows, same as POSIX SIGKILL) if PM2's own signal didn't finish the job
|
|
115
|
+
// within the timeout. Only throws if a process survives an explicit SIGKILL,
|
|
116
|
+
// which should not happen -- if it does, the caller needs to know its
|
|
117
|
+
// "stop" silently didn't, rather than proceed as though it did. The thrown
|
|
118
|
+
// error carries the surviving pids as structured fields (not just in the
|
|
119
|
+
// message) so a caller logging/job-recording the failure can act on them
|
|
120
|
+
// even after this operation's own bookkeeping (e.g. a deleted pm2 record)
|
|
121
|
+
// is gone.
|
|
122
|
+
async function verifyTerminated(target, oldPids, operation) {
|
|
123
|
+
if (oldPids.length === 0) return;
|
|
124
|
+
const survivors = await waitForDeath(oldPids);
|
|
125
|
+
if (survivors.length === 0) return;
|
|
126
|
+
console.error(
|
|
127
|
+
`[ops] pm2 ${operation} "${target}" did not stop pid(s) ${survivors.join(", ")} within ${DEATH_TIMEOUT_MS}ms -- force-killing`,
|
|
128
|
+
);
|
|
129
|
+
for (const pid of survivors) {
|
|
130
|
+
try {
|
|
131
|
+
process.kill(pid, "SIGKILL");
|
|
132
|
+
} catch {
|
|
133
|
+
// Already gone between the check and the kill -- fine.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const stillAlive = await waitForDeath(survivors, { timeoutMs: KILL_TIMEOUT_MS });
|
|
137
|
+
if (stillAlive.length > 0) {
|
|
138
|
+
const error = new Error(
|
|
139
|
+
`pm2 ${operation} "${target}": pid(s) ${stillAlive.join(", ")} survived SIGKILL -- refusing to report success`,
|
|
140
|
+
);
|
|
141
|
+
error.pids = stillAlive;
|
|
142
|
+
error.operation = operation;
|
|
143
|
+
error.target = target;
|
|
144
|
+
error.reason = "survived_sigkill";
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// restart/reload spawn the replacement BEFORE the pm2 callback this adapter
|
|
150
|
+
// awaits resolves (confirmed against pm2's own God.restartProcessId, and
|
|
151
|
+
// reload falls through to the identical path outside cluster_mode -- which
|
|
152
|
+
// is every app this control plane manages). So by the time verification
|
|
153
|
+
// starts, a genuinely new process can already exist, and on Windows a freed
|
|
154
|
+
// pid can be reallocated to it inside the same poll window this adapter is
|
|
155
|
+
// watching. Re-describing AFTER the operation and only verifying death for
|
|
156
|
+
// pids no longer reported as current tells the two apart: a truly orphaned
|
|
157
|
+
// old pid is never in the fresh describe and still gets killed; a pid pm2
|
|
158
|
+
// still reports as live is never touched, whether that's because pm2
|
|
159
|
+
// genuinely didn't rotate it or because it now belongs to the new process.
|
|
160
|
+
async function survivorsOf(pm2, target, oldPids) {
|
|
161
|
+
if (oldPids.length === 0) return [];
|
|
162
|
+
const currentPids = new Set(await pidsFor(pm2, target));
|
|
163
|
+
return oldPids.filter((pid) => !currentPids.has(pid));
|
|
164
|
+
}
|
|
165
|
+
|
|
56
166
|
function normalizeProcess(process) {
|
|
57
167
|
if (!process || typeof process !== "object") return process;
|
|
58
168
|
return {
|
|
@@ -101,10 +211,43 @@ export function createPm2Adapter(pm2) {
|
|
|
101
211
|
case "logs": return (await callbackCall(pm2, "describe", [missing(input.target, "target")])).map(normalizeProcess);
|
|
102
212
|
case "bus": return callbackCall(pm2, "launchBus");
|
|
103
213
|
case "start": return callbackCall(pm2, "start", [missing(input.script, "script"), input.options || {}]);
|
|
104
|
-
case "stop":
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
214
|
+
case "stop": {
|
|
215
|
+
const target = missing(input.target, "target");
|
|
216
|
+
const oldPids = await pidsFor(pm2, target);
|
|
217
|
+
const result = await callbackCall(pm2, "stop", [target]);
|
|
218
|
+
await verifyTerminated(target, oldPids, "stop");
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
case "restart": {
|
|
222
|
+
const target = missing(input.target, "target");
|
|
223
|
+
const oldPids = await pidsFor(pm2, target);
|
|
224
|
+
const result = await callbackCall(pm2, "restart", [target, input.options || {}]);
|
|
225
|
+
await verifyTerminated(target, await survivorsOf(pm2, target, oldPids), "restart");
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
case "reload": {
|
|
229
|
+
// Fork mode -- every app this control plane manages -- makes
|
|
230
|
+
// reload identical to restart inside pm2 (God.reloadProcessId only
|
|
231
|
+
// takes the cluster branch in cluster_mode; everything else falls
|
|
232
|
+
// straight through to God.restartProcessId). clauth's own deploy
|
|
233
|
+
// cutover (deployment-adapter.js) calls this operation, not
|
|
234
|
+
// "restart" -- so this is the actual path "install then hit
|
|
235
|
+
// restart, pm2 should pick up the new one" travels in production,
|
|
236
|
+
// and it needs the identical guarantee, not a lighter one because
|
|
237
|
+
// the pm2 verb sounds gentler.
|
|
238
|
+
const target = missing(input.target, "target");
|
|
239
|
+
const oldPids = await pidsFor(pm2, target);
|
|
240
|
+
const result = await callbackCall(pm2, "reload", [target, input.options || {}]);
|
|
241
|
+
await verifyTerminated(target, await survivorsOf(pm2, target, oldPids), "reload");
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
case "delete": {
|
|
245
|
+
const target = missing(input.target, "target");
|
|
246
|
+
const oldPids = await pidsFor(pm2, target);
|
|
247
|
+
const result = await callbackCall(pm2, "delete", [target]);
|
|
248
|
+
await verifyTerminated(target, oldPids, "delete");
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
108
251
|
case "scale": return callbackCall(pm2, "scale", [missing(input.target, "target"), missing(input.instances, "instances")]);
|
|
109
252
|
case "reset": return callbackCall(pm2, "reset", [missing(input.target, "target")]);
|
|
110
253
|
case "dump": return callbackCall(pm2, "dump");
|