@twinsearth/w2m-dsh-plugin 0.5.9 → 0.6.2
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/CHANGELOG.md +379 -3
- package/PROTOCOL-v0.4.0.md +11 -3
- package/PROTOCOL.md +69 -15
- package/README.md +197 -27
- package/SECURITY.md +69 -19
- package/TROUBLESHOOTING.md +129 -53
- package/bin/w2m-localside.mjs +5 -0
- package/bin/w2m-rabbit.mjs +32 -5
- package/deploy/networking/LESSONS.md +18 -1
- package/deploy/networking/README.md +32 -7
- package/deploy/windows/README.md +20 -1
- package/deploy/windows/install-service.ps1 +98 -19
- package/deploy/windows/verify-supervisor.mjs +794 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +867 -40
- package/docs/AUDIT.md +10 -1
- package/docs/DEPLOY.md +16 -3
- package/docs/P2P-CRYPTO.md +105 -4
- package/docs/PIPELINE-DESIGN.md +35 -10
- package/docs/TROUBLESHOOTING.md +22 -3
- package/package.json +5 -5
- package/src/agent/agent.mjs +242 -23
- package/src/agent/allowed-commands.mjs +480 -18
- package/src/agent/crypto.mjs +252 -40
- package/src/agent/p2p-node.mjs +136 -7
- package/src/agent/p2p.mjs +342 -37
- package/src/agent/resolve.mjs +68 -3
- package/src/plugin/fleet-target.mjs +160 -15
- package/src/plugin/tools.mjs +27 -29
- package/src/relay/persistence.mjs +72 -2
- package/src/relay/server.mjs +84 -11
- package/src/relay/state.mjs +9 -2
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
// verify-supervisor.mjs -- a reproducible, no-elevation check of the invariants
|
|
2
|
+
// `w2m-agent-supervisor.mjs` is supposed to hold: ONE supervisor generation, ONE agent, and the
|
|
3
|
+
// punch port bound by that agent.
|
|
4
|
+
//
|
|
5
|
+
// node deploy/windows/verify-supervisor.mjs # the mechanism, against a stub worker
|
|
6
|
+
// node deploy/windows/verify-supervisor.mjs --keep # ...and leave the temp tree for inspection
|
|
7
|
+
//
|
|
8
|
+
// WHY A CHECK AND NOT ONLY A TEST
|
|
9
|
+
//
|
|
10
|
+
// The failure this guards against is not a unit of logic, it is a *state* the machine reached:
|
|
11
|
+
// fourteen `w2m` processes, nine agents with one identity, and the punch port held by an agent from
|
|
12
|
+
// thirteen hours earlier, so every dispatch fell back to the relay (`P2P_UNREACHABLE`) while the
|
|
13
|
+
// heartbeat said nothing. The property that was violated is "one supervisor, one agent, port bound",
|
|
14
|
+
// and the honest way to check a property like that is to build the broken state and start the thing:
|
|
15
|
+
// orphan agents already holding the punch port, a lock file left behind by a supervisor that was
|
|
16
|
+
// killed, and then one supervisor start. Exactly one agent must be left, it must own the port, and
|
|
17
|
+
// the heartbeat must name the pids it reaped.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IS EXERCISED, AND WHAT IS NOT
|
|
20
|
+
//
|
|
21
|
+
// The mechanism runs here exactly as it does in production: the real supervisor, the real lock file,
|
|
22
|
+
// a real detached worker, the real port probe, the same identity rule for reaping. What is stubbed
|
|
23
|
+
// is the *worker* -- a two-line UDP binder instead of the real agent -- so that the check can run on
|
|
24
|
+
// a free port, without admin rights, without Windows Task Scheduler, and without a second agent
|
|
25
|
+
// joining the fleet under a real device identity. A real scheduled-task run is deliberately NOT
|
|
26
|
+
// scripted here: it needs the live task registration, it needs the machine's punch port, and it
|
|
27
|
+
// would evict the machine's actual agent. Every run prints exactly which scenarios it covered.
|
|
28
|
+
//
|
|
29
|
+
// Nothing outside a temporary directory is touched: the lock, the heartbeat and the log are all
|
|
30
|
+
// under the tree created below, so a running installation cannot be disturbed by construction.
|
|
31
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
32
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
|
+
import { createSocket } from 'node:dgram';
|
|
34
|
+
import { tmpdir } from 'node:os';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const supervisorPath = join(here, 'w2m-agent-supervisor.mjs');
|
|
40
|
+
const keep = process.argv.includes('--keep');
|
|
41
|
+
/**
|
|
42
|
+
* A STABLE identity, not a per-run one, and that is the point rather than a detail: a real
|
|
43
|
+
* installation has one state directory, one punch port and one lock file that outlive every process
|
|
44
|
+
* using them. An earlier version of this check made a fresh temp directory per run -- so each run's
|
|
45
|
+
* agents were invisible to the next run's reap, they accumulated exactly as the fleet's did, and the
|
|
46
|
+
* check became a demonstration of the bug it was written to catch. `/stale` and `/fresh` are two
|
|
47
|
+
* identities for the two things that must not share one: state deliberately left behind to be
|
|
48
|
+
* reaped, and a clean state to start against.
|
|
49
|
+
*/
|
|
50
|
+
const root = join(tmpdir(), 'w2m-supervisor-check');
|
|
51
|
+
|
|
52
|
+
const sleep = (ms) =>
|
|
53
|
+
new Promise((resolve) => {
|
|
54
|
+
setTimeout(resolve, ms);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function isAlive(pid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A port nothing is bound to, asked of the OS rather than guessed. */
|
|
67
|
+
function unboundPort() {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const socket = createSocket('udp4');
|
|
70
|
+
socket.on('error', reject);
|
|
71
|
+
socket.bind({ address: '0.0.0.0', port: 0 }, () => {
|
|
72
|
+
const { port } = socket.address();
|
|
73
|
+
socket.close(() => resolve(port));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function portIsFree(port) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const socket = createSocket('udp4');
|
|
81
|
+
socket.on('error', () => resolve(false));
|
|
82
|
+
socket.bind({ address: '0.0.0.0', port }, () => socket.close(() => resolve(true)));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every live `w2m-localside` process whose command line names this scenario's state directory: the
|
|
88
|
+
* same identity rule the supervisor's reap uses, evaluated the same way (process table + command
|
|
89
|
+
* line), so an assertion here is about the invariant and not about a copy of the implementation.
|
|
90
|
+
*
|
|
91
|
+
* The supervisor exclusion is part of that rule, not a detail: a supervisor's own command line
|
|
92
|
+
* carries the stub's path (`--bin ...\w2m-localside-stub.mjs`), so "contains w2m-localside" is true
|
|
93
|
+
* of the supervisor too. This check counted supervisors as agents for several runs and blamed the
|
|
94
|
+
* supervisor for the difference -- the same mistake, and now the same fix, as the reap itself.
|
|
95
|
+
*/
|
|
96
|
+
function isAgentRow(row) {
|
|
97
|
+
return String(row.cmd).includes('w2m-localside') && !String(row.cmd).includes('w2m-agent-supervisor');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Process-table rows whose command line names this identity. */
|
|
101
|
+
function listRows(marker) {
|
|
102
|
+
if (process.platform !== 'win32') {
|
|
103
|
+
const out = spawnSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8' });
|
|
104
|
+
return (out.stdout ?? '')
|
|
105
|
+
.split('\n')
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((line) => {
|
|
108
|
+
const at = line.trim().indexOf(' ');
|
|
109
|
+
return { pid: Number(line.trim().slice(0, at)), cmd: line.trim().slice(at + 1) };
|
|
110
|
+
})
|
|
111
|
+
.filter((row) => Number.isInteger(row.pid) && row.cmd.includes(marker));
|
|
112
|
+
}
|
|
113
|
+
// The identity string travels in the environment, not in the script text: a Windows path is full
|
|
114
|
+
// of backslashes, and a query that fails to parse returns nothing -- which would read as "no
|
|
115
|
+
// agents", the one wrong answer that matters. The separator is a literal tab, written as one:
|
|
116
|
+
// PowerShell's backtick escapes do not survive the trip through a spawn argument list.
|
|
117
|
+
const script = [
|
|
118
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
119
|
+
"Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($env:W2M_STATE_MARKER) } | ForEach-Object { \"$($_.ProcessId)\t$($_.CommandLine)\" }",
|
|
120
|
+
"Write-Output '#end'",
|
|
121
|
+
].join('\n');
|
|
122
|
+
const out = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
windowsHide: true,
|
|
125
|
+
env: { ...process.env, W2M_STATE_MARKER: marker },
|
|
126
|
+
});
|
|
127
|
+
const lines = (out.stdout ?? '').split(/\r?\n/).map((line) => line.trim());
|
|
128
|
+
if (!lines.includes('#end')) return [];
|
|
129
|
+
const rows = [];
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const tab = line.indexOf('\t');
|
|
132
|
+
if (tab <= 0) continue;
|
|
133
|
+
const pid = Number(line.slice(0, tab));
|
|
134
|
+
const cmd = line.slice(tab + 1);
|
|
135
|
+
if (Number.isInteger(pid) && pid > 0) rows.push({ pid, cmd });
|
|
136
|
+
}
|
|
137
|
+
return rows;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function agentsFor(stateDir) {
|
|
141
|
+
return listRows(stateDir).filter(isAgentRow).map((row) => row.pid);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The pids *and command lines* of this identity's agents: evidence, not just a count. */
|
|
145
|
+
function agentRowsFor(stateDir) {
|
|
146
|
+
return listRows(stateDir).filter(isAgentRow);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The pid the OS says holds the UDP port. Absence of an answer is reported as null, never guessed. */
|
|
150
|
+
function udpOwner(port) {
|
|
151
|
+
if (process.platform !== 'win32') return null;
|
|
152
|
+
// netstat first: measured here at ~0.09 s against ~2.6 s for one PowerShell start, and a check that
|
|
153
|
+
// spends two seconds per probe cannot poll anything.
|
|
154
|
+
const quick = spawnSync('netstat', ['-ano', '-p', 'UDP'], { encoding: 'utf8', windowsHide: true, timeout: 10_000 });
|
|
155
|
+
if (!quick.error && typeof quick.stdout === 'string') {
|
|
156
|
+
for (const line of quick.stdout.split(/\r?\n/)) {
|
|
157
|
+
const match = /^\s*UDP\s+\S*:(\d+)\s+\S+\s+(\d+)\s*$/.exec(line);
|
|
158
|
+
if (match && Number(match[1]) === port) return Number(match[2]);
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const out = spawnSync(
|
|
163
|
+
'powershell.exe',
|
|
164
|
+
['-NoProfile', '-NonInteractive', '-Command', `(Get-NetUDPEndpoint -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess`],
|
|
165
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
166
|
+
);
|
|
167
|
+
const pid = Number((out.stdout ?? '').trim());
|
|
168
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* End a pid -- and check, because "the kill was issued" and "the process is gone" are different facts.
|
|
173
|
+
*
|
|
174
|
+
* Measured while writing this check: `taskkill /PID <pid> /T /F` exits 128 with "process not found"
|
|
175
|
+
* and kills nothing when the named pid's parent has already exited, which is exactly what a detached
|
|
176
|
+
* worker looks like after its supervisor is killed. A harness that trusted the exit code leaked
|
|
177
|
+
* workers into the next scenario, where they read as orphans and made the supervisor look wrong.
|
|
178
|
+
*/
|
|
179
|
+
function killTree(pid) {
|
|
180
|
+
if (!pid || !isAlive(pid)) return true;
|
|
181
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
182
|
+
if (!isAlive(pid)) return true;
|
|
183
|
+
if (process.platform === 'win32') {
|
|
184
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
|
|
185
|
+
spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true });
|
|
186
|
+
} else {
|
|
187
|
+
try {
|
|
188
|
+
process.kill(pid, 'SIGKILL');
|
|
189
|
+
} catch {
|
|
190
|
+
/* gone */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return !isAlive(pid);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `Stop-Process -Force`, exactly as the incident was reproduced by hand. */
|
|
198
|
+
function forceKill(pid) {
|
|
199
|
+
spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `Stop-Process -Id ${pid} -Force -ErrorAction SilentlyContinue`], { windowsHide: true });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* End ONE process and deliberately leave its children alone.
|
|
204
|
+
*
|
|
205
|
+
* This is the shape of a console teardown and of a `Stop-Process -Force`: the supervisor dies, and
|
|
206
|
+
* the detached agent -- which is not in its tree, that being the whole point of detaching it -- keeps
|
|
207
|
+
* running. A tree kill cannot model that, because a tree kill takes the survivor with it, so the
|
|
208
|
+
* orphan this whole defect is about would never exist to be reaped.
|
|
209
|
+
*/
|
|
210
|
+
function killOnly(pid) {
|
|
211
|
+
if (!pid || !isAlive(pid)) return true;
|
|
212
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
213
|
+
if (!isAlive(pid)) return true;
|
|
214
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(pid), '/F'], { windowsHide: true });
|
|
215
|
+
else {
|
|
216
|
+
try {
|
|
217
|
+
process.kill(pid, 'SIGKILL');
|
|
218
|
+
} catch {
|
|
219
|
+
/* gone */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return !isAlive(pid);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const results = [];
|
|
227
|
+
function record(name, ok, detail, s) {
|
|
228
|
+
results.push({ name, ok, detail });
|
|
229
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`);
|
|
230
|
+
if (detail) console.log(` ${detail}`);
|
|
231
|
+
// A failing check must be able to explain itself: if a generation died before it opened its log,
|
|
232
|
+
// its stderr is the only place the reason exists.
|
|
233
|
+
if (!ok && s) {
|
|
234
|
+
console.log(` supervisor stdout/stderr (last lines):\n ${supervisorStdio(s)}`);
|
|
235
|
+
console.log(` supervisor decision trace (last lines):\n ${supervisorTrace(s)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------------------------
|
|
240
|
+
// THE STUB WORKER
|
|
241
|
+
//
|
|
242
|
+
// It is `w2m-localside` in name and in argument shape so that the supervisor's identity rule (script
|
|
243
|
+
// name + state directory) matches it exactly as it matches the real agent; it binds the punch port
|
|
244
|
+
// on 0.0.0.0 because that is what the real agent binds; and it writes the pid it ended up as,
|
|
245
|
+
// because an assertion should know which process owns the port rather than trust a spawn.
|
|
246
|
+
// ---------------------------------------------------------------------------------------------
|
|
247
|
+
const stubSource = `import { createSocket } from 'node:dgram';
|
|
248
|
+
import { writeFileSync } from 'node:fs';
|
|
249
|
+
const argv = process.argv.slice(2);
|
|
250
|
+
const at = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : undefined; };
|
|
251
|
+
const port = Number(at('--p2p-port'));
|
|
252
|
+
const out = at('--port-file');
|
|
253
|
+
const state = at('--state');
|
|
254
|
+
const socket = createSocket('udp4');
|
|
255
|
+
socket.on('error', (error) => {
|
|
256
|
+
writeFileSync(out, JSON.stringify({ pid: process.pid, state, bind: 'failed', code: error.code, at: new Date().toISOString() }));
|
|
257
|
+
setTimeout(() => process.exit(3), 50);
|
|
258
|
+
});
|
|
259
|
+
socket.bind({ address: '0.0.0.0', port }, () => {
|
|
260
|
+
writeFileSync(out, JSON.stringify({ pid: process.pid, state, bind: 'ok', bound_port: socket.address().port, at: new Date().toISOString() }));
|
|
261
|
+
});
|
|
262
|
+
setInterval(() => {}, 1000);
|
|
263
|
+
`;
|
|
264
|
+
|
|
265
|
+
/** One tree per scenario, so no two scenarios can see each other's lock, heartbeat or port. */
|
|
266
|
+
let activeScenario = null;
|
|
267
|
+
function scenario(name, port) {
|
|
268
|
+
const dir = join(root, name);
|
|
269
|
+
// The stub lives in `bin-w2m-localside/`, and its *path* is deliberately free of the string
|
|
270
|
+
// `w2m-agent-supervisor`: that marker is how the supervisor recognises a supervisor, and a stub
|
|
271
|
+
// whose path contained it would be reported as one. The directory name still carries the marker for
|
|
272
|
+
// the diagnostic that counts live supervisor processes, which is the shape of a real installation
|
|
273
|
+
// (`deploy/windows/` is under a `w2m-agent-supervisor`-named tree in the fleet's own layout).
|
|
274
|
+
const binDir = join(dir, 'bin-w2m-localside');
|
|
275
|
+
mkdirSync(binDir, { recursive: true });
|
|
276
|
+
const stateDir = join(dir, 'localside');
|
|
277
|
+
mkdirSync(stateDir, { recursive: true });
|
|
278
|
+
const portFile = join(dir, 'port.json');
|
|
279
|
+
if (existsSync(portFile)) rmSync(portFile, { force: true });
|
|
280
|
+
writeFileSync(join(binDir, 'w2m-localside-stub.mjs'), stubSource, 'utf8');
|
|
281
|
+
writeFileSync(join(dir, 'agent.json'), `${JSON.stringify({ note: 'written by verify-supervisor.mjs' }, null, 2)}\n`, 'utf8');
|
|
282
|
+
activeScenario = {
|
|
283
|
+
name,
|
|
284
|
+
dir,
|
|
285
|
+
port,
|
|
286
|
+
stubPath: join(binDir, 'w2m-localside-stub.mjs'),
|
|
287
|
+
stateDir,
|
|
288
|
+
logPath: join(dir, 'agent.log'),
|
|
289
|
+
lockPath: join(dir, 'supervisor.lock'),
|
|
290
|
+
heartbeatPath: join(dir, 'supervisor-heartbeat.json'),
|
|
291
|
+
portFile,
|
|
292
|
+
stdioPath: null,
|
|
293
|
+
tracePath: join(dir, 'supervisor-trace.jsonl'),
|
|
294
|
+
stubs: [],
|
|
295
|
+
// EVERY generation this check starts, not just the newest: the scenario that starts a second
|
|
296
|
+
// supervisor to watch it refuse the lock would otherwise leak the first one and its agent.
|
|
297
|
+
supervisors: [],
|
|
298
|
+
generations: 0,
|
|
299
|
+
};
|
|
300
|
+
return activeScenario;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Terminate every agent of this identity and forget the lock, the heartbeat and the trace -- the
|
|
305
|
+
* "and now start from nothing" an operator does by hand, and one of the things being checked. It
|
|
306
|
+
* reaches agents from *earlier runs* too, which is exactly what a stable identity buys.
|
|
307
|
+
*/
|
|
308
|
+
function cleanIdentity(s) {
|
|
309
|
+
const killed = [];
|
|
310
|
+
for (const pid of agentsFor(s.stateDir)) {
|
|
311
|
+
killTree(pid);
|
|
312
|
+
killed.push(pid);
|
|
313
|
+
}
|
|
314
|
+
for (const pid of s.supervisors.map((child) => child.pid)) killTree(pid);
|
|
315
|
+
s.supervisors = [];
|
|
316
|
+
for (const file of [s.lockPath, s.heartbeatPath, s.tracePath, s.portFile]) rmSync(file, { force: true });
|
|
317
|
+
for (const generation of [0, 1, 2, 3, 4]) rmSync(join(s.dir, `supervisor-stdio-${generation}.log`), { force: true });
|
|
318
|
+
s.stubs = [];
|
|
319
|
+
s.generations = 0;
|
|
320
|
+
return killed;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function startSupervisor(s) {
|
|
324
|
+
const args = [
|
|
325
|
+
supervisorPath,
|
|
326
|
+
'--config', join(s.dir, 'agent.json'),
|
|
327
|
+
'--bin', s.stubPath,
|
|
328
|
+
'--state', s.stateDir,
|
|
329
|
+
'--log', s.logPath,
|
|
330
|
+
'--project', s.dir,
|
|
331
|
+
'--p2p-port', String(s.port),
|
|
332
|
+
'--p2p-mode', 'auto',
|
|
333
|
+
'--port-file', s.portFile,
|
|
334
|
+
'--backoff-ms', '2000',
|
|
335
|
+
'--degraded-after', '99',
|
|
336
|
+
];
|
|
337
|
+
// stdout/stderr go to a file rather than nowhere: if a generation dies before it opens its log,
|
|
338
|
+
// the reason is a Node stack trace on stderr, and a check that swallows it is a check that cannot
|
|
339
|
+
// explain its own failure.
|
|
340
|
+
const stdioPath = join(s.dir, `supervisor-stdio-${s.generations}.log`);
|
|
341
|
+
s.generations += 1;
|
|
342
|
+
s.stdioPath = stdioPath;
|
|
343
|
+
s.tracePath = join(s.dir, 'supervisor-trace.jsonl');
|
|
344
|
+
const fd = openSync(stdioPath, 'a');
|
|
345
|
+
const supervisor = spawn(process.execPath, args, {
|
|
346
|
+
stdio: ['ignore', fd, fd],
|
|
347
|
+
windowsHide: true,
|
|
348
|
+
env: { ...process.env, W2M_SUPERVISOR_TRACE: s.tracePath },
|
|
349
|
+
});
|
|
350
|
+
closeSync(fd);
|
|
351
|
+
s.supervisors.push(supervisor);
|
|
352
|
+
return supervisor;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Whatever the generations wrote to stdout/stderr outside the log, for a failing check to print. */
|
|
356
|
+
function supervisorStdio(s) {
|
|
357
|
+
try {
|
|
358
|
+
return readFileSync(s.stdioPath, 'utf8').trim().split(/\r?\n/).slice(-12).join('\n ');
|
|
359
|
+
} catch {
|
|
360
|
+
return '(none)';
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The supervisor's own decision trace, so a failing check can show what it decided and when. */
|
|
365
|
+
function supervisorTrace(s) {
|
|
366
|
+
try {
|
|
367
|
+
return readFileSync(s.tracePath, 'utf8').trim().split(/\r?\n/).slice(-16).join('\n ');
|
|
368
|
+
} catch {
|
|
369
|
+
return '(none)';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A stub worker started outside any supervisor: the orphan the incident was made of. */
|
|
374
|
+
function startOrphan(s, port) {
|
|
375
|
+
const args = [s.stubPath, '--state', s.stateDir, '--p2p-port', String(port), '--port-file', s.portFile];
|
|
376
|
+
const child = spawn(process.execPath, args, { stdio: 'ignore', detached: true, windowsHide: true });
|
|
377
|
+
child.unref();
|
|
378
|
+
s.stubs.push(child.pid);
|
|
379
|
+
return child;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function waitFor(label, predicate, timeoutMs, stepMs = 250) {
|
|
383
|
+
const began = Date.now();
|
|
384
|
+
for (;;) {
|
|
385
|
+
const value = await predicate();
|
|
386
|
+
if (value) return value;
|
|
387
|
+
if (Date.now() - began >= timeoutMs) throw new Error(`timed out waiting for ${label}`);
|
|
388
|
+
await sleep(stepMs);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function heartbeat(s) {
|
|
393
|
+
try {
|
|
394
|
+
return JSON.parse(readFileSync(s.heartbeatPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Wait until the heartbeat describes a *settled* agent: this supervisor's child, alive, with the
|
|
402
|
+
* punch port recorded as bound by that child.
|
|
403
|
+
*
|
|
404
|
+
* Reading the heartbeat the instant the port looks busy races the supervisor's own bookkeeping -- the
|
|
405
|
+
* port is bound a fraction of a second before `port.state: bound` is written -- and a check that
|
|
406
|
+
* fails on that race reports a defect that is not there. This wait was added after three checks
|
|
407
|
+
* "failed" that way.
|
|
408
|
+
*/
|
|
409
|
+
function waitForSettled(s, port, timeoutMs = 90_000) {
|
|
410
|
+
return waitFor('a settled heartbeat: running, punch port bound by this generation\'s child', async () => {
|
|
411
|
+
const hb = heartbeat(s);
|
|
412
|
+
if (hb?.state !== 'running' || !Number.isInteger(hb.child_pid)) return null;
|
|
413
|
+
if (hb.port?.state !== 'bound' || hb.port?.owner_pid !== hb.child_pid) return null;
|
|
414
|
+
if (await portIsFree(port)) return null;
|
|
415
|
+
if (agentsFor(s.stateDir).length !== 1) return null;
|
|
416
|
+
return hb;
|
|
417
|
+
}, timeoutMs, 700);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Wait for a *new* generation to settle, one whose supervisor pid differs from `previousPid`. Without
|
|
422
|
+
* the pid test a heartbeat left by the previous generation reads as current state, and the assertion
|
|
423
|
+
* ends up being about a process that is already gone.
|
|
424
|
+
*/
|
|
425
|
+
function waitForSettledGeneration(s, port, previousPid, timeoutMs = 90_000) {
|
|
426
|
+
return waitFor('a new settled generation', async () => {
|
|
427
|
+
const hb = heartbeat(s);
|
|
428
|
+
if (hb?.supervisor_pid === previousPid || hb?.state !== 'running' || !Number.isInteger(hb.child_pid)) return null;
|
|
429
|
+
if (hb.port?.state !== 'bound' || hb.port?.owner_pid !== hb.child_pid) return null;
|
|
430
|
+
if (await portIsFree(port)) return null;
|
|
431
|
+
if (agentsFor(s.stateDir).length !== 1) return null;
|
|
432
|
+
return hb;
|
|
433
|
+
}, timeoutMs, 700);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function lockFile(s) {
|
|
437
|
+
try {
|
|
438
|
+
return JSON.parse(readFileSync(s.lockPath, 'utf8').replace(/^\uFEFF/, ''));
|
|
439
|
+
} catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function logText(s) {
|
|
445
|
+
try {
|
|
446
|
+
return readFileSync(s.logPath, 'utf8');
|
|
447
|
+
} catch {
|
|
448
|
+
return '';
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function cleanupScenario(s) {
|
|
453
|
+
// ORDER MATTERS, and this is the same lesson as the defect itself: `taskkill /T` follows the
|
|
454
|
+
// *parent* link, and a detached agent has been re-parented away from its supervisor -- so killing
|
|
455
|
+
// the supervisor's tree cannot reach it. Kill by identity first, then the supervisor, then confirm
|
|
456
|
+
// by identity again. Workers this harness leaked would otherwise read as orphans to the next
|
|
457
|
+
// scenario, which is precisely how the fleet accumulated nine agents.
|
|
458
|
+
for (const supervisor of s.supervisors) killTree(supervisor.pid);
|
|
459
|
+
s.supervisors = [];
|
|
460
|
+
const trackedStubs = s.stubs.filter((pid) => !agentsFor(s.stateDir).includes(pid));
|
|
461
|
+
for (const pid of trackedStubs) killTree(pid);
|
|
462
|
+
for (const pid of agentsFor(s.stateDir)) killTree(pid);
|
|
463
|
+
const left = agentsFor(s.stateDir);
|
|
464
|
+
if (left.length > 0) console.log(` WARNING: ${left.length} agent(s) of ${s.dir} survived cleanup: [${left.join(',')}]`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------------------------
|
|
468
|
+
// SCENARIOS
|
|
469
|
+
// ---------------------------------------------------------------------------------------------
|
|
470
|
+
async function checkHealthyStart() {
|
|
471
|
+
const port = await unboundPort();
|
|
472
|
+
const s = scenario('stale', port);
|
|
473
|
+
try {
|
|
474
|
+
// A clean start: no lock file, no lock sidecar, no heartbeats, no agents. Anything left from an
|
|
475
|
+
// earlier run would show up in `orphans_reaped`, which the assertion below checks is zero.
|
|
476
|
+
cleanIdentity(s);
|
|
477
|
+
startSupervisor(s);
|
|
478
|
+
const hb = await waitForSettled(s, port);
|
|
479
|
+
const agents = agentsFor(s.stateDir);
|
|
480
|
+
const owner = udpOwner(port);
|
|
481
|
+
const ok = agents.length === 1 && agents[0] === hb.child_pid && owner === hb.child_pid && hb.orphans_reaped === 0 && hb.port?.state === 'bound';
|
|
482
|
+
record(
|
|
483
|
+
'clean start: one supervisor, one agent, the punch port bound by that agent',
|
|
484
|
+
ok,
|
|
485
|
+
`agents=[${agentRowsFor(s.stateDir)
|
|
486
|
+
.map((row) => `${row.pid}${row.pid === hb.child_pid ? ' (this generation)' : ' (NOT ours)'}`)
|
|
487
|
+
.join(', ')}] port_owner=${owner} heartbeat.child_pid=${hb.child_pid} port.state=${hb.port?.state} ` +
|
|
488
|
+
`orphans_reaped=${hb.orphans_reaped} lock=${hb.lock?.pid === hb.supervisor_pid ? 'held by this pid' : JSON.stringify(hb.lock)}`,
|
|
489
|
+
s,
|
|
490
|
+
);
|
|
491
|
+
} finally {
|
|
492
|
+
cleanupScenario(s);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function checkStaleOrphansAndStaleLock() {
|
|
497
|
+
const port = await unboundPort();
|
|
498
|
+
const extraPort = await unboundPort();
|
|
499
|
+
const s = scenario('stale', port);
|
|
500
|
+
try {
|
|
501
|
+
// The state this machine was actually in, built on purpose: two agents of ONE identity with the
|
|
502
|
+
// oldest holding the punch port, a lock file left by a supervisor that was killed, and the
|
|
503
|
+
// agent's own config naming the pinned port.
|
|
504
|
+
cleanIdentity(s);
|
|
505
|
+
writeFileSync(s.lockPath, `${JSON.stringify({ pid: 999_999_999, started_at: '1970-01-01T00:00:00.000Z', argv: 'a supervisor that was killed' }, null, 2)}\n`, 'utf8');
|
|
506
|
+
const holder = startOrphan(s, port);
|
|
507
|
+
const extra = startOrphan(s, extraPort);
|
|
508
|
+
await waitFor('the oldest orphan to bind the punch port', async () => (await portIsFree(port)) === false, 20_000);
|
|
509
|
+
await waitFor('the second orphan to be up', async () => isAlive(extra.pid), 20_000);
|
|
510
|
+
await sleep(500);
|
|
511
|
+
const before = agentsFor(s.stateDir);
|
|
512
|
+
const portHolderBefore = udpOwner(port);
|
|
513
|
+
|
|
514
|
+
startSupervisor(s);
|
|
515
|
+
const hb = await waitForSettled(s, port);
|
|
516
|
+
const owner = udpOwner(port);
|
|
517
|
+
const log = logText(s);
|
|
518
|
+
const agents = agentsFor(s.stateDir);
|
|
519
|
+
const reapedNamed = Array.isArray(hb.orphans_reaped_pids) && hb.orphans_reaped_pids.includes(holder.pid) && hb.orphans_reaped_pids.includes(extra.pid);
|
|
520
|
+
const lockTakenOver = /stale lock taken over/.test(log) && hb.lock?.takeovers_total >= 1;
|
|
521
|
+
const ok = before.length === 2 && agents.length === 1 && owner === hb.child_pid && hb.orphans_reaped === 2 && reapedNamed && lockTakenOver;
|
|
522
|
+
record(
|
|
523
|
+
'stale orphans + stale lock: every agent of this identity reaped, exactly one left, port rebound',
|
|
524
|
+
ok,
|
|
525
|
+
`before=${before.length} agent(s) [pids ${before.join(',')}, punch-port holder=${holder.pid}, was holding the port=${portHolderBefore === holder.pid}] ` +
|
|
526
|
+
`-> after=${agents.length} (pid=${hb.child_pid}); port_owner=${owner}; heartbeat.orphans_reaped=${hb.orphans_reaped} ` +
|
|
527
|
+
`orphans_reaped_pids=[${(hb.orphans_reaped_pids ?? []).join(',')}]; lock.takeovers_total=${hb.lock?.takeovers_total}; ` +
|
|
528
|
+
`log says "stale lock taken over"=${/stale lock taken over/.test(log)}`,
|
|
529
|
+
s,
|
|
530
|
+
);
|
|
531
|
+
} finally {
|
|
532
|
+
cleanupScenario(s);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function checkLockStopsASecondGeneration() {
|
|
537
|
+
const port = await unboundPort();
|
|
538
|
+
const s = scenario('fresh', port);
|
|
539
|
+
try {
|
|
540
|
+
cleanIdentity(s);
|
|
541
|
+
startSupervisor(s);
|
|
542
|
+
const first = await waitForSettled(s, port);
|
|
543
|
+
const firstAgents = agentsFor(s.stateDir);
|
|
544
|
+
// The watchdog tick: the task starts a second supervisor while a healthy one runs.
|
|
545
|
+
const second = startSupervisor(s);
|
|
546
|
+
const exited = await Promise.race([
|
|
547
|
+
new Promise((resolve) => {
|
|
548
|
+
second.on('exit', (code) => resolve({ code }));
|
|
549
|
+
}),
|
|
550
|
+
sleep(20_000).then(() => ({ code: 'still-running' })),
|
|
551
|
+
]);
|
|
552
|
+
await sleep(1_500);
|
|
553
|
+
const hb = heartbeat(s);
|
|
554
|
+
const agents = agentsFor(s.stateDir);
|
|
555
|
+
const log = logText(s);
|
|
556
|
+
// The second generation writes its no-op line and exits immediately; the log write is synchronous
|
|
557
|
+
// for exactly this case, so the evidence is in the file by the time it is read here.
|
|
558
|
+
const noOpSaid = /another supervisor generation already holds the lock/.test(log) && /exiting without spawning/.test(log);
|
|
559
|
+
const secondPid = second.pid;
|
|
560
|
+
const ok =
|
|
561
|
+
exited.code === 0 &&
|
|
562
|
+
agents.length === 1 &&
|
|
563
|
+
hb.supervisor_pid === first.supervisor_pid &&
|
|
564
|
+
hb.child_pid === first.child_pid &&
|
|
565
|
+
noOpSaid &&
|
|
566
|
+
!isAlive(secondPid);
|
|
567
|
+
record(
|
|
568
|
+
'watchdog tick during a healthy run: second generation exits without spawning',
|
|
569
|
+
ok,
|
|
570
|
+
`second generation (pid=${secondPid}) exit=${JSON.stringify(exited)}, still alive=${isAlive(secondPid)}; heartbeat still owned by ` +
|
|
571
|
+
`supervisor_pid=${hb.supervisor_pid} (first was ${first.supervisor_pid}) with child_pid=${hb.child_pid}; agents before=${firstAgents.length} ` +
|
|
572
|
+
`after=${agents.length}; log admits the no-op=${noOpSaid}`,
|
|
573
|
+
s,
|
|
574
|
+
);
|
|
575
|
+
} finally {
|
|
576
|
+
cleanupScenario(s);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function checkUnreadableLock() {
|
|
581
|
+
const port = await unboundPort();
|
|
582
|
+
const s = scenario('fresh', port);
|
|
583
|
+
try {
|
|
584
|
+
cleanIdentity(s);
|
|
585
|
+
writeFileSync(s.lockPath, '{"pid": 4242, "started_at"', 'utf8'); // half-written, as a kill can leave it
|
|
586
|
+
startSupervisor(s);
|
|
587
|
+
const hb = await waitForSettled(s, port);
|
|
588
|
+
const log = logText(s);
|
|
589
|
+
const agents = agentsFor(s.stateDir);
|
|
590
|
+
const ok = agents.length === 1 && /stale lock taken over: not held because unreadable/.test(log) && hb.lock?.takeovers_total >= 1;
|
|
591
|
+
record(
|
|
592
|
+
'unreadable lock file: taken over, not treated as a live holder',
|
|
593
|
+
ok,
|
|
594
|
+
`heartbeat.state=${hb.state} agents=${agents.length} takeovers_total=${hb.lock?.takeovers_total} log="${/stale lock taken over[^\n]*/.exec(log)?.[0] ?? '(none)'}"`,
|
|
595
|
+
s,
|
|
596
|
+
);
|
|
597
|
+
} finally {
|
|
598
|
+
cleanupScenario(s);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function checkForeignPortHolderIsFatalAndNamed() {
|
|
603
|
+
const port = await unboundPort();
|
|
604
|
+
const s = scenario('fresh', port);
|
|
605
|
+
try {
|
|
606
|
+
cleanIdentity(s);
|
|
607
|
+
// Something that is NOT an agent of this identity holds the punch port: there is no orphan to
|
|
608
|
+
// reap, so the only honest answers are "refuse and say why" or "run blind". Item 3 decides.
|
|
609
|
+
const holderSource = `import { createSocket } from 'node:dgram';const s=createSocket('udp4');s.bind({address:'0.0.0.0',port:${port}},()=>{});setInterval(()=>{},1000);`;
|
|
610
|
+
const foreign = spawn(process.execPath, ['--input-type=module', '-e', holderSource], { stdio: 'ignore', windowsHide: true });
|
|
611
|
+
s.stubs.push(foreign.pid);
|
|
612
|
+
await waitFor('the foreign holder to bind', async () => (await portIsFree(port)) === false, 15_000);
|
|
613
|
+
startSupervisor(s);
|
|
614
|
+
await waitFor('the refusal to be recorded', async () => heartbeat(s)?.last_reason === 'P2P_PORT_HELD_BY_OTHER', 60_000);
|
|
615
|
+
const hb = heartbeat(s);
|
|
616
|
+
const agents = agentsFor(s.stateDir);
|
|
617
|
+
const ok = agents.length === 0 && hb.port?.state === 'bound-by-other' && hb.port?.owner_pid === foreign.pid;
|
|
618
|
+
record(
|
|
619
|
+
'punch port held by a foreign process: no agent is started, reason is named in the heartbeat',
|
|
620
|
+
ok,
|
|
621
|
+
// `state` is read from the last transition rather than asserted: the supervisor cycles
|
|
622
|
+
// degraded -> backoff -> gate -> degraded while the port stays blocked, and any single sample
|
|
623
|
+
// of it is a moment in that cycle. `last_reason` and `port.state` are the durable facts.
|
|
624
|
+
`agents=${agents.length} heartbeat.state=${hb.state} last_reason=${hb.last_reason} port.state=${hb.port?.state} ` +
|
|
625
|
+
`port.owner_pid=${hb.port?.owner_pid} (foreign pid=${foreign.pid})`,
|
|
626
|
+
s,
|
|
627
|
+
);
|
|
628
|
+
} finally {
|
|
629
|
+
cleanupScenario(s);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function checkForceKillRecovery() {
|
|
634
|
+
const port = await unboundPort();
|
|
635
|
+
const s = scenario('fresh', port);
|
|
636
|
+
try {
|
|
637
|
+
cleanIdentity(s);
|
|
638
|
+
startSupervisor(s);
|
|
639
|
+
const g1 = await waitForSettled(s, port);
|
|
640
|
+
forceKill(g1.supervisor_pid);
|
|
641
|
+
await waitFor('generation 1 to be gone', async () => !isAlive(g1.supervisor_pid), 15_000);
|
|
642
|
+
const orphanStillThere = isAlive(g1.child_pid);
|
|
643
|
+
const lockLeftBehind = lockFile(s) !== null;
|
|
644
|
+
// Generation 2 is the next watchdog tick.
|
|
645
|
+
startSupervisor(s);
|
|
646
|
+
const hb = await waitForSettledGeneration(s, port, g1.supervisor_pid);
|
|
647
|
+
const agents = agentsFor(s.stateDir);
|
|
648
|
+
const owner = udpOwner(port);
|
|
649
|
+
const ok =
|
|
650
|
+
orphanStillThere &&
|
|
651
|
+
lockLeftBehind &&
|
|
652
|
+
agents.length === 1 &&
|
|
653
|
+
agents[0] === hb.child_pid &&
|
|
654
|
+
owner === hb.child_pid &&
|
|
655
|
+
hb.lock?.takeovers_total >= 1 &&
|
|
656
|
+
(hb.orphans_reaped_pids ?? []).includes(g1.child_pid);
|
|
657
|
+
record(
|
|
658
|
+
'supervisor killed with Stop-Process -Force: stale lock taken over, orphan reaped, one agent left',
|
|
659
|
+
ok,
|
|
660
|
+
`after the kill: supervisor ${g1.supervisor_pid} gone, agent ${g1.child_pid} survived=${orphanStillThere}, lock file left behind=${lockLeftBehind}; ` +
|
|
661
|
+
`generation 2 supervisor_pid=${hb.supervisor_pid} reaped=[${(hb.orphans_reaped_pids ?? []).join(',')}] agents=${agents.length} ` +
|
|
662
|
+
`port_owner=${owner} takeovers_total=${hb.lock?.takeovers_total}`,
|
|
663
|
+
);
|
|
664
|
+
} finally {
|
|
665
|
+
cleanupScenario(s);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function checkTreeKillRecovery() {
|
|
670
|
+
const port = await unboundPort();
|
|
671
|
+
const s = scenario('fresh', port);
|
|
672
|
+
try {
|
|
673
|
+
cleanIdentity(s);
|
|
674
|
+
startSupervisor(s);
|
|
675
|
+
const g1 = await waitForSettled(s, port);
|
|
676
|
+
// The console-teardown shape: Task Scheduler ends the task's tree with 0xC000013A. The detached
|
|
677
|
+
// agent is not in that tree -- that is the whole point of detaching it -- so it survives, and
|
|
678
|
+
// that survival is what used to leak. A forced tree kill is the reproducible stand-in.
|
|
679
|
+
killOnly(g1.supervisor_pid);
|
|
680
|
+
await waitFor('generation 1 to be gone', async () => !isAlive(g1.supervisor_pid), 15_000);
|
|
681
|
+
const orphanStillThere = isAlive(g1.child_pid);
|
|
682
|
+
startSupervisor(s);
|
|
683
|
+
const hb = await waitForSettledGeneration(s, port, g1.supervisor_pid);
|
|
684
|
+
const agents = agentsFor(s.stateDir);
|
|
685
|
+
const owner = udpOwner(port);
|
|
686
|
+
const ok =
|
|
687
|
+
orphanStillThere &&
|
|
688
|
+
agents.length === 1 &&
|
|
689
|
+
agents[0] === hb.child_pid &&
|
|
690
|
+
owner === hb.child_pid &&
|
|
691
|
+
(hb.orphans_reaped_pids ?? []).includes(g1.child_pid);
|
|
692
|
+
record(
|
|
693
|
+
'tree kill (console-teardown shape): the detached orphan is reaped by the next generation',
|
|
694
|
+
ok,
|
|
695
|
+
`after the tree kill: supervisor ${g1.supervisor_pid} gone, detached agent ${g1.child_pid} survived=${orphanStillThere}; ` +
|
|
696
|
+
`generation 2 reaped=[${(hb.orphans_reaped_pids ?? []).join(',')}] agents=${agents.length} port_owner=${owner}`,
|
|
697
|
+
);
|
|
698
|
+
} finally {
|
|
699
|
+
cleanupScenario(s);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function checkRepeatedKillsDoNotAccumulate() {
|
|
704
|
+
const port = await unboundPort();
|
|
705
|
+
const extraPort = await unboundPort();
|
|
706
|
+
const s = scenario('fresh', port);
|
|
707
|
+
try {
|
|
708
|
+
cleanIdentity(s);
|
|
709
|
+
// The regime that produced fourteen processes: kill after kill, each leaving its detached agent.
|
|
710
|
+
// The invariant is not "one orphan is cleaned up", it is "nothing accumulates" -- so the third
|
|
711
|
+
// generation must still find exactly one agent of this identity, whoever started it.
|
|
712
|
+
const counts = [];
|
|
713
|
+
let firstOrphan = null;
|
|
714
|
+
let previousPid = null;
|
|
715
|
+
for (let generation = 1; generation <= 3; generation += 1) {
|
|
716
|
+
if (generation === 1) startOrphan(s, extraPort); // a pre-existing orphan, older than any supervisor here
|
|
717
|
+
startSupervisor(s);
|
|
718
|
+
const hb = generation === 1 ? await waitForSettled(s, port) : await waitForSettledGeneration(s, port, previousPid);
|
|
719
|
+
previousPid = hb.supervisor_pid;
|
|
720
|
+
if (generation === 1) firstOrphan = hb.child_pid;
|
|
721
|
+
const agents = agentsFor(s.stateDir);
|
|
722
|
+
counts.push(agents.length);
|
|
723
|
+
if (generation < 3) {
|
|
724
|
+
forceKill(hb.supervisor_pid);
|
|
725
|
+
await waitFor(`generation ${generation} to be gone`, async () => !isAlive(hb.supervisor_pid), 15_000);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const hb = heartbeat(s);
|
|
729
|
+
const agents = agentsFor(s.stateDir);
|
|
730
|
+
const owner = udpOwner(port);
|
|
731
|
+
const ok = counts.every((count) => count === 1) && agents.length === 1 && owner === hb.child_pid && firstOrphan !== null && !isAlive(firstOrphan);
|
|
732
|
+
record(
|
|
733
|
+
'three generations with kills in between: the agent count never exceeds one',
|
|
734
|
+
ok,
|
|
735
|
+
`agents observed after each generation start=[${counts.join(',')}] final agents=${agents.length} (pid=${agents[0]}) port_owner=${owner}; ` +
|
|
736
|
+
`the pre-existing orphan pid=${firstOrphan} alive=${firstOrphan !== null && isAlive(firstOrphan)}`,
|
|
737
|
+
);
|
|
738
|
+
} finally {
|
|
739
|
+
cleanupScenario(s);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------------------------------
|
|
744
|
+
console.log(`w2m supervisor invariant check -- ${new Date().toISOString()}`);
|
|
745
|
+
console.log(`supervisor : ${supervisorPath}`);
|
|
746
|
+
console.log(`temp tree : ${root}${keep ? ' (kept)' : ''}`);
|
|
747
|
+
console.log('');
|
|
748
|
+
|
|
749
|
+
// Before anything runs, end every agent of both identities and forget their locks. This is also the
|
|
750
|
+
// first demonstration of the property under test: it reaches agents left by earlier runs precisely
|
|
751
|
+
// because the identity is stable, which is what the supervisor's reap does too.
|
|
752
|
+
const preCleaned = [...cleanIdentity(scenario('stale', 0)), ...cleanIdentity(scenario('fresh', 0))];
|
|
753
|
+
console.log(`pre-clean: terminated ${preCleaned.length} agent(s) left by earlier runs${preCleaned.length > 0 ? ` [pids ${preCleaned.join(',')}]` : ''}`);
|
|
754
|
+
console.log('');
|
|
755
|
+
|
|
756
|
+
const scenarios = [
|
|
757
|
+
['clean start', checkHealthyStart],
|
|
758
|
+
['stale orphans + stale lock', checkStaleOrphansAndStaleLock],
|
|
759
|
+
['live lock blocks a second generation', checkLockStopsASecondGeneration],
|
|
760
|
+
['unreadable lock', checkUnreadableLock],
|
|
761
|
+
['foreign punch-port holder', checkForeignPortHolderIsFatalAndNamed],
|
|
762
|
+
['Stop-Process -Force recovery', checkForceKillRecovery],
|
|
763
|
+
['tree-kill recovery', checkTreeKillRecovery],
|
|
764
|
+
['repeated kills do not accumulate', checkRepeatedKillsDoNotAccumulate],
|
|
765
|
+
];
|
|
766
|
+
const onlyArg = process.argv.indexOf('--only');
|
|
767
|
+
const only = onlyArg >= 0 ? process.argv[onlyArg + 1] : null;
|
|
768
|
+
const selected = only === null ? scenarios : scenarios.filter(([name]) => name.includes(only));
|
|
769
|
+
if (selected.length === 0) {
|
|
770
|
+
console.log(`no scenario matches "${only}"; known: ${scenarios.map(([name]) => name).join(' | ')}`);
|
|
771
|
+
process.exit(2);
|
|
772
|
+
}
|
|
773
|
+
for (const [name, run] of selected) {
|
|
774
|
+
try {
|
|
775
|
+
await run();
|
|
776
|
+
} catch (error) {
|
|
777
|
+
record(name, false, `threw: ${error.message}`, activeScenario);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const failed = results.filter((r) => !r.ok);
|
|
782
|
+
console.log('');
|
|
783
|
+
console.log(`${results.length - failed.length}/${results.length} checks passed`);
|
|
784
|
+
console.log('NOT exercised here: the real agent, the machine\'s real punch port, and Windows Task');
|
|
785
|
+
console.log('Scheduler. This check drives w2m-agent-supervisor.mjs directly with a stub worker, on');
|
|
786
|
+
console.log('ports the OS picks, so it needs no elevation and cannot disturb a running installation.');
|
|
787
|
+
if (!keep) {
|
|
788
|
+
try {
|
|
789
|
+
rmSync(root, { recursive: true, force: true });
|
|
790
|
+
} catch (error) {
|
|
791
|
+
console.log(`could not remove ${root}: ${error.message}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
process.exit(failed.length === 0 ? 0 : 1);
|