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.
- package/CHANGELOG.md +97 -0
- package/README.md +55 -6
- package/client.js +102 -9
- package/docs/README.zh.md +4 -2
- package/index.js +514 -39
- package/lib/backup.js +279 -9
- package/lib/checkpoint.js +134 -0
- package/lib/diagnostics.js +18 -0
- package/lib/restart-phase.js +263 -0
- package/lib/watchdog-disarm.js +506 -0
- package/lib/watchdog.js +86 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +6 -1
- package/scripts/drill-rescue.sh +427 -0
- package/scripts/drill-watchdog.mjs +474 -0
- package/scripts/emergency-disable.sh +314 -0
- package/scripts/rescue-strings.zh.json +168 -0
- package/scripts/restore-profile.sh +593 -0
- package/scripts/watchdog.mjs +310 -0
|
@@ -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);
|