dsh-autorestart 0.6.2 → 0.8.0

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.
Files changed (47) hide show
  1. package/README.md +460 -12
  2. package/assets/screenshot-1.png +0 -0
  3. package/assets/screenshot-2.png +0 -0
  4. package/assets/screenshot-3.png +0 -0
  5. package/assets/screenshot-4.png +0 -0
  6. package/assets/screenshot-5.png +0 -0
  7. package/client.js +6329 -3863
  8. package/cordis.patch.yml +44 -1
  9. package/docs/README.zh.md +297 -9
  10. package/docs/platform-matrix.md +92 -0
  11. package/docs/rescue-drill.md +606 -0
  12. package/index.js +2867 -199
  13. package/lib/access.js +212 -0
  14. package/lib/autostart-advice.js +198 -0
  15. package/lib/backup.js +131 -4
  16. package/lib/blockers.js +228 -0
  17. package/lib/checkpoint.js +147 -0
  18. package/lib/diagnostics.js +188 -0
  19. package/lib/health.js +264 -0
  20. package/lib/host-kind.js +145 -0
  21. package/lib/node-runtime.js +144 -0
  22. package/lib/rescue-paths.js +253 -0
  23. package/lib/rescue.js +596 -0
  24. package/lib/restart-verdict.js +327 -0
  25. package/lib/restart.js +823 -56
  26. package/lib/scheduler-capability.js +300 -0
  27. package/lib/sleep.js +46 -49
  28. package/lib/startup-mode.js +491 -0
  29. package/lib/supervisor-config.js +735 -0
  30. package/lib/supervisor-install.js +557 -0
  31. package/lib/supervisor.js +1276 -0
  32. package/lib/watchdog-arbiter.js +117 -0
  33. package/lib/watchdog-schedule.js +310 -0
  34. package/lib/watchdog-tier.js +182 -0
  35. package/lib/watchdog.js +479 -0
  36. package/locale/en.json +274 -64
  37. package/locale/zh.json +274 -64
  38. package/package.json +7 -2
  39. package/screenshots.json +6 -4
  40. package/scripts/README.md +65 -0
  41. package/scripts/drill-rescue.ps1 +508 -0
  42. package/scripts/emergency-disable.cmd +27 -0
  43. package/scripts/emergency-toggle.ps1 +373 -0
  44. package/scripts/restore-profile.cmd +44 -0
  45. package/scripts/restore-profile.ps1 +608 -0
  46. package/scripts/run-tests.ps1 +46 -0
  47. package/supervisor/supervisor.js +884 -0
@@ -0,0 +1,479 @@
1
+ /**
2
+ * THE WATCHDOG — one flow, three ways to trigger it.
3
+ *
4
+ * The flow is the same everywhere; only the way the watchdog is made to run
5
+ * differs (see lib/watchdog-tier.js and lib/watchdog-schedule.js). This file
6
+ * is that flow:
7
+ *
8
+ * 1. wait for the READY marker (the new host wrote it after its health
9
+ * check passed);
10
+ * 2. no marker? Wait a bit and ASK AGAIN — a host that is slow to start is
11
+ * not a host that is dead (the misjudgement protection);
12
+ * 3. still nothing? Is an instance ALIVE? If one is listening, the rollback
13
+ * is not needed: stand down (the no-double-start protection);
14
+ * 4. take the shared lock (`rollback.lock`) — exactly ONE of us restores;
15
+ * 5. restore the snapshot, then START THE HOST AGAIN;
16
+ * 6. wait for the ready marker once more: it came → `recovered`, it did not
17
+ * → `failed` — and then we STOP (at most one restore and one start).
18
+ *
19
+ * Whatever happens, the scheduling item and the pending marker go; the report
20
+ * stays.
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+
26
+ /** The marker that says "a restart is coming, a watchdog is watching". */
27
+ export const PENDING_NAME = 'watchdog-pending.json';
28
+ /** The marker that says "the new host is up and healthy". */
29
+ export const READY_NAME = 'watchdog-ready.json';
30
+ /** The report of the last watchdog run. */
31
+ export const REPORT_NAME = 'last-watchdog-report.json';
32
+ /** The lock that both the plugin and the watchdog take before a restore. */
33
+ export const LOCK_NAME = 'rollback.lock';
34
+
35
+ /** How long the watchdog waits for the new host. */
36
+ export const DEFAULT_TIMEOUT_MS = 180000;
37
+ /** How long it waits before it asks again (the misjudgement protection). */
38
+ export const DEFAULT_RECHECK_MS = 20000;
39
+ /** How often it looks at the ready marker. */
40
+ export const DEFAULT_POLL_MS = 2000;
41
+ /** How long it waits for the host after the restore. */
42
+ export const DEFAULT_READY_TIMEOUT_MS = 90000;
43
+
44
+ /**
45
+ * Write a JSON file (no BOM, so every reader can parse it).
46
+ *
47
+ * @param {string} file
48
+ * @param {object} value
49
+ */
50
+ function writeJson(file, value) {
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ fs.writeFileSync(file, JSON.stringify(value, null, 2), 'utf8');
53
+ }
54
+
55
+ /**
56
+ * Read a JSON file (a file that cannot be read is a file that is not there).
57
+ *
58
+ * @param {string} file
59
+ * @returns {object|null}
60
+ */
61
+ function readJson(file) {
62
+ try {
63
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
64
+ return parsed && typeof parsed === 'object' ? parsed : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The pending marker: what the watchdog has to know.
72
+ *
73
+ * @param {{
74
+ * tier?: string,
75
+ * tierReason?: string,
76
+ * taskName?: string,
77
+ * unitId?: string,
78
+ * pidPath?: string,
79
+ * snapshotDir?: string,
80
+ * profileDir?: string,
81
+ * restoreCommand?: object|null,
82
+ * relaunchCommand?: object|null,
83
+ * healthUrl?: string,
84
+ * healthPath?: string,
85
+ * portFile?: string,
86
+ * expectedBootId?: string|null,
87
+ * timeoutMs?: number,
88
+ * recheckMs?: number,
89
+ * readyTimeoutMs?: number,
90
+ * hostKind?: string,
91
+ * attempts?: number,
92
+ * now?: number,
93
+ * }} options
94
+ * @returns {object}
95
+ */
96
+ export function buildPending({
97
+ tier = 'none',
98
+ tierReason = '',
99
+ arbiter = 'v2',
100
+ taskName = '',
101
+ unitId = '',
102
+ pidPath = '',
103
+ snapshotDir = '',
104
+ profileDir = '',
105
+ restoreCommand = null,
106
+ relaunchCommand = null,
107
+ healthUrl = '',
108
+ healthPath = '/__dsh_autorestart/health',
109
+ portFile = '',
110
+ expectedBootId = null,
111
+ timeoutMs = DEFAULT_TIMEOUT_MS,
112
+ recheckMs = DEFAULT_RECHECK_MS,
113
+ readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
114
+ hostKind = '',
115
+ attempts = 1,
116
+ now = Date.now(),
117
+ } = {}) {
118
+ return {
119
+ version: 1,
120
+ at: new Date(now).toISOString(),
121
+ tier,
122
+ tierReason,
123
+ arbiter,
124
+ taskName,
125
+ unitId,
126
+ pidPath,
127
+ snapshotDir,
128
+ profileDir,
129
+ restoreCommand,
130
+ relaunchCommand,
131
+ healthUrl,
132
+ healthPath,
133
+ portFile,
134
+ expectedBootId,
135
+ timeoutMs,
136
+ recheckMs,
137
+ readyTimeoutMs,
138
+ hostKind,
139
+ attempts,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * @param {string} stateDir
145
+ * @returns {string}
146
+ */
147
+ export function pendingPath(stateDir) {
148
+ return path.join(stateDir, PENDING_NAME);
149
+ }
150
+
151
+ /**
152
+ * @param {string} stateDir
153
+ * @returns {string}
154
+ */
155
+ export function readyPath(stateDir) {
156
+ return path.join(stateDir, READY_NAME);
157
+ }
158
+
159
+ /**
160
+ * @param {string} stateDir
161
+ * @returns {string}
162
+ */
163
+ export function reportPath(stateDir) {
164
+ return path.join(stateDir, REPORT_NAME);
165
+ }
166
+
167
+ /**
168
+ * Write the pending marker.
169
+ *
170
+ * @param {string} stateDir
171
+ * @param {object} pending
172
+ */
173
+ export function writePending(stateDir, pending) {
174
+ writeJson(pendingPath(stateDir), pending);
175
+ }
176
+
177
+ /**
178
+ * @param {string} stateDir
179
+ * @returns {object|null}
180
+ */
181
+ export function readPending(stateDir) {
182
+ return readJson(pendingPath(stateDir));
183
+ }
184
+
185
+ /**
186
+ * Remove the pending marker (the watchdog is over).
187
+ *
188
+ * @param {string} stateDir
189
+ */
190
+ export function clearPending(stateDir) {
191
+ try {
192
+ fs.rmSync(pendingPath(stateDir), { force: true });
193
+ } catch {
194
+ // It is gone either way.
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Write the ready marker: "the new host is up".
200
+ *
201
+ * @param {string} stateDir
202
+ * @param {{ bootId?: string, port?: number, at?: number }} [info]
203
+ */
204
+ export function writeReady(stateDir, info = {}) {
205
+ writeJson(readyPath(stateDir), {
206
+ at: new Date(info.at ?? Date.now()).toISOString(),
207
+ bootId: info.bootId ?? null,
208
+ port: info.port ?? null,
209
+ });
210
+ }
211
+
212
+ /**
213
+ * @param {string} stateDir
214
+ * @returns {object|null}
215
+ */
216
+ export function readReady(stateDir) {
217
+ return readJson(readyPath(stateDir));
218
+ }
219
+
220
+ /**
221
+ * Remove the ready marker (it belongs to one restart).
222
+ *
223
+ * @param {string} stateDir
224
+ */
225
+ export function clearReady(stateDir) {
226
+ try {
227
+ fs.rmSync(readyPath(stateDir), { force: true });
228
+ } catch {
229
+ // It is gone either way.
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Write the report of the last watchdog run.
235
+ *
236
+ * @param {string} stateDir
237
+ * @param {object} report
238
+ */
239
+ export function writeReport(stateDir, report) {
240
+ writeJson(reportPath(stateDir), report);
241
+ }
242
+
243
+ /**
244
+ * @param {string} stateDir
245
+ * @returns {object|null}
246
+ */
247
+ export function readReport(stateDir) {
248
+ return readJson(reportPath(stateDir));
249
+ }
250
+
251
+ /**
252
+ * Take the lock (exactly one restore at a time).
253
+ *
254
+ * @param {string} lockFile
255
+ * @param {{ now?: number, exists?: Function, mkdir?: Function, open?: Function }} [deps]
256
+ * @returns {boolean}
257
+ */
258
+ export function acquireLock(lockFile, { now = Date.now(), exists = fs.existsSync, mkdir = (dir) => fs.mkdirSync(dir, { recursive: true }), open = null } = {}) {
259
+ try {
260
+ mkdir(path.dirname(lockFile));
261
+ if (exists(lockFile)) {
262
+ // A lock that is older than 10 minutes belongs to a process that died
263
+ // in the middle of its restore: it must not block the rollback for ever.
264
+ let age = 0;
265
+ try {
266
+ age = now - fs.statSync(lockFile).mtimeMs;
267
+ } catch {
268
+ age = 0;
269
+ }
270
+ if (age > 10 * 60 * 1000) fs.rmSync(lockFile, { force: true });
271
+ else return false;
272
+ }
273
+ const handle = open === null
274
+ ? fs.openSync(lockFile, 'wx')
275
+ : open(lockFile);
276
+ if (handle !== null && handle !== undefined && typeof handle.close === 'function') handle.close();
277
+ return true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Give the lock back.
285
+ *
286
+ * @param {string} lockFile
287
+ */
288
+ export function releaseLock(lockFile) {
289
+ try {
290
+ fs.rmSync(lockFile, { force: true });
291
+ } catch {
292
+ // It is gone either way.
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Put the snapshot back.
298
+ *
299
+ * The same for every platform: the configuration files and the plugin tree
300
+ * are copied out of the snapshot into the profile. (The profile is NOT
301
+ * deleted first — the broken files are overwritten, so a half-restored
302
+ * profile is never worse than the broken one.)
303
+ *
304
+ * @param {{ snapshotDir?: string, profileDir?: string, copy?: Function }} options
305
+ * @returns {{ ok: boolean, error: string|null, copied: string[] }}
306
+ */
307
+ export function restoreFromSnapshot({ snapshotDir = '', profileDir = '', copy = null } = {}) {
308
+ const from = String(snapshotDir ?? '');
309
+ const into = String(profileDir ?? '');
310
+ if (from === '' || into === '') {
311
+ return { ok: false, error: 'the snapshot or the profile directory is unknown', copied: [] };
312
+ }
313
+ const copied = [];
314
+ const copyInto = copy === null
315
+ ? (source, target) => { fs.cpSync(source, target, { recursive: true, force: true }); }
316
+ : copy;
317
+ try {
318
+ for (const [part, target] of [['config', into], ['node_modules', path.join(into, 'node_modules')]]) {
319
+ const source = path.join(from, part);
320
+ if (!fs.existsSync(source)) continue;
321
+ copyInto(source, target);
322
+ copied.push(part);
323
+ }
324
+ } catch (error) {
325
+ return {
326
+ ok: false,
327
+ error: error && error.message ? error.message : String(error),
328
+ copied,
329
+ };
330
+ }
331
+ return { ok: copied.length > 0, error: copied.length > 0 ? null : 'the snapshot carries nothing to restore', copied };
332
+ }
333
+
334
+ /**
335
+ * Wait for the ready marker.
336
+ *
337
+ * @param {object} deps
338
+ * @param {number} timeoutMs
339
+ * @returns {Promise<boolean>}
340
+ */
341
+ async function waitForReady(deps, timeoutMs) {
342
+ const deadline = deps.now() + Math.max(0, timeoutMs);
343
+ while (deps.now() < deadline) {
344
+ if (deps.readReady()) return true;
345
+ // The plugin may have decided on its own (the restart failed): stand down.
346
+ if (deps.isWithdrawn && deps.isWithdrawn()) return false;
347
+ await deps.sleep(Math.min(deps.pollMs, Math.max(0, deadline - deps.now())));
348
+ }
349
+ return deps.readReady() === true;
350
+ }
351
+
352
+ /**
353
+ * THE FLOW.
354
+ *
355
+ * @param {{
356
+ * pending: object,
357
+ * stateDir?: string,
358
+ * deps?: object,
359
+ * }} options
360
+ * @returns {Promise<object>} the report
361
+ */
362
+ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
363
+ const d = {
364
+ sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
365
+ now: () => Date.now(),
366
+ pollMs: DEFAULT_POLL_MS,
367
+ readReady: () => readReady(stateDir),
368
+ isWithdrawn: () => readPending(stateDir) === null,
369
+ probeAlive: () => false,
370
+ runRestore: () => ({ ok: false, error: 'no restore command' }),
371
+ runRelaunch: () => ({ ok: false, error: 'no relaunch command' }),
372
+ acquireLock: () => true,
373
+ releaseLock: () => {},
374
+ removeSchedule: () => {},
375
+ writeReport: (report) => writeReport(stateDir, report),
376
+ ...deps,
377
+ };
378
+ const startedAt = d.now();
379
+ const report = {
380
+ at: new Date(startedAt).toISOString(),
381
+ result: 'unknown',
382
+ tier: pending && pending.tier ? pending.tier : 'none',
383
+ tierReason: pending && pending.tierReason ? pending.tierReason : '',
384
+ // WHO watched this restart. There is only ever one watchdog at a time
385
+ // (see lib/watchdog-arbiter.js), and the report says which one it was.
386
+ arbiter: pending && pending.arbiter ? pending.arbiter : 'v2',
387
+ waitedMs: 0,
388
+ rechecked: false,
389
+ restored: false,
390
+ relaunched: false,
391
+ ready: false,
392
+ error: null,
393
+ };
394
+ try {
395
+ // (1) The new host writes the ready marker after its health check.
396
+ const ready = await waitForReady(d, pending.timeoutMs);
397
+ if (ready) {
398
+ report.result = 'not-needed';
399
+ report.ready = true;
400
+ return report;
401
+ }
402
+ if (d.isWithdrawn()) {
403
+ report.result = 'decided';
404
+ report.error = 'the plugin decided on its own: the watchdog stood down';
405
+ return report;
406
+ }
407
+
408
+ // (2) ASK AGAIN. A host that is slow to start is not a host that is dead.
409
+ report.rechecked = true;
410
+ await d.sleep(Math.max(0, pending.recheckMs));
411
+ if (d.readReady()) {
412
+ report.result = 'not-needed';
413
+ report.ready = true;
414
+ return report;
415
+ }
416
+ if (d.isWithdrawn()) {
417
+ report.result = 'decided';
418
+ report.error = 'the plugin decided on its own: the watchdog stood down';
419
+ return report;
420
+ }
421
+
422
+ // (3) Is an instance ALIVE? Then there is nothing to restore.
423
+ if (await d.probeAlive()) {
424
+ report.result = 'abandoned';
425
+ report.error = 'an instance is alive: the snapshot was not restored';
426
+ return report;
427
+ }
428
+
429
+ // (4) The lock: exactly ONE of us restores.
430
+ if (!d.acquireLock()) {
431
+ report.result = 'abandoned';
432
+ report.error = 'another rollback is already running';
433
+ return report;
434
+ }
435
+ try {
436
+ // (5) Restore the snapshot.
437
+ const restored = d.runRestore();
438
+ report.restored = restored !== null && restored !== undefined && restored.ok === true;
439
+ if (!report.restored) {
440
+ report.result = 'failed';
441
+ report.error = restored && restored.error ? restored.error : 'the restore failed';
442
+ return report;
443
+ }
444
+ // (6) Start the host again.
445
+ const relaunched = d.runRelaunch();
446
+ report.relaunched = relaunched !== null && relaunched !== undefined && relaunched.ok === true;
447
+ if (!report.relaunched) {
448
+ report.result = 'failed';
449
+ report.error = relaunched && relaunched.error ? relaunched.error : 'the host could not be started again';
450
+ return report;
451
+ }
452
+ // (7) Did it come up this time?
453
+ const readyAgain = await waitForReady(d, pending.readyTimeoutMs);
454
+ report.ready = readyAgain;
455
+ report.result = readyAgain ? 'recovered' : 'failed';
456
+ if (!readyAgain) report.error = 'the host did not come up after the restore';
457
+ } finally {
458
+ d.releaseLock();
459
+ }
460
+ } catch (error) {
461
+ report.result = 'failed';
462
+ report.error = error && error.message ? error.message : String(error);
463
+ } finally {
464
+ report.waitedMs = d.now() - startedAt;
465
+ // The scheduling item and the pending marker go; the report stays.
466
+ try {
467
+ d.removeSchedule();
468
+ } catch {
469
+ // The scheduling item is cleaned up by the next run if this fails.
470
+ }
471
+ try {
472
+ if (stateDir !== '') clearPending(stateDir);
473
+ } catch {
474
+ // It is gone either way.
475
+ }
476
+ d.writeReport(report);
477
+ }
478
+ return report;
479
+ }