dsh-autorestart 0.8.0 → 0.8.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.
@@ -0,0 +1,474 @@
1
+ /**
2
+ * THE WATCHDOG DRILL: four real scenarios, and the tiers on a real machine.
3
+ *
4
+ * Nothing here touches the profile of a running DSH: the drill works in its
5
+ * own directory under the temp directory, with a FAKE host (a small script
6
+ * that either comes up and writes the ready marker, or stays down).
7
+ *
8
+ * 1. the host comes up -> the watchdog stands down, NO restore
9
+ * 2. the host never comes up -> restore, start again, RECOVERED
10
+ * 3. the host stays down -> restore ONCE, start again, FAILED
11
+ * 4. the host is UP but wrote NO ready marker (the 0.8.2 incident) -> the
12
+ * watchdog ASKS THE HEALTH ROUTE, sees the host answer, does NOT restore,
13
+ * and still deletes the pending marker AND the scheduled task.
14
+ *
15
+ * And then the tiers, for real:
16
+ *
17
+ * - the Windows scheduled task is registered, read back, and REALLY FIRES
18
+ * (a marker file appears), and is deleted completely afterwards;
19
+ * - the detached process tier runs inside WSL (setsid nohup), and the
20
+ * systemd tier runs there too.
21
+ *
22
+ * The drill cleans up after itself: no leftover task, no leftover process,
23
+ * no leftover directory.
24
+ */
25
+
26
+ import { execFileSync, spawn } from 'node:child_process';
27
+ import crypto from 'node:crypto';
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+
33
+ import {
34
+ buildPending,
35
+ readReport,
36
+ readyPath,
37
+ writePending,
38
+ writeReady,
39
+ } from '../lib/watchdog.js';
40
+ import {
41
+ detachedPidPath,
42
+ localIso,
43
+ registerDetachedProcess,
44
+ registerScheduledTask,
45
+ removeScheduledTask,
46
+ } from '../lib/watchdog-schedule.js';
47
+
48
+ const here = path.dirname(fileURLToPath(import.meta.url));
49
+ const pluginDir = path.join(here, '..');
50
+ const drillRoot = path.join(os.tmpdir(), 'dsh-watchdog-drill-' + crypto.randomUUID());
51
+
52
+ /** The checks of the drill: every one of them must pass. */
53
+ const failures = [];
54
+ /**
55
+ * @param {string} what
56
+ * @param {boolean} ok
57
+ * @param {string} [detail]
58
+ */
59
+ function check(what, ok, detail = '') {
60
+ const line = (ok ? ' ok ' : ' FAIL ') + what + (detail ? ' (' + detail + ')' : '');
61
+ console.log(line);
62
+ if (!ok) failures.push(line);
63
+ }
64
+
65
+ /**
66
+ * Wait for a file to appear.
67
+ *
68
+ * @param {string} file
69
+ * @param {number} timeoutMs
70
+ * @returns {Promise<boolean>}
71
+ */
72
+ async function waitForFile(file, timeoutMs) {
73
+ const deadline = Date.now() + timeoutMs;
74
+ while (Date.now() < deadline) {
75
+ if (fs.existsSync(file)) return true;
76
+ await new Promise((resolve) => setTimeout(resolve, 200));
77
+ }
78
+ return fs.existsSync(file);
79
+ }
80
+
81
+ /**
82
+ * The fake host: it comes up (and writes the ready marker) or it stays down.
83
+ *
84
+ * @param {string} dir
85
+ * @param {'up'|'down'} behaviour
86
+ */
87
+ function fakeHost(dir, behaviour) {
88
+ const marker = path.join(dir, 'restore.log');
89
+ const script = path.join(dir, 'fake-host.mjs');
90
+ fs.writeFileSync(script, [
91
+ "import fs from 'node:fs';",
92
+ "import path from 'node:path';",
93
+ "const dir = process.argv[2];",
94
+ "const behaviour = process.argv[3];",
95
+ "if (behaviour === 'up') {",
96
+ " fs.writeFileSync(path.join(dir, 'watchdog-ready.json'), JSON.stringify({ at: Date.now() }));",
97
+ "}",
98
+ "fs.appendFileSync(path.join(dir, 'host.log'), behaviour + '\\n');",
99
+ "setTimeout(() => process.exit(0), 30000);",
100
+ '',
101
+ ].join('\n'), 'utf8');
102
+ return { script, marker };
103
+ }
104
+
105
+ /**
106
+ * The fake restore: it counts the attempts, and it can make the host come up.
107
+ *
108
+ * @param {string} dir
109
+ * @param {boolean} fixIt whether the restore makes the host come up
110
+ */
111
+ function fakeRestore(dir, fixIt) {
112
+ const script = path.join(dir, 'fake-restore.mjs');
113
+ fs.writeFileSync(script, [
114
+ "import fs from 'node:fs';",
115
+ "import path from 'node:path';",
116
+ "const dir = process.argv[2];",
117
+ "fs.appendFileSync(path.join(dir, 'restore.log'), 'restore\\n');",
118
+ fixIt
119
+ ? "fs.writeFileSync(path.join(dir, 'behaviour.txt'), 'up');"
120
+ : "fs.writeFileSync(path.join(dir, 'behaviour.txt'), 'down');",
121
+ '',
122
+ ].join('\n'), 'utf8');
123
+ return script;
124
+ }
125
+
126
+ /**
127
+ * The "start the host again" command: it reads the behaviour file.
128
+ *
129
+ * @param {string} dir
130
+ */
131
+ function fakeStart(dir) {
132
+ const script = path.join(dir, 'fake-start.mjs');
133
+ fs.writeFileSync(script, [
134
+ "import { spawn } from 'node:child_process';",
135
+ "import fs from 'node:fs';",
136
+ "import path from 'node:path';",
137
+ "const dir = process.argv[2];",
138
+ "const behaviour = fs.existsSync(path.join(dir, 'behaviour.txt'))",
139
+ " ? fs.readFileSync(path.join(dir, 'behaviour.txt'), 'utf8').trim()",
140
+ " : 'down';",
141
+ "const child = spawn(process.execPath, [path.join(dir, 'fake-host.mjs'), dir, behaviour], { detached: true, stdio: 'ignore' });",
142
+ "child.unref();",
143
+ '',
144
+ ].join('\n'), 'utf8');
145
+ return script;
146
+ }
147
+
148
+ /**
149
+ * Run one scenario of the drill.
150
+ *
151
+ * @param {{ name: string, behaviour: 'up'|'down', fixIt: boolean, expect: string }} options
152
+ */
153
+ async function scenario({ name, behaviour, fixIt, expect }) {
154
+ const dir = path.join(drillRoot, name);
155
+ fs.mkdirSync(dir, { recursive: true });
156
+ const restoreLog = path.join(dir, 'restore.log');
157
+ const readyMarker = readyPath(dir);
158
+ const host = fakeHost(dir, behaviour);
159
+ const restore = fakeRestore(dir, fixIt);
160
+ const start = fakeStart(dir);
161
+ fs.writeFileSync(path.join(dir, 'behaviour.txt'), behaviour);
162
+
163
+ // The pending marker: what the plugin writes before the restart.
164
+ writePending(dir, buildPending({
165
+ tier: 'detached-process',
166
+ tierReason: 'the drill starts the watchdog directly',
167
+ restoreCommand: { file: process.execPath, args: [restore, dir] },
168
+ relaunchCommand: { file: process.execPath, args: [start, dir] },
169
+ timeoutMs: 4000,
170
+ recheckMs: 1500,
171
+ readyTimeoutMs: 6000,
172
+ now: Date.now(),
173
+ }));
174
+
175
+ // Scenario 1: the host comes up. The others: it stays down.
176
+ if (behaviour === 'up') {
177
+ const child = spawn(process.execPath, [host.script, dir, 'up'], { detached: true, stdio: 'ignore' });
178
+ child.unref();
179
+ }
180
+
181
+ // The watchdog itself: the real CLI, in its own process.
182
+ const watchdog = spawn(process.execPath, [path.join(here, 'watchdog.mjs'), '--state-dir', dir], { stdio: 'ignore' });
183
+ await new Promise((resolve) => watchdog.on('exit', resolve));
184
+
185
+ const report = readReport(dir);
186
+ const restores = fs.existsSync(restoreLog) ? fs.readFileSync(restoreLog, 'utf8').trim().split('\n').filter(Boolean).length : 0;
187
+ check(name + ': the report says ' + expect, report !== null && report.result === expect, report ? report.result : 'no report');
188
+ if (expect === 'not-needed') {
189
+ check(name + ': nothing was restored', restores === 0, restores + ' restore(s)');
190
+ } else {
191
+ check(name + ': the restore ran exactly once', restores === 1, restores + ' restore(s)');
192
+ check(name + ': the report says which tier was used', report !== null && typeof report.tier === 'string' && report.tier !== '', report ? report.tier : '');
193
+ }
194
+ check(name + ': the pending marker is gone', !fs.existsSync(path.join(dir, 'watchdog-pending.json')));
195
+ check(name + ': the report was kept', fs.existsSync(path.join(dir, 'last-watchdog-report.json')));
196
+ return report;
197
+ }
198
+
199
+ /**
200
+ * A fake host that is UP and answers the health route -- and NEVER writes the
201
+ * ready marker. That is exactly the 0.8.2 incident: a healthy host with no
202
+ * marker, because the marker write failed and the failure was swallowed.
203
+ *
204
+ * @param {string} dir
205
+ */
206
+ function fakeHealthOnlyHost(dir) {
207
+ const script = path.join(dir, 'fake-health-host.mjs');
208
+ const portFile = path.join(dir, 'health-port.txt');
209
+ fs.writeFileSync(script, [
210
+ "import fs from 'node:fs';",
211
+ "import http from 'node:http';",
212
+ "import path from 'node:path';",
213
+ "const dir = process.argv[2];",
214
+ "const server = http.createServer((req, res) => {",
215
+ " res.statusCode = 200;",
216
+ " res.setHeader('content-type', 'application/json');",
217
+ " res.end(JSON.stringify({ ok: true }));",
218
+ "});",
219
+ "server.listen(0, '127.0.0.1', () => {",
220
+ " fs.writeFileSync(path.join(dir, 'health-port.txt'), String(server.address().port));",
221
+ "});",
222
+ "setTimeout(() => process.exit(0), 90000);",
223
+ '',
224
+ ].join('\n'), 'utf8');
225
+ return { script, portFile };
226
+ }
227
+
228
+ /**
229
+ * Does the Windows task still exist?
230
+ *
231
+ * @param {string} taskName
232
+ * @returns {boolean}
233
+ */
234
+ function taskStillExists(taskName) {
235
+ try {
236
+ execFileSync('schtasks', ['/Query', '/TN', taskName], { stdio: 'ignore', timeout: 15000 });
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * SCENARIO 4 -- THE HOST IS UP BUT WROTE NO READY MARKER (the 0.8.2 incident).
245
+ *
246
+ * The watchdog must ask the health route itself before it restores anything.
247
+ * The host answers, so the snapshot is NOT restored -- and the pending marker
248
+ * and the real scheduled task are still cleaned up (an armed task that fires
249
+ * later would restore a healthy profile).
250
+ */
251
+ async function scenarioHostUpWithoutMarker() {
252
+ const name = 'scenario-4-no-marker';
253
+ const dir = path.join(drillRoot, name);
254
+ fs.mkdirSync(dir, { recursive: true });
255
+ const restoreLog = path.join(dir, 'restore.log');
256
+ const restore = fakeRestore(dir, false);
257
+
258
+ // (1) The host: up, answering health, NO ready marker.
259
+ const host = fakeHealthOnlyHost(dir);
260
+ const child = spawn(process.execPath, [host.script, dir], { detached: true, stdio: 'ignore' });
261
+ child.unref();
262
+ const up = await waitForFile(host.portFile, 15000);
263
+ check(name + ': the fake host came up and answers health', up);
264
+ const port = up ? Number(fs.readFileSync(host.portFile, 'utf8').trim()) : 0;
265
+ check(name + ': the health port is known', Number.isFinite(port) && port > 0, String(port));
266
+
267
+ // (2) The instance record the real host writes at boot -- WITHOUT a boot id,
268
+ // so "is an instance alive" cannot be answered from it alone: only the
269
+ // direct health question can. That is the guard this scenario tests.
270
+ const portFile = path.join(dir, 'access.json');
271
+ fs.writeFileSync(portFile, JSON.stringify({ bootId: null, port: port > 0 ? port : null, at: Date.now() }), 'utf8');
272
+
273
+ // (3) A REAL scheduling item: the watchdog has to delete it, not only the
274
+ // pending marker. (On a machine without the task scheduler the drill
275
+ // falls back to the detached-process tier and checks the markers only.)
276
+ const taskName = 'dsh-autorestart-drill-' + crypto.randomUUID().slice(0, 8);
277
+ let registered = { ok: false, error: 'the task scheduler is not used here' };
278
+ if (process.platform === 'win32') {
279
+ registered = registerScheduledTask({
280
+ taskName,
281
+ startIso: localIso(new Date(Date.now() + 15 * 60 * 1000)),
282
+ endIso: localIso(new Date(Date.now() + 45 * 60 * 1000)),
283
+ command: 'echo fired > "' + path.join(dir, 'task-fired.txt') + '"',
284
+ xmlPath: path.join(dir, 'task.xml'),
285
+ });
286
+ check(name + ': the real scheduled task was registered', registered.ok === true, registered.error || '');
287
+ }
288
+
289
+ writePending(dir, buildPending({
290
+ tier: registered.ok === true ? 'scheduled-task' : 'detached-process',
291
+ tierReason: 'the drill arms the watchdog itself',
292
+ taskName: registered.ok === true ? taskName : '',
293
+ restoreCommand: { file: process.execPath, args: [restore, dir] },
294
+ healthPath: '/__dsh_autorestart/health',
295
+ portFile,
296
+ expectedBootId: 'the-boot-before-the-restart',
297
+ timeoutMs: 3000,
298
+ recheckMs: 800,
299
+ readyTimeoutMs: 4000,
300
+ now: Date.now(),
301
+ }));
302
+
303
+ const watchdog = spawn(process.execPath, [path.join(here, 'watchdog.mjs'), '--state-dir', dir], { stdio: 'ignore' });
304
+ await new Promise((resolve) => watchdog.on('exit', resolve));
305
+
306
+ const report = readReport(dir);
307
+ const restores = fs.existsSync(restoreLog)
308
+ ? fs.readFileSync(restoreLog, 'utf8').trim().split('\n').filter(Boolean).length
309
+ : 0;
310
+ check(name + ': the report says not-needed', report !== null && report.result === 'not-needed', report ? String(report.result) : 'no report');
311
+ check(name + ': the health route was asked and answered', report !== null && report.healthCheck === 'healthy', report ? String(report.healthCheck) : 'no report');
312
+ check(name + ': nothing was restored', restores === 0, restores + ' restore(s)');
313
+ check(name + ': the pending marker is gone', !fs.existsSync(path.join(dir, 'watchdog-pending.json')));
314
+ check(name + ': no task was fired', !fs.existsSync(path.join(dir, 'task-fired.txt')));
315
+ if (registered.ok === true) {
316
+ check(name + ': the scheduled task is gone', !taskStillExists(taskName), taskName);
317
+ }
318
+ try {
319
+ child.kill();
320
+ } catch {
321
+ // It is gone anyway (it exits by itself).
322
+ }
323
+ return report;
324
+ }
325
+
326
+ /**
327
+ * The Windows scheduled task, for real: register it, read it back, let it
328
+ * fire, delete it.
329
+ */
330
+ async function windowsTier() {
331
+ const dir = path.join(drillRoot, 'tier-windows');
332
+ fs.mkdirSync(dir, { recursive: true });
333
+ const marker = path.join(dir, 'fired.txt');
334
+ const taskName = 'dsh-autorestart-drill-' + crypto.randomUUID().slice(0, 8);
335
+ const xmlPath = path.join(dir, 'task.xml');
336
+ const start = new Date(Date.now() + 60 * 1000);
337
+ const end = new Date(Date.now() + 10 * 60 * 1000);
338
+ const registered = registerScheduledTask({
339
+ taskName,
340
+ startIso: localIso(start),
341
+ endIso: localIso(end),
342
+ command: 'echo fired > "' + marker + '"',
343
+ xmlPath,
344
+ });
345
+ check('tier 1: the task was registered and read back', registered.ok === true, registered.error || '');
346
+ if (!registered.ok) return;
347
+ // It must REALLY fire.
348
+ const fired = await waitForFile(marker, 100000);
349
+ check('tier 1: the task really fired', fired);
350
+ const removed = removeScheduledTask({ taskName });
351
+ check('tier 1: the task was deleted', removed.ok === true, removed.error || '');
352
+ let gone = false;
353
+ try {
354
+ execFileSync('schtasks', ['/Query', '/TN', taskName], { stdio: 'ignore', timeout: 15000 });
355
+ } catch {
356
+ gone = true;
357
+ }
358
+ check('tier 1: the task is really gone', gone);
359
+ try {
360
+ fs.rmSync(xmlPath, { force: true });
361
+ } catch {
362
+ // It is gone either way.
363
+ }
364
+ }
365
+
366
+ /**
367
+ * The Linux tiers, for real: inside WSL.
368
+ */
369
+ async function linuxTiers() {
370
+ const dir = path.join(drillRoot, 'tier-linux');
371
+ fs.mkdirSync(dir, { recursive: true });
372
+ const marker = path.join(dir, 'fired.txt');
373
+ const wslDir = wslPath(dir);
374
+ const helper = path.join(dir, 'tier.mjs');
375
+ fs.writeFileSync(helper, [
376
+ "import { registerDetachedProcess, registerSystemdRun, cleanupSystemdUnit } from '" + wslPath(path.join(pluginDir, 'lib', 'watchdog-schedule.js')).replace(/\\/g, '/') + "';",
377
+ "const dir = process.argv[2];",
378
+ "const marker = process.argv[3];",
379
+ "const which = process.argv[4];",
380
+ "if (which === 'systemd') {",
381
+ " const unit = 'dsh-watchdog-drill-' + Date.now();",
382
+ " const answer = registerSystemdRun({ unitId: unit, delaySeconds: 2, command: ['sh', '-c', 'sleep 1; echo fired > ' + marker] });",
383
+ " console.log(JSON.stringify({ ok: answer.ok, error: answer.error }));",
384
+ " setTimeout(() => { cleanupSystemdUnit({ unitId: unit }); }, 6000);",
385
+ "} else {",
386
+ " const answer = registerDetachedProcess({ delaySeconds: 2, command: \"sh -c 'echo fired > \" + marker + \"'\", pidPath: dir + '/pid' });",
387
+ " console.log(JSON.stringify({ ok: answer.ok, pid: answer.pid, error: answer.error }));",
388
+ "}",
389
+ '',
390
+ ].join('\n'), 'utf8');
391
+
392
+ // Tier 3: the detached process of our own.
393
+ try {
394
+ const out = execFileSync('wsl.exe', ['-e', 'node', wslPath(helper), wslDir, wslPath(marker), 'detached'], { encoding: 'utf8', timeout: 60000 });
395
+ const answer = JSON.parse(String(out).trim().split('\n').pop());
396
+ check('tier 3: the background process was started', answer.ok === true, answer.error || '');
397
+ check('tier 3: it really ran later', await waitForFile(marker, 20000));
398
+ } catch (error) {
399
+ check('tier 3: the background process was started', false, String(error && error.message ? error.message : error));
400
+ }
401
+ try {
402
+ fs.rmSync(marker, { force: true });
403
+ } catch {
404
+ // It is gone either way.
405
+ }
406
+
407
+ // Tier 2: the systemd user timer.
408
+ try {
409
+ const out = execFileSync('wsl.exe', ['-e', 'node', wslPath(helper), wslDir, wslPath(marker), 'systemd'], { encoding: 'utf8', timeout: 60000 });
410
+ const answer = JSON.parse(String(out).trim().split('\n').pop());
411
+ check('tier 2: the systemd unit was registered', answer.ok === true, answer.error || '');
412
+ check('tier 2: it really ran later', await waitForFile(marker, 30000));
413
+ } catch (error) {
414
+ check('tier 2: the systemd unit was registered', false, String(error && error.message ? error.message : error));
415
+ }
416
+ }
417
+
418
+ /**
419
+ * The WSL path of a Windows path.
420
+ *
421
+ * @param {string} target
422
+ * @returns {string}
423
+ */
424
+ function wslPath(target) {
425
+ const normalized = target.replace(/\\/g, '/');
426
+ const match = /^[A-Za-z]:\/(.*)$/.exec(normalized);
427
+ if (!match) return normalized;
428
+ return '/mnt/' + normalized[0].toLowerCase() + '/' + match[1];
429
+ }
430
+
431
+ /**
432
+ * Wait a moment (the drill is not in a hurry).
433
+ *
434
+ * @param {number} ms
435
+ */
436
+ function wait(ms) {
437
+ return new Promise((resolve) => setTimeout(resolve, ms));
438
+ }
439
+
440
+ console.log('dsh-autorestart watchdog drill');
441
+ console.log(' workspace: ' + drillRoot);
442
+ console.log('');
443
+
444
+ console.log('--- the four scenarios ---');
445
+ await scenario({ name: 'scenario-1-normal', behaviour: 'up', fixIt: false, expect: 'not-needed' });
446
+ await scenario({ name: 'scenario-2-recovered', behaviour: 'down', fixIt: true, expect: 'recovered' });
447
+ await scenario({ name: 'scenario-3-failed', behaviour: 'down', fixIt: false, expect: 'failed' });
448
+ // THE 0.8.2 INCIDENT: a healthy host that never wrote the ready marker. The
449
+ // watchdog has to ask the health route instead of trusting the marker.
450
+ await scenarioHostUpWithoutMarker();
451
+
452
+ console.log('');
453
+ console.log('--- the tiers, for real ---');
454
+ await windowsTier();
455
+ await linuxTiers();
456
+
457
+ console.log('');
458
+ console.log('--- cleanup ---');
459
+ try {
460
+ fs.rmSync(drillRoot, { recursive: true, force: true });
461
+ check('the drill directory is gone', !fs.existsSync(drillRoot));
462
+ } catch (error) {
463
+ check('the drill directory is gone', false, String(error && error.message ? error.message : error));
464
+ }
465
+ await wait(500);
466
+
467
+ console.log('');
468
+ if (failures.length === 0) {
469
+ console.log('the watchdog drill passed: every scenario behaved as it should, every tier really fires.');
470
+ process.exit(0);
471
+ }
472
+ console.log('the watchdog drill FAILED:');
473
+ for (const line of failures) console.log(' ' + line);
474
+ process.exit(1);