dsh-autorestart 0.8.4 → 0.8.6
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 +313 -0
- package/README.md +465 -40
- package/assets/icon-9c245e5b.ico +0 -0
- package/assets/icon-rescue-1902bb15.ico +0 -0
- package/assets/icon-rescue-1902bb15.png +0 -0
- package/client.js +3002 -170
- package/cordis.patch.yml +12 -0
- package/docs/README.zh.md +231 -23
- package/docs/platform-matrix.md +171 -55
- package/docs/real-machine-verification-0.8.6.md +202 -0
- package/index.js +13272 -8513
- package/lib/autostart-advice.js +31 -2
- package/lib/backup.js +2768 -21
- package/lib/checkpoint.js +214 -7
- package/lib/cookie-clear.js +260 -0
- package/lib/lifecycle.js +23 -40
- package/lib/plugin-compare.js +499 -0
- package/lib/plugin-failure.js +382 -0
- package/lib/plugin-recover.js +1557 -0
- package/lib/plugin-watch.js +213 -0
- package/lib/rescue-paths.js +982 -56
- package/lib/restart-phase.js +5 -0
- package/lib/restart-verdict.js +379 -0
- package/lib/restart.js +244 -7
- package/lib/scheduler-capability.js +19 -3
- package/lib/sleep.js +190 -29
- package/lib/watchdog-disarm.js +46 -2
- package/lib/watchdog-schedule.js +834 -17
- package/lib/watchdog-tier.js +8 -1
- package/lib/watchdog.js +1222 -47
- package/locale/en.json +175 -68
- package/locale/zh.json +175 -68
- package/package.json +3 -2
- package/screenshots.json +6 -6
- package/scripts/README.md +6 -3
- package/scripts/build-icon.mjs +998 -0
- package/scripts/clear-dsh-auth-cookies.mjs +257 -0
- package/scripts/emergency-disable.sh +69 -2
- package/scripts/emergency-toggle.ps1 +85 -69
- package/scripts/rescue-strings.zh.json +36 -18
- package/scripts/restore-profile.ps1 +182 -1
- package/scripts/watchdog.mjs +432 -79
package/lib/watchdog.js
CHANGED
|
@@ -18,17 +18,47 @@
|
|
|
18
18
|
* When there is no port to ask at all (no instance record, no health
|
|
19
19
|
* URL), one more round is waited and the leftovers are looked at again;
|
|
20
20
|
* 4. take the shared lock (`rollback.lock`) — exactly ONE of us restores;
|
|
21
|
-
* 5.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* 5. THE PASS — up to `maxAttempts` times (3 by default, and NEVER endless):
|
|
22
|
+
* a. ask again whether the host came up or is alive (a host that came
|
|
23
|
+
* up between two attempts must not be started a second time);
|
|
24
|
+
* b. restore the snapshot on the first pass, and again on a later pass
|
|
25
|
+
* only when the restore itself failed (the snapshot is the same
|
|
26
|
+
* file tree: copying it twice would not change anything);
|
|
27
|
+
* c. CLEAR THE STALE HANDSHAKE COOKIES after the restore and before
|
|
28
|
+
* the start (see lib/cookie-clear.js: without it the relaunched
|
|
29
|
+
* host dies with 431 on its very first request);
|
|
30
|
+
* d. START THE HOST AGAIN and wait for the ready marker;
|
|
31
|
+
* e. not ready and attempts left? wait the backoff (15s, then 45s) and
|
|
32
|
+
* go back to (a);
|
|
33
|
+
* 6. the marker came → `recovered`; the attempts ran out → `failed` with
|
|
34
|
+
* "tried N times, no further rollback will be attempted"; a start that
|
|
35
|
+
* could not be issued at all → `failed` with `stage:'relaunch'`.
|
|
24
36
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
37
|
+
* A cleanup that fails never blocks a rescue (the cookie script exits 0 for
|
|
38
|
+
* the same reason), but it is ALWAYS written into the report (`cookieClear`):
|
|
39
|
+
* a cleanup that could not run must never look like one that did.
|
|
40
|
+
*
|
|
41
|
+
* Whatever happens, the report is written FIRST, then the pending marker goes,
|
|
42
|
+
* and only then the scheduling item — because `removeSchedule` can stop the very
|
|
43
|
+
* service this process runs in (2026-11-01), and anything written after that
|
|
44
|
+
* never reaches the disk. The report is written from the `finally`, so an
|
|
45
|
+
* exception inside the flow still leaves a report behind.
|
|
27
46
|
*/
|
|
28
47
|
|
|
29
48
|
import fs from 'node:fs';
|
|
30
49
|
import path from 'node:path';
|
|
31
50
|
|
|
51
|
+
import {
|
|
52
|
+
CERTIFICATION_EVIDENCE,
|
|
53
|
+
collectLinkedPlugins,
|
|
54
|
+
decideLinkRestore,
|
|
55
|
+
isUsableRestorePoint,
|
|
56
|
+
LINK_ACTIONS,
|
|
57
|
+
LINKS_DIR,
|
|
58
|
+
MANIFEST_NAME,
|
|
59
|
+
restorePointRefusal,
|
|
60
|
+
} from './backup.js';
|
|
61
|
+
|
|
32
62
|
/** The marker that says "a restart is coming, a watchdog is watching". */
|
|
33
63
|
export const PENDING_NAME = 'watchdog-pending.json';
|
|
34
64
|
/** The marker that says "the new host is up and healthy". */
|
|
@@ -47,6 +77,78 @@ export const DEFAULT_POLL_MS = 2000;
|
|
|
47
77
|
/** How long it waits for the host after the restore. */
|
|
48
78
|
export const DEFAULT_READY_TIMEOUT_MS = 90000;
|
|
49
79
|
|
|
80
|
+
/**
|
|
81
|
+
* How many pass-and-start attempts one watchdog run may make.
|
|
82
|
+
*
|
|
83
|
+
* The flow used to be "one restore, one start, then stop". One start is not
|
|
84
|
+
* enough: the desktop app can die while it boots (a 431 on its first request
|
|
85
|
+
* killed it after 14 seconds on 2026-10-30), and a host that died is a host
|
|
86
|
+
* that has to be started again. It is a NUMBER, not "as often as you like":
|
|
87
|
+
* a run that never ends is a run that never reports.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_MAX_ATTEMPTS = 3;
|
|
90
|
+
/**
|
|
91
|
+
* The hard ceiling of `maxAttempts`.
|
|
92
|
+
*
|
|
93
|
+
* A pending marker is a file on disk, so a bad or hand-edited value must not
|
|
94
|
+
* be able to turn the watchdog into an endless loop.
|
|
95
|
+
*/
|
|
96
|
+
export const MAX_ATTEMPTS_CEILING = 10;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* HOW OFTEN THE WATCHDOG MAY PUT A COPY BACK = ONCE (user decision, 2026-11-01).
|
|
100
|
+
*
|
|
101
|
+
* Restoring the same directory again cannot change anything: the bytes are
|
|
102
|
+
* identical, so a second and third restore of the same copy is the same attempt
|
|
103
|
+
* repeated — measured on the real machine, three attempts restored the same copy
|
|
104
|
+
* three times and failed the same way three times, which reads like "we tried
|
|
105
|
+
* hard" while nothing new was tried at all.
|
|
106
|
+
*
|
|
107
|
+
* So: the restore happens at most once per run. The START may still be repeated
|
|
108
|
+
* (`maxAttempts`), because a host that died for an unrelated reason can come up
|
|
109
|
+
* on the next try.
|
|
110
|
+
*/
|
|
111
|
+
export const MAX_RESTORES = 1;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The waits BETWEEN two attempts, in milliseconds (15s, then 45s).
|
|
115
|
+
*
|
|
116
|
+
* The first wait is short (a host that died on its first request is worth
|
|
117
|
+
* starting again at once); the second is longer (a host that dies twice is
|
|
118
|
+
* dying for a reason, and hammering the disk with relaunches helps nobody).
|
|
119
|
+
* The last entry repeats when there are more attempts than entries.
|
|
120
|
+
*/
|
|
121
|
+
export const DEFAULT_BACKOFF_MS = Object.freeze([15000, 45000]);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* How many attempts this run may make (the marker, clamped and defaulted).
|
|
125
|
+
*
|
|
126
|
+
* An OLD marker carries no `maxAttempts`: it is read as `DEFAULT_MAX_ATTEMPTS`,
|
|
127
|
+
* never as "one" — a marker written before this field existed must not turn
|
|
128
|
+
* the watchdog back into the one-shot process that failed on 2026-10-30.
|
|
129
|
+
*
|
|
130
|
+
* @param {object|null} pending
|
|
131
|
+
* @returns {number}
|
|
132
|
+
*/
|
|
133
|
+
export function maxAttemptsOf(pending) {
|
|
134
|
+
const raw = pending && typeof pending === 'object' ? pending.maxAttempts : undefined;
|
|
135
|
+
const wanted = Number(raw);
|
|
136
|
+
if (!Number.isFinite(wanted) || wanted < 1) return DEFAULT_MAX_ATTEMPTS;
|
|
137
|
+
return Math.min(MAX_ATTEMPTS_CEILING, Math.trunc(wanted));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The wait AFTER a given attempt (1-based), in milliseconds.
|
|
142
|
+
*
|
|
143
|
+
* @param {number} attempt
|
|
144
|
+
* @returns {number}
|
|
145
|
+
*/
|
|
146
|
+
export function backoffMsFor(attempt) {
|
|
147
|
+
const index = Math.max(0, Math.trunc(Number(attempt) || 0) - 1);
|
|
148
|
+
const table = DEFAULT_BACKOFF_MS;
|
|
149
|
+
return table[Math.min(index, table.length - 1)];
|
|
150
|
+
}
|
|
151
|
+
|
|
50
152
|
/**
|
|
51
153
|
* Write a JSON file (no BOM, so every reader can parse it).
|
|
52
154
|
*
|
|
@@ -59,22 +161,104 @@ function writeJson(file, value) {
|
|
|
59
161
|
}
|
|
60
162
|
|
|
61
163
|
/**
|
|
62
|
-
* Read a JSON file (a file that cannot be read is
|
|
164
|
+
* Read a JSON file and say WHAT happened (a file that cannot be read is not
|
|
165
|
+
* the same thing as a file that is not there).
|
|
166
|
+
*
|
|
167
|
+
* WHY THE THREE STATES (2026-10-30, review finding F4): the old reader returned
|
|
168
|
+
* `null` for BOTH "there is no such file" and "the file cannot be parsed". The
|
|
169
|
+
* watchdog therefore read a `watchdog-pending.json` that had been written BY
|
|
170
|
+
* POWERSHELL (UTF-8 **with** a byte order mark) as "there is no pending marker",
|
|
171
|
+
* cleaned up its scheduling item and exited 0 - looking like a run, doing
|
|
172
|
+
* nothing at all. That is exactly the failure this whole file exists to
|
|
173
|
+
* prevent, so "missing" and "corrupt" are separate answers now, and the byte
|
|
174
|
+
* order mark is stripped before parsing.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} file
|
|
177
|
+
* @returns {{
|
|
178
|
+
* state: 'ok'|'missing'|'corrupt',
|
|
179
|
+
* value: object|null,
|
|
180
|
+
* error: string|null,
|
|
181
|
+
* }}
|
|
182
|
+
*/
|
|
183
|
+
export function readJsonState(file) {
|
|
184
|
+
let text = ''
|
|
185
|
+
try {
|
|
186
|
+
text = fs.readFileSync(file, 'utf8');
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const code = error && typeof error.code === 'string' ? error.code : '';
|
|
189
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
190
|
+
return { state: 'missing', value: null, error: null };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
state: 'corrupt',
|
|
194
|
+
value: null,
|
|
195
|
+
error: 'the file exists but cannot be read: ' + (error && error.message ? error.message : String(error)),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
// Windows PowerShell 5.1 writes UTF-8 WITH a byte order mark, and
|
|
199
|
+
// `JSON.parse` refuses a leading U+FEFF. Stripping it is not cosmetic: a
|
|
200
|
+
// marker written by a rescue script has to be readable by this module.
|
|
201
|
+
const cleaned = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
202
|
+
let parsed = null;
|
|
203
|
+
try {
|
|
204
|
+
parsed = JSON.parse(cleaned);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
state: 'corrupt',
|
|
208
|
+
value: null,
|
|
209
|
+
error: 'the file cannot be parsed: ' + (error && error.message ? error.message : String(error)),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
213
|
+
return { state: 'corrupt', value: null, error: 'the file does not hold a JSON object' };
|
|
214
|
+
}
|
|
215
|
+
return { state: 'ok', value: parsed, error: null };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read a JSON file (the value, or null when there is nothing usable).
|
|
220
|
+
*
|
|
221
|
+
* The PLUGIN side keeps this shape: `readPending` / `readReady` / `readReport`
|
|
222
|
+
* are imported by index.js, and a caller that only wants "the object or
|
|
223
|
+
* nothing" must not have to unwrap a state machine. The watchdog CLI uses
|
|
224
|
+
* {@link readJsonState} (and {@link readPendingState}) because it has to tell
|
|
225
|
+
* "no marker" from "a marker it cannot read".
|
|
63
226
|
*
|
|
64
227
|
* @param {string} file
|
|
65
228
|
* @returns {object|null}
|
|
66
229
|
*/
|
|
67
230
|
function readJson(file) {
|
|
68
|
-
|
|
69
|
-
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
70
|
-
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
71
|
-
} catch {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
231
|
+
return readJsonState(file).value;
|
|
74
232
|
}
|
|
75
233
|
|
|
76
234
|
/**
|
|
77
|
-
* The pending marker
|
|
235
|
+
* The pending marker WITH the reason it could not be used.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} stateDir
|
|
238
|
+
* @returns {{ state: 'ok'|'missing'|'corrupt', pending: object|null, error: string|null }}
|
|
239
|
+
*/
|
|
240
|
+
export function readPendingState(stateDir) {
|
|
241
|
+
const read = readJsonState(pendingPath(stateDir));
|
|
242
|
+
return { state: read.state, pending: read.value, error: read.error };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* THE PENDING MARKER: what the watchdog has to know.
|
|
247
|
+
*
|
|
248
|
+
* `restoreTargetDir` USED TO BE CALLED `snapshotDir` AND THAT NAME LIED
|
|
249
|
+
* (2026-11-01): the directory stored here is never the snapshot of this
|
|
250
|
+
* restart — it is the copy the watchdog may restore, which since the strict mode
|
|
251
|
+
* is "the newest copy that was CERTIFIED and that no system intervention is on
|
|
252
|
+
* record for". The old name is still accepted as an input alias so a caller
|
|
253
|
+
* that was not updated keeps working, but the marker itself only carries the
|
|
254
|
+
* new name.
|
|
255
|
+
*
|
|
256
|
+
* `restoreBlocked` / `restoreBlockedReason` / `restoreCandidates` are written by
|
|
257
|
+
* the ARMING side when it found no copy it is allowed to restore. The watchdog
|
|
258
|
+
* must not restore in that case (strict mode: no trusted restore point), and the
|
|
259
|
+
* report has to be able to say WHICH copies were looked at and why each was
|
|
260
|
+
* refused — a rescue that quietly does nothing is the incident this whole change
|
|
261
|
+
* is about.
|
|
78
262
|
*
|
|
79
263
|
* @param {{
|
|
80
264
|
* tier?: string,
|
|
@@ -82,7 +266,11 @@ function readJson(file) {
|
|
|
82
266
|
* taskName?: string,
|
|
83
267
|
* unitId?: string,
|
|
84
268
|
* pidPath?: string,
|
|
269
|
+
* restoreTargetDir?: string,
|
|
85
270
|
* snapshotDir?: string,
|
|
271
|
+
* restoreBlocked?: boolean,
|
|
272
|
+
* restoreBlockedReason?: string,
|
|
273
|
+
* restoreCandidates?: Array<object>|null,
|
|
86
274
|
* profileDir?: string,
|
|
87
275
|
* restoreCommand?: object|null,
|
|
88
276
|
* relaunchCommand?: object|null,
|
|
@@ -95,6 +283,7 @@ function readJson(file) {
|
|
|
95
283
|
* readyTimeoutMs?: number,
|
|
96
284
|
* hostKind?: string,
|
|
97
285
|
* attempts?: number,
|
|
286
|
+
* maxAttempts?: number,
|
|
98
287
|
* now?: number,
|
|
99
288
|
* }} options
|
|
100
289
|
* @returns {object}
|
|
@@ -106,7 +295,11 @@ export function buildPending({
|
|
|
106
295
|
taskName = '',
|
|
107
296
|
unitId = '',
|
|
108
297
|
pidPath = '',
|
|
298
|
+
restoreTargetDir = '',
|
|
109
299
|
snapshotDir = '',
|
|
300
|
+
restoreBlocked = false,
|
|
301
|
+
restoreBlockedReason = '',
|
|
302
|
+
restoreCandidates = null,
|
|
110
303
|
profileDir = '',
|
|
111
304
|
restoreCommand = null,
|
|
112
305
|
relaunchCommand = null,
|
|
@@ -119,8 +312,12 @@ export function buildPending({
|
|
|
119
312
|
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS,
|
|
120
313
|
hostKind = '',
|
|
121
314
|
attempts = 1,
|
|
315
|
+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
122
316
|
now = Date.now(),
|
|
123
317
|
} = {}) {
|
|
318
|
+
const target = typeof restoreTargetDir === 'string' && restoreTargetDir !== ''
|
|
319
|
+
? restoreTargetDir
|
|
320
|
+
: (typeof snapshotDir === 'string' ? snapshotDir : '')
|
|
124
321
|
return {
|
|
125
322
|
version: 1,
|
|
126
323
|
at: new Date(now).toISOString(),
|
|
@@ -130,7 +327,10 @@ export function buildPending({
|
|
|
130
327
|
taskName,
|
|
131
328
|
unitId,
|
|
132
329
|
pidPath,
|
|
133
|
-
|
|
330
|
+
restoreTargetDir: target,
|
|
331
|
+
restoreBlocked: restoreBlocked === true || target === '',
|
|
332
|
+
restoreBlockedReason: typeof restoreBlockedReason === 'string' ? restoreBlockedReason : '',
|
|
333
|
+
restoreCandidates: Array.isArray(restoreCandidates) ? restoreCandidates : [],
|
|
134
334
|
profileDir,
|
|
135
335
|
restoreCommand,
|
|
136
336
|
relaunchCommand,
|
|
@@ -143,9 +343,271 @@ export function buildPending({
|
|
|
143
343
|
readyTimeoutMs,
|
|
144
344
|
hostKind,
|
|
145
345
|
attempts,
|
|
346
|
+
maxAttempts,
|
|
146
347
|
};
|
|
147
348
|
}
|
|
148
349
|
|
|
350
|
+
/**
|
|
351
|
+
* WHY A COPY MAY NOT BE RESTORED — one word per reason, so a report can group
|
|
352
|
+
* them instead of repeating a sentence per copy.
|
|
353
|
+
*/
|
|
354
|
+
export const RESTORE_EXCLUSIONS = Object.freeze({
|
|
355
|
+
/**
|
|
356
|
+
* No certification evidence at all: an old copy, a snapshot nobody checked, OR
|
|
357
|
+
* — since 2026-11-01 — a copy that carries a stamp (`certifiedBy=full-checks`)
|
|
358
|
+
* but no criterion that can be re-checked. The last one is the historical
|
|
359
|
+
* pollution: its stamp says who signed, never what was checked, so it is
|
|
360
|
+
* refused exactly like a copy with no stamp at all. See
|
|
361
|
+
* `CERTIFICATION_EVIDENCE` in lib/backup.js.
|
|
362
|
+
*/
|
|
363
|
+
NOT_CERTIFIED: 'not-certified',
|
|
364
|
+
/**
|
|
365
|
+
* The copy has no restart record saying the instance came up with matching
|
|
366
|
+
* content (2026-11-02: the selector reads the record, not a verdict). The old
|
|
367
|
+
* value above is kept for anything that still compares against it; nothing new
|
|
368
|
+
* is emitted in that vocabulary.
|
|
369
|
+
*/
|
|
370
|
+
NOT_CONTENT_VERIFIED: 'not-content-verified',
|
|
371
|
+
/** The boot that certified it had to be rescued by the system. */
|
|
372
|
+
INTERVENED: 'intervened',
|
|
373
|
+
/** Certified, but nobody recorded whether that boot needed help. */
|
|
374
|
+
INTERVENTION_UNKNOWN: 'intervention-unknown',
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* CHOOSE THE ONE COPY THE WATCHDOG MAY RESTORE (2026-11-01, strict mode).
|
|
379
|
+
*
|
|
380
|
+
* The rule the user decided on: a copy may only be restored when it is CERTIFIED
|
|
381
|
+
* — and "certified" now means BOTH halves: a real source AND a criterion that can
|
|
382
|
+
* be re-checked (`certificationEvidence === 'fingerprint-match'`), as computed by
|
|
383
|
+
* `isCertifiedBackup` in lib/backup.js. A copy that merely names a source is NOT
|
|
384
|
+
* certified: the two broken configurations on the hosted instance (71B / 70B of
|
|
385
|
+
* `cordis.patch.yml`, both stamped `certifiedBy=full-checks` by the old code) are
|
|
386
|
+
* exactly that shape, and they must never be picked again.
|
|
387
|
+
*
|
|
388
|
+
* AND nothing on record may say the boot that certified it had to be rescued
|
|
389
|
+
* (`intervention` is not true). Two consequences, both deliberate:
|
|
390
|
+
*
|
|
391
|
+
* - `intervention === null` means "nobody recorded it". It is NOT treated as
|
|
392
|
+
* "no intervention": a certified copy whose provenance is unknown is not
|
|
393
|
+
* trusted, and the report says so in those words. That is the difference
|
|
394
|
+
* between "we checked" and "we did not hear otherwise".
|
|
395
|
+
* - `intervention === true` is refused even when `certifiedBy` is
|
|
396
|
+
* 'full-checks'. A copy whose boot needed rescuing describes a state that
|
|
397
|
+
* was already being fixed by somebody else; restoring it would put the
|
|
398
|
+
* repaired state back under a config that broke.
|
|
399
|
+
*
|
|
400
|
+
* NO FALLBACK: when nothing qualifies, the answer is `target: null` with
|
|
401
|
+
* `strict: true` and the full `excluded` list. It never degrades to "try the
|
|
402
|
+
* newest copy anyway" — the user chose strict mode, and a silent fallback is
|
|
403
|
+
* exactly how the bad copy got certified in the first place.
|
|
404
|
+
*
|
|
405
|
+
* @param {{ candidates?: Array<object> }} [options] the copies, newest first
|
|
406
|
+
* @returns {{
|
|
407
|
+
* target: string|null,
|
|
408
|
+
* targetName: string|null,
|
|
409
|
+
* strict: boolean,
|
|
410
|
+
* reason: string,
|
|
411
|
+
* excluded: Array<{ dir: string|null, name: string|null, certifiedBy: string, certificationEvidence: string|null, intervention: boolean|null, exclusion: string }>,
|
|
412
|
+
* notes: string[],
|
|
413
|
+
* }}
|
|
414
|
+
*/
|
|
415
|
+
export function selectRestoreTarget({ candidates = [] } = {}) {
|
|
416
|
+
const list = Array.isArray(candidates) ? candidates : []
|
|
417
|
+
const excluded = []
|
|
418
|
+
const notes = []
|
|
419
|
+
for (const candidate of list) {
|
|
420
|
+
if (candidate === null || typeof candidate !== 'object') continue
|
|
421
|
+
const dir = typeof candidate.dir === 'string' && candidate.dir !== '' ? candidate.dir : null
|
|
422
|
+
const name = typeof candidate.name === 'string' && candidate.name !== '' ? candidate.name : null
|
|
423
|
+
const certifiedBy = typeof candidate.certifiedBy === 'string' && candidate.certifiedBy !== ''
|
|
424
|
+
? candidate.certifiedBy
|
|
425
|
+
: 'none'
|
|
426
|
+
const intervention = typeof candidate.intervention === 'boolean' ? candidate.intervention : null
|
|
427
|
+
// THE QUALIFICATION IS A RECORDED FACT, NOT AN ADJECTIVE (2026-11-02).
|
|
428
|
+
//
|
|
429
|
+
// It used to be `certified === true`, i.e. "somebody stamped this copy AND the
|
|
430
|
+
// stamp says fingerprint-match". The user's objection is the stamp itself: a
|
|
431
|
+
// backup is either something that was observed to come up, or it is not, and
|
|
432
|
+
// the selector has to read that observation rather than a verdict written next
|
|
433
|
+
// to it. The fact lives in `boot`: `started` (the instance came up) and
|
|
434
|
+
// `contentMatched` (the content that boot ran is this copy's content).
|
|
435
|
+
//
|
|
436
|
+
// ONE PREDICATE, TWO CALLERS (2026-11-02): the decision is made by
|
|
437
|
+
// `isUsableRestorePoint` in lib/backup.js, which single-plugin recovery asks as
|
|
438
|
+
// well. Two rulers over the same directory was a bug: the same copy could be
|
|
439
|
+
// "a restore point" to one path and "not a restore point" to the other.
|
|
440
|
+
//
|
|
441
|
+
// AN OLD COPY IS TRANSLATED, NOT DROPPED (the same rule as `readBackupMeta`):
|
|
442
|
+
// a meta with no `boot` record but with the legacy stamp
|
|
443
|
+
// (`certifiedBy` + `certificationEvidence: 'fingerprint-match'`) is an
|
|
444
|
+
// observation from an older build, and refusing every one of them would make
|
|
445
|
+
// every existing install unable to restore anything. It is read here as the
|
|
446
|
+
// observation it recorded; nothing is *written* in that vocabulary any more.
|
|
447
|
+
const observed = candidate.boot !== null && typeof candidate.boot === 'object' ? candidate.boot : null
|
|
448
|
+
const boot = observed !== null
|
|
449
|
+
? observed
|
|
450
|
+
: (candidate.certifiedBy !== 'none' && candidate.certifiedBy !== undefined
|
|
451
|
+
&& candidate.certificationEvidence === CERTIFICATION_EVIDENCE.FINGERPRINT_MATCH)
|
|
452
|
+
? { started: true, contentMatched: true, observedBy: 'an older build' }
|
|
453
|
+
: null
|
|
454
|
+
const usable = isUsableRestorePoint({ boot, intervention })
|
|
455
|
+
if (usable !== true) {
|
|
456
|
+
const reason = restorePointRefusal({ boot, intervention })
|
|
457
|
+
const exclusion = boot === null || boot.started !== true || boot.contentMatched !== true
|
|
458
|
+
? RESTORE_EXCLUSIONS.NOT_CONTENT_VERIFIED
|
|
459
|
+
: (intervention === true ? RESTORE_EXCLUSIONS.INTERVENED : RESTORE_EXCLUSIONS.INTERVENTION_UNKNOWN)
|
|
460
|
+
excluded.push({
|
|
461
|
+
dir,
|
|
462
|
+
name,
|
|
463
|
+
certifiedBy: certifiedBy === 'none' ? 'none' : certifiedBy,
|
|
464
|
+
certificationEvidence: typeof candidate.certificationEvidence === 'string'
|
|
465
|
+
? candidate.certificationEvidence
|
|
466
|
+
: null,
|
|
467
|
+
intervention,
|
|
468
|
+
exclusion,
|
|
469
|
+
})
|
|
470
|
+
// SAID OUT LOUD: silence here would read like "there is nothing to restore",
|
|
471
|
+
// which is a different fact. The sentence names what is missing, never who
|
|
472
|
+
// signed off on it.
|
|
473
|
+
if (exclusion !== RESTORE_EXCLUSIONS.INTERVENED) notes.push('a copy was not used: ' + reason)
|
|
474
|
+
continue
|
|
475
|
+
}
|
|
476
|
+
if (dir === null) {
|
|
477
|
+
// A copy without a directory cannot be restored from, whatever it says.
|
|
478
|
+
excluded.push({ dir, name, certifiedBy, intervention, exclusion: RESTORE_EXCLUSIONS.NOT_CONTENT_VERIFIED })
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
if (excluded.length > 0) {
|
|
482
|
+
notes.push(excluded.length + ' newer cop' + (excluded.length === 1 ? 'y was' : 'ies were')
|
|
483
|
+
+ ' skipped before this one (see the excluded list)')
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
target: dir,
|
|
487
|
+
targetName: name,
|
|
488
|
+
strict: false,
|
|
489
|
+
interventionUnknown: false,
|
|
490
|
+
reason: 'its restart record says the instance came up with matching content, and no system intervention is on record',
|
|
491
|
+
excluded,
|
|
492
|
+
notes,
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// THE FIRST INCIDENT ON A FRESH INSTALL (user decision, 2026-11-02).
|
|
496
|
+
//
|
|
497
|
+
// A copy is usable when its record says the instance came up AND that no rescue
|
|
498
|
+
// was needed (`intervention === false`). On a machine that has never been
|
|
499
|
+
// rescued nobody can know that second fact yet: a restore report is written when
|
|
500
|
+
// a restore was CONSIDERED, so the very first restart's copy says
|
|
501
|
+
// `intervention: null` - and refusing it would leave the machine with NO restore
|
|
502
|
+
// point at the one moment it needs one.
|
|
503
|
+
//
|
|
504
|
+
// So there is ONE named fallback, deliberately narrow:
|
|
505
|
+
// - it fires only when EVERY refused copy was refused for `intervention` being
|
|
506
|
+
// unrecorded (`INTERVENTION_UNKNOWN`). A single copy that was actually
|
|
507
|
+
// rescued (`intervention === true`), or whose content never came up, keeps
|
|
508
|
+
// the strict answer: nothing is restored;
|
|
509
|
+
// - it takes the NEWEST such copy (the list arrives newest-first);
|
|
510
|
+
// - it SAYS SO, in the reason and in a note. It is never dressed up as a
|
|
511
|
+
// verified copy.
|
|
512
|
+
const refusals = excluded.map((entry) => entry.exclusion)
|
|
513
|
+
const allUnknownIntervention = refusals.length > 0
|
|
514
|
+
&& refusals.every((exclusion) => exclusion === RESTORE_EXCLUSIONS.INTERVENTION_UNKNOWN)
|
|
515
|
+
if (allUnknownIntervention) {
|
|
516
|
+
const fallback = list.find((candidate) => candidate !== null
|
|
517
|
+
&& typeof candidate === 'object'
|
|
518
|
+
&& typeof candidate.dir === 'string'
|
|
519
|
+
&& candidate.dir !== '')
|
|
520
|
+
if (fallback !== undefined) {
|
|
521
|
+
notes.push(
|
|
522
|
+
'the copy that was used is the newest one, and NOBODY RECORDED whether that boot was rescued'
|
|
523
|
+
+ ' (intervention unknown) - it is used because every copy on this machine is in that state,'
|
|
524
|
+
+ ' which is what a machine nobody ever had to rescue looks like',
|
|
525
|
+
)
|
|
526
|
+
return {
|
|
527
|
+
target: fallback.dir,
|
|
528
|
+
targetName: typeof fallback.name === 'string' ? fallback.name : null,
|
|
529
|
+
strict: false,
|
|
530
|
+
interventionUnknown: true,
|
|
531
|
+
reason: 'no copy has a recorded boot that came up with matching content and no recorded rescue;'
|
|
532
|
+
+ ' the newest copy is used anyway because nobody recorded whether ITS boot was rescued'
|
|
533
|
+
+ ' (intervention unknown) - the choice is named here, not assumed',
|
|
534
|
+
excluded,
|
|
535
|
+
notes,
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
target: null,
|
|
541
|
+
targetName: null,
|
|
542
|
+
strict: true,
|
|
543
|
+
interventionUnknown: false,
|
|
544
|
+
reason: list.length === 0
|
|
545
|
+
? 'there is no backup at all, so there is no restore point'
|
|
546
|
+
: 'no backup qualifies: none of the ' + list.length + ' copies has a restart record saying the instance came up with matching content AND is free of a recorded intervention',
|
|
547
|
+
excluded,
|
|
548
|
+
notes,
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* WHAT THE MARKER SAYS ABOUT THE RESTORE — normalized, in one place.
|
|
554
|
+
*
|
|
555
|
+
* A marker WITHOUT `restoreTargetDir` was written by an older version, which
|
|
556
|
+
* stored a "known-good" directory under `snapshotDir` and no certification
|
|
557
|
+
* evidence at all. Reading such a marker as "restore it" would restore a copy
|
|
558
|
+
* whose provenance nobody recorded — the strict answer is "blocked, and say
|
|
559
|
+
* why".
|
|
560
|
+
*
|
|
561
|
+
* @param {object|null|undefined} pending
|
|
562
|
+
* @returns {{ dir: string, blocked: boolean, reason: string, candidates: Array<object>, legacy: boolean }}
|
|
563
|
+
*/
|
|
564
|
+
export function restoreTargetInfoOf(pending) {
|
|
565
|
+
const marker = pending !== null && pending !== undefined && typeof pending === 'object' ? pending : {}
|
|
566
|
+
const dir = typeof marker.restoreTargetDir === 'string' ? marker.restoreTargetDir : ''
|
|
567
|
+
const legacyDir = typeof marker.snapshotDir === 'string' ? marker.snapshotDir : ''
|
|
568
|
+
const candidates = Array.isArray(marker.restoreCandidates) ? marker.restoreCandidates : []
|
|
569
|
+
if (dir !== '') {
|
|
570
|
+
return {
|
|
571
|
+
dir,
|
|
572
|
+
blocked: false,
|
|
573
|
+
reason: typeof marker.restoreBlockedReason === 'string' ? marker.restoreBlockedReason : '',
|
|
574
|
+
candidates,
|
|
575
|
+
legacy: false,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (marker.restoreBlocked === true) {
|
|
579
|
+
return {
|
|
580
|
+
dir: '',
|
|
581
|
+
blocked: true,
|
|
582
|
+
reason: typeof marker.restoreBlockedReason === 'string' && marker.restoreBlockedReason !== ''
|
|
583
|
+
? marker.restoreBlockedReason
|
|
584
|
+
: 'no backup qualifies as a trusted restore point',
|
|
585
|
+
candidates,
|
|
586
|
+
legacy: false,
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (legacyDir !== '') {
|
|
590
|
+
// An OLD marker: it names a directory, but records nothing about the restart
|
|
591
|
+
// that produced it (no boot record, no intervention). Strict mode says: do not
|
|
592
|
+
// restore it, and say why in facts rather than in a verdict.
|
|
593
|
+
return {
|
|
594
|
+
dir: '',
|
|
595
|
+
blocked: true,
|
|
596
|
+
reason: 'this restart was armed by an older version, which recorded no boot record for the copy — nothing says whether that content ever came up, so it is not restored',
|
|
597
|
+
candidates,
|
|
598
|
+
legacy: true,
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
dir: '',
|
|
603
|
+
blocked: true,
|
|
604
|
+
reason: 'this restart was armed without a restore target',
|
|
605
|
+
candidates,
|
|
606
|
+
legacy: false,
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
|
|
149
611
|
/**
|
|
150
612
|
* @param {string} stateDir
|
|
151
613
|
* @returns {string}
|
|
@@ -236,6 +698,65 @@ export function clearReady(stateDir) {
|
|
|
236
698
|
}
|
|
237
699
|
}
|
|
238
700
|
|
|
701
|
+
/**
|
|
702
|
+
* The time inside a marker, in milliseconds, or `null` when it carries none.
|
|
703
|
+
*
|
|
704
|
+
* `at` is written as an ISO string (see {@link writeReady}), but an older or
|
|
705
|
+
* hand-written marker may carry milliseconds, so both are understood.
|
|
706
|
+
*
|
|
707
|
+
* @param {unknown} value
|
|
708
|
+
* @returns {number|null}
|
|
709
|
+
*/
|
|
710
|
+
function markerAtMs(value) {
|
|
711
|
+
if (value === null || typeof value !== 'object') return null;
|
|
712
|
+
const raw = value.at;
|
|
713
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
|
714
|
+
if (typeof raw === 'string' && raw !== '') {
|
|
715
|
+
const parsed = Date.parse(raw);
|
|
716
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
717
|
+
}
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* DOES THIS READY MARKER BELONG TO THIS RESTART?
|
|
723
|
+
*
|
|
724
|
+
* THE BLOCKER THIS EXISTS FOR (2026-11-01, hosted web instance on port 5601):
|
|
725
|
+
* the host was restarted with a broken profile and never came up. The ready
|
|
726
|
+
* marker of the PREVIOUS boot was still on disk (written 09:54:21, the pending
|
|
727
|
+
* marker of the new restart at 09:57:10 — 2m49s apart). {@link readReady} only
|
|
728
|
+
* answered "there is a marker", the flow read that as "the new host is up",
|
|
729
|
+
* waited 0 ms and reported `result: 'not-needed'` with
|
|
730
|
+
* `restored: false, relaunched: false, attempts: 0` while `ss -ltn` showed no
|
|
731
|
+
* listener on 5601 for more than two minutes. A rescue that does nothing must
|
|
732
|
+
* not be indistinguishable from a rescue that was not needed.
|
|
733
|
+
*
|
|
734
|
+
* A ready marker is the receipt of ONE restart: it may only be believed when it
|
|
735
|
+
* was written AT OR AFTER that restart's pending marker. An older marker is the
|
|
736
|
+
* receipt of an earlier boot and says nothing about this one.
|
|
737
|
+
*
|
|
738
|
+
* Two deliberate leniencies, both to keep "a host that is up is never rolled
|
|
739
|
+
* back" (the worst thing this file can do) intact:
|
|
740
|
+
* - a marker without a usable `at` (a rescue script wrote it by hand) cannot
|
|
741
|
+
* be attributed to a restart at all, so it is accepted as before;
|
|
742
|
+
* - a dependency that answers with a bare boolean (only a flow test injects
|
|
743
|
+
* one) carries no timestamp either, so it is accepted as before.
|
|
744
|
+
* In both cases the flow still asks the health route before touching anything,
|
|
745
|
+
* so a live host is protected by that answer instead of by the clock.
|
|
746
|
+
*
|
|
747
|
+
* @param {unknown} ready the value read from watchdog-ready.json (or a dep's answer)
|
|
748
|
+
* @param {number|string|null|undefined} pendingAt the `at` of THIS restart's pending marker
|
|
749
|
+
* @returns {boolean} true when the marker may be believed
|
|
750
|
+
*/
|
|
751
|
+
export function readyIsCurrent(ready, pendingAt) {
|
|
752
|
+
if (!ready) return false;
|
|
753
|
+
const since = markerAtMs({ at: pendingAt });
|
|
754
|
+
if (since === null) return true;
|
|
755
|
+
const atMs = markerAtMs(ready);
|
|
756
|
+
if (atMs === null) return true;
|
|
757
|
+
return atMs >= since;
|
|
758
|
+
}
|
|
759
|
+
|
|
239
760
|
/**
|
|
240
761
|
* Write the report of the last watchdog run.
|
|
241
762
|
*
|
|
@@ -246,6 +767,88 @@ export function writeReport(stateDir, report) {
|
|
|
246
767
|
writeJson(reportPath(stateDir), report);
|
|
247
768
|
}
|
|
248
769
|
|
|
770
|
+
/** The `outcome` written when the host came up and the watchdog stood down. */
|
|
771
|
+
export const REPORT_OUTCOME_DISARMED = 'disarmed';
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* WRITE THE ENDING OF A RESTART THAT WENT FINE (2026-11-01).
|
|
775
|
+
*
|
|
776
|
+
* WHAT WENT WRONG WITHOUT THIS: when a restart succeeded, nobody wrote anything.
|
|
777
|
+
* The report kept the shape it had when the watchdog was ARMED (`result:'armed'`),
|
|
778
|
+
* so `last-watchdog-report.json` said "armed" for a restart that had finished
|
|
779
|
+
* hours earlier — while `health` honestly answered `ready:true` and
|
|
780
|
+
* `scheduleRemoved:true`. Two sources, one of them frozen at the beginning.
|
|
781
|
+
*
|
|
782
|
+
* A report is a record of what HAPPENED, so the ending belongs in it: the host
|
|
783
|
+
* came up, and the watchdog has nothing left to do.
|
|
784
|
+
*
|
|
785
|
+
* EVERY EXISTING FIELD IS PRESERVED VERBATIM (`...previous`): `tier`, `arbiter`,
|
|
786
|
+
* `waitedMs`, `restored`, `relaunched`, `error`, … keep their names and their
|
|
787
|
+
* meaning, because health, the settings page and the watchdog itself all read
|
|
788
|
+
* this file. Only the ending is added, plus `previousResult`, so a reader can
|
|
789
|
+
* still tell what the file said before this call.
|
|
790
|
+
*
|
|
791
|
+
* Never throws: a failed report must not take the boot down with it.
|
|
792
|
+
*
|
|
793
|
+
* @param {string} stateDir
|
|
794
|
+
* @param {{
|
|
795
|
+
* now?: number,
|
|
796
|
+
* record?: object|null,
|
|
797
|
+
* }} [options]
|
|
798
|
+
* @returns {object|null} the merged report (null when there is no state directory)
|
|
799
|
+
*/
|
|
800
|
+
export function recordDisarmOutcome(stateDir, { now = Date.now(), record = null } = {}) {
|
|
801
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
802
|
+
if (dir === '') return null;
|
|
803
|
+
const previous = (() => {
|
|
804
|
+
try {
|
|
805
|
+
const value = readReport(dir);
|
|
806
|
+
return value !== null && typeof value === 'object' && Array.isArray(value) !== true ? value : {};
|
|
807
|
+
} catch {
|
|
808
|
+
return {};
|
|
809
|
+
}
|
|
810
|
+
})();
|
|
811
|
+
const stamp = Number.isFinite(Number(now)) ? Number(now) : Date.now();
|
|
812
|
+
const merged = {
|
|
813
|
+
...previous,
|
|
814
|
+
// THE ENDING: the host is up, so nothing may roll it back.
|
|
815
|
+
hostReady: true,
|
|
816
|
+
disarmed: true,
|
|
817
|
+
disarmedAt: new Date(stamp).toISOString(),
|
|
818
|
+
outcome: REPORT_OUTCOME_DISARMED,
|
|
819
|
+
// The old value is kept beside the new one: "it said armed, and then it was
|
|
820
|
+
// disarmed" is a fact worth being able to read back.
|
|
821
|
+
previousResult: typeof previous.result === 'string' ? previous.result : null,
|
|
822
|
+
result: REPORT_OUTCOME_DISARMED,
|
|
823
|
+
stage: REPORT_OUTCOME_DISARMED,
|
|
824
|
+
ready: true,
|
|
825
|
+
// WHAT THE DISARM ITSELF DID, including the probe tasks it cleaned up.
|
|
826
|
+
disarm: record !== null && typeof record === 'object'
|
|
827
|
+
? {
|
|
828
|
+
at: typeof record.at === 'string' ? record.at : null,
|
|
829
|
+
source: typeof record.source === 'string' ? record.source : null,
|
|
830
|
+
attempts: Number.isFinite(Number(record.attempts)) ? Number(record.attempts) : null,
|
|
831
|
+
ok: record.ok === true,
|
|
832
|
+
scheduleRemoved: record.scheduleRemoved === true
|
|
833
|
+
? true
|
|
834
|
+
: (record.scheduleRemoved === false ? false : null),
|
|
835
|
+
probesRemoved: record.probes && Array.isArray(record.probes.removed)
|
|
836
|
+
? record.probes.removed.slice(0, 50)
|
|
837
|
+
: [],
|
|
838
|
+
probesFailed: record.probes && Array.isArray(record.probes.failed)
|
|
839
|
+
? record.probes.failed.slice(0, 50)
|
|
840
|
+
: [],
|
|
841
|
+
}
|
|
842
|
+
: null,
|
|
843
|
+
};
|
|
844
|
+
try {
|
|
845
|
+
writeReport(dir, merged);
|
|
846
|
+
} catch {
|
|
847
|
+
// A report that cannot be written is not a reason to fail a boot.
|
|
848
|
+
}
|
|
849
|
+
return merged;
|
|
850
|
+
}
|
|
851
|
+
|
|
249
852
|
/**
|
|
250
853
|
* @param {string} stateDir
|
|
251
854
|
* @returns {object|null}
|
|
@@ -299,6 +902,85 @@ export function releaseLock(lockFile) {
|
|
|
299
902
|
}
|
|
300
903
|
}
|
|
301
904
|
|
|
905
|
+
/**
|
|
906
|
+
* The links a restore has to decide about, with everything the decision needs.
|
|
907
|
+
*
|
|
908
|
+
* It is the UNION of two lists, and both halves matter:
|
|
909
|
+
*
|
|
910
|
+
* - the links the LIVE profile still has (`collectLinkedPlugins`), and
|
|
911
|
+
* - the links the BACKUP recorded (`manifest.json` → `links`).
|
|
912
|
+
*
|
|
913
|
+
* A plugin whose `node_modules` entry was deleted is gone from the first list
|
|
914
|
+
* but still in the second — and that is exactly the case this feature exists
|
|
915
|
+
* for ("the plugin that does the rescuing was removed with the profile").
|
|
916
|
+
*
|
|
917
|
+
* WHERE THE COPY COMES FROM is called `restoreTargetDir` (2026-11-01): it is the
|
|
918
|
+
* copy the watchdog is allowed to restore, never "the snapshot of this restart"
|
|
919
|
+
* — the old name said the wrong thing and invited exactly that confusion. The
|
|
920
|
+
* old name is still accepted as an alias so an older caller keeps working.
|
|
921
|
+
*
|
|
922
|
+
* @param {{ restoreTargetDir?: string, snapshotDir?: string, profileDir?: string, collect?: Function }} [options]
|
|
923
|
+
* @returns {Array<{
|
|
924
|
+
* name: string, target: string, targetExists: boolean, targetReadable: boolean,
|
|
925
|
+
* hasBackup: boolean, backupPath: string,
|
|
926
|
+
* }>}
|
|
927
|
+
*/
|
|
928
|
+
export function linkPlanOf({ restoreTargetDir = '', snapshotDir = '', profileDir = '', collect = null } = {}) {
|
|
929
|
+
const requested = typeof restoreTargetDir === 'string' && restoreTargetDir !== '' ? restoreTargetDir : snapshotDir;
|
|
930
|
+
const snapshot = typeof requested === 'string' ? requested : '';
|
|
931
|
+
const profile = typeof profileDir === 'string' ? profileDir : '';
|
|
932
|
+
if (snapshot === '') return [];
|
|
933
|
+
const gather = typeof collect === 'function' ? collect : collectLinkedPlugins;
|
|
934
|
+
const live = (() => {
|
|
935
|
+
try {
|
|
936
|
+
const collected = gather({ profileDir: profile });
|
|
937
|
+
return Array.isArray(collected && collected.links) ? collected.links : [];
|
|
938
|
+
} catch {
|
|
939
|
+
return [];
|
|
940
|
+
}
|
|
941
|
+
})();
|
|
942
|
+
const manifestRead = readJsonState(path.join(snapshot, MANIFEST_NAME));
|
|
943
|
+
const recorded = manifestRead.state === 'ok'
|
|
944
|
+
&& manifestRead.value !== null
|
|
945
|
+
&& Array.isArray(manifestRead.value.links)
|
|
946
|
+
? manifestRead.value.links
|
|
947
|
+
: [];
|
|
948
|
+
|
|
949
|
+
const plan = [];
|
|
950
|
+
const seen = new Set();
|
|
951
|
+
for (const link of live) {
|
|
952
|
+
const name = typeof link.name === 'string' ? link.name : '';
|
|
953
|
+
if (name === '' || seen.has(name)) continue;
|
|
954
|
+
seen.add(name);
|
|
955
|
+
const backupPath = path.join(snapshot, LINKS_DIR, name);
|
|
956
|
+
plan.push({
|
|
957
|
+
name,
|
|
958
|
+
target: typeof link.target === 'string' ? link.target : '',
|
|
959
|
+
targetExists: link.exists === true,
|
|
960
|
+
targetReadable: link.readable === true,
|
|
961
|
+
hasBackup: fs.existsSync(backupPath),
|
|
962
|
+
backupPath,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
for (const link of recorded) {
|
|
966
|
+
const name = link && typeof link.name === 'string' ? link.name : '';
|
|
967
|
+
if (name === '' || seen.has(name)) continue;
|
|
968
|
+
seen.add(name);
|
|
969
|
+
const backupPath = path.join(snapshot, LINKS_DIR, name);
|
|
970
|
+
plan.push({
|
|
971
|
+
name,
|
|
972
|
+
target: typeof link.target === 'string' ? link.target : '',
|
|
973
|
+
// The entry is not in the live profile any more: it was deleted.
|
|
974
|
+
targetExists: false,
|
|
975
|
+
targetReadable: false,
|
|
976
|
+
hasBackup: fs.existsSync(backupPath),
|
|
977
|
+
backupPath,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
plan.sort((left, right) => (left.name < right.name ? -1 : (left.name > right.name ? 1 : 0)));
|
|
981
|
+
return plan;
|
|
982
|
+
}
|
|
983
|
+
|
|
302
984
|
/**
|
|
303
985
|
* Put the snapshot back.
|
|
304
986
|
*
|
|
@@ -307,19 +989,135 @@ export function releaseLock(lockFile) {
|
|
|
307
989
|
* deleted first — the broken files are overwritten, so a half-restored
|
|
308
990
|
* profile is never worse than the broken one.)
|
|
309
991
|
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
992
|
+
* THE LINKED PLUGINS (2026-10-30). A `link:` installation puts a reparse point
|
|
993
|
+
* in `node_modules/<name>`, so the tree copy above brings back the LINK and
|
|
994
|
+
* nothing behind it. A host cannot start from a link whose target is gone, and
|
|
995
|
+
* this plugin — the one doing the rescuing — is itself installed that way. The
|
|
996
|
+
* decision is therefore made per link, with the SAME function the restore
|
|
997
|
+
* script uses (`decideLinkRestore` in lib/backup.js), so a manual restore and
|
|
998
|
+
* an automatic one cannot disagree:
|
|
999
|
+
*
|
|
1000
|
+
* - target there and readable, first attempt -> KEEP the link (the
|
|
1001
|
+
* development directory keeps working);
|
|
1002
|
+
* - target gone / unreadable, forced, or a later attempt -> REPLACE it with
|
|
1003
|
+
* the contents the backup holds;
|
|
1004
|
+
* - no backup content -> leave the link alone and say so (never delete what
|
|
1005
|
+
* cannot be replaced).
|
|
1006
|
+
*
|
|
1007
|
+
* @param {{
|
|
1008
|
+
* restoreTargetDir?: string, snapshotDir?: string, profileDir?: string, copy?: Function,
|
|
1009
|
+
* attempts?: number, forceLinkPlugins?: boolean,
|
|
1010
|
+
* collectLinks?: Function, decideLink?: Function,
|
|
1011
|
+
* exists?: Function, removeLink?: Function,
|
|
1012
|
+
* }} options
|
|
1013
|
+
* @returns {{ ok: boolean, error: string|null, copied: string[], links: Array<object> }}
|
|
312
1014
|
*/
|
|
313
|
-
export function restoreFromSnapshot({
|
|
314
|
-
|
|
1015
|
+
export function restoreFromSnapshot({
|
|
1016
|
+
restoreTargetDir = '',
|
|
1017
|
+
snapshotDir = '',
|
|
1018
|
+
profileDir = '',
|
|
1019
|
+
copy = null,
|
|
1020
|
+
attempts = 1,
|
|
1021
|
+
forceLinkPlugins = false,
|
|
1022
|
+
collectLinks = null,
|
|
1023
|
+
decideLink = null,
|
|
1024
|
+
exists = null,
|
|
1025
|
+
removeLink = null,
|
|
1026
|
+
} = {}) {
|
|
1027
|
+
const requested = typeof restoreTargetDir === 'string' && restoreTargetDir !== '' ? restoreTargetDir : snapshotDir;
|
|
1028
|
+
const from = String(requested ?? '');
|
|
315
1029
|
const into = String(profileDir ?? '');
|
|
316
1030
|
if (from === '' || into === '') {
|
|
317
|
-
return { ok: false, error: 'the snapshot or the profile directory is unknown', copied: [] };
|
|
1031
|
+
return { ok: false, error: 'the snapshot or the profile directory is unknown', copied: [], links: [] };
|
|
318
1032
|
}
|
|
319
1033
|
const copied = [];
|
|
1034
|
+
const linkReport = [];
|
|
320
1035
|
const copyInto = copy === null
|
|
321
1036
|
? (source, target) => { fs.cpSync(source, target, { recursive: true, force: true }); }
|
|
322
1037
|
: copy;
|
|
1038
|
+
// A DANGLING reparse point is invisible to `fs.existsSync` (it follows the
|
|
1039
|
+
// link and the target is gone) but it IS an entry that `fs.cpSync` will try
|
|
1040
|
+
// to write into - which kills the whole process on Windows (0xC0000409).
|
|
1041
|
+
// `lstatSync` does not follow, so it answers "the entry is there".
|
|
1042
|
+
const isThere = typeof exists === 'function'
|
|
1043
|
+
? exists
|
|
1044
|
+
: (target) => {
|
|
1045
|
+
try {
|
|
1046
|
+
fs.lstatSync(target);
|
|
1047
|
+
return true;
|
|
1048
|
+
} catch {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
// The default remover deletes the REPARSE POINT, never the directory behind
|
|
1053
|
+
// it: on Windows `[System.IO.Directory]::Delete(path, false)` removes a
|
|
1054
|
+
// junction/symlink itself, while `rm -rf` would delete the target's contents
|
|
1055
|
+
// (that would take the development tree with it).
|
|
1056
|
+
const dropLink = typeof removeLink === 'function'
|
|
1057
|
+
? removeLink
|
|
1058
|
+
: (target) => {
|
|
1059
|
+
try {
|
|
1060
|
+
fs.unlinkSync(target);
|
|
1061
|
+
} catch {
|
|
1062
|
+
fs.rmdirSync(target);
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
// THE DEFAULT PLAN comes from lib/backup.js, so this file and the restore
|
|
1066
|
+
// script run the SAME decision (and the same rules) instead of two opinions
|
|
1067
|
+
// that drift apart. The CLI does not have to pass anything.
|
|
1068
|
+
const gather = typeof collectLinks === 'function'
|
|
1069
|
+
? collectLinks
|
|
1070
|
+
: (input) => linkPlanOf({ snapshotDir: input.snapshotDir, profileDir: input.profileDir });
|
|
1071
|
+
const judge = typeof decideLink === 'function' ? decideLink : decideLinkRestore;
|
|
1072
|
+
|
|
1073
|
+
// -------------------------------------------------------------------------
|
|
1074
|
+
// ORDER MATTERS, and it was measured the hard way: `fs.cpSync` into a
|
|
1075
|
+
// node_modules that still holds a DANGLING junction/junction-like reparse
|
|
1076
|
+
// point kills the Node process outright on Windows (0xC0000409, no stack, no
|
|
1077
|
+
// message, nothing written) - the probe is in the task report. So every link
|
|
1078
|
+
// whose target is gone is removed BEFORE the tree copy, and only then are the
|
|
1079
|
+
// contents of the backup put in its place.
|
|
1080
|
+
// -------------------------------------------------------------------------
|
|
1081
|
+
const planned = [];
|
|
1082
|
+
try {
|
|
1083
|
+
for (const link of gather({ snapshotDir: from, profileDir: into })) {
|
|
1084
|
+
const verdict = judge({
|
|
1085
|
+
targetExists: link.targetExists === true,
|
|
1086
|
+
targetReadable: link.targetReadable === true,
|
|
1087
|
+
hasBackup: link.hasBackup === true,
|
|
1088
|
+
force: forceLinkPlugins === true,
|
|
1089
|
+
attempts,
|
|
1090
|
+
});
|
|
1091
|
+
const entry = {
|
|
1092
|
+
name: link.name,
|
|
1093
|
+
target: link.target,
|
|
1094
|
+
hasBackup: link.hasBackup === true,
|
|
1095
|
+
backupPath: link.backupPath,
|
|
1096
|
+
action: verdict.action,
|
|
1097
|
+
reason: verdict.reason,
|
|
1098
|
+
replace: verdict.replace === true,
|
|
1099
|
+
cleared: false,
|
|
1100
|
+
};
|
|
1101
|
+
// A link that points nowhere is cleared whether or not a copy is going in:
|
|
1102
|
+
// leaving it there crashes the copy (above) and cannot start a host.
|
|
1103
|
+
const broken = link.targetExists !== true || link.targetReadable !== true;
|
|
1104
|
+
if (entry.replace === true || broken === true) {
|
|
1105
|
+
const livePath = path.join(into, 'node_modules', link.name);
|
|
1106
|
+
if (isThere(livePath)) {
|
|
1107
|
+
try {
|
|
1108
|
+
dropLink(livePath);
|
|
1109
|
+
entry.cleared = true;
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
entry.clearError = error && error.message ? error.message : String(error);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
planned.push(entry);
|
|
1116
|
+
}
|
|
1117
|
+
} catch (error) {
|
|
1118
|
+
linkReport.push({ name: '', action: 'error', reason: error && error.message ? error.message : String(error) });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
323
1121
|
try {
|
|
324
1122
|
for (const [part, target] of [['config', into], ['node_modules', path.join(into, 'node_modules')]]) {
|
|
325
1123
|
const source = path.join(from, part);
|
|
@@ -332,9 +1130,43 @@ export function restoreFromSnapshot({ snapshotDir = '', profileDir = '', copy =
|
|
|
332
1130
|
ok: false,
|
|
333
1131
|
error: error && error.message ? error.message : String(error),
|
|
334
1132
|
copied,
|
|
1133
|
+
links: linkReport.concat(planned.map((entry) => ({
|
|
1134
|
+
name: entry.name,
|
|
1135
|
+
target: entry.target,
|
|
1136
|
+
action: entry.action,
|
|
1137
|
+
reason: entry.reason,
|
|
1138
|
+
}))),
|
|
335
1139
|
};
|
|
336
1140
|
}
|
|
337
|
-
|
|
1141
|
+
|
|
1142
|
+
// THE LINKS, one decision each, after the tree is back. A failure here is
|
|
1143
|
+
// reported, never thrown: the configuration and the tree are already restored.
|
|
1144
|
+
for (const entry of planned) {
|
|
1145
|
+
const reported = { name: entry.name, target: entry.target, action: entry.action, reason: entry.reason };
|
|
1146
|
+
if (entry.replace === true && entry.hasBackup === true) {
|
|
1147
|
+
const livePath = path.join(into, 'node_modules', entry.name);
|
|
1148
|
+
try {
|
|
1149
|
+
if (isThere(livePath)) dropLink(livePath);
|
|
1150
|
+
copyInto(entry.backupPath, livePath);
|
|
1151
|
+
reported.replaced = true;
|
|
1152
|
+
copied.push(path.join('node_modules', entry.name));
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
reported.replaced = false;
|
|
1155
|
+
reported.error = error && error.message ? error.message : String(error);
|
|
1156
|
+
}
|
|
1157
|
+
} else if (entry.cleared === true) {
|
|
1158
|
+
// No copy to put back: the dead link is gone and the report says so.
|
|
1159
|
+
reported.cleared = true;
|
|
1160
|
+
}
|
|
1161
|
+
linkReport.push(reported);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return {
|
|
1165
|
+
ok: copied.length > 0,
|
|
1166
|
+
error: copied.length > 0 ? null : 'the snapshot carries nothing to restore',
|
|
1167
|
+
copied,
|
|
1168
|
+
links: linkReport,
|
|
1169
|
+
};
|
|
338
1170
|
}
|
|
339
1171
|
|
|
340
1172
|
/**
|
|
@@ -352,7 +1184,13 @@ async function waitForReady(deps, timeoutMs) {
|
|
|
352
1184
|
if (deps.isWithdrawn && deps.isWithdrawn()) return false;
|
|
353
1185
|
await deps.sleep(Math.min(deps.pollMs, Math.max(0, deadline - deps.now())));
|
|
354
1186
|
}
|
|
355
|
-
|
|
1187
|
+
// `Boolean(...)`, NOT `=== true`: the real reader answers with the marker
|
|
1188
|
+
// OBJECT (or null), so `=== true` made this last look never count — a host
|
|
1189
|
+
// whose marker appeared exactly at the deadline was reported as not coming
|
|
1190
|
+
// up. The value has already been filtered through `readyIsCurrent` by the
|
|
1191
|
+
// caller (see runWatchdogFlow), so a marker of an EARLIER restart is `null`
|
|
1192
|
+
// here and cannot be counted either.
|
|
1193
|
+
return Boolean(deps.readReady());
|
|
356
1194
|
}
|
|
357
1195
|
|
|
358
1196
|
/**
|
|
@@ -375,6 +1213,58 @@ function healthAnswer(value) {
|
|
|
375
1213
|
return { state: typeof value === 'string' ? value : 'unknown', detail: null };
|
|
376
1214
|
}
|
|
377
1215
|
|
|
1216
|
+
/**
|
|
1217
|
+
* Normalize the answer of the cookie cleanup.
|
|
1218
|
+
*
|
|
1219
|
+
* The cleanup script always exits 0 (a restart must never be blocked by a
|
|
1220
|
+
* cleanup), so the answer of the runner means "the cleanup process finished",
|
|
1221
|
+
* not "cookies were deleted". `removed` is the count when the runner knows it
|
|
1222
|
+
* and `null` when it does not — a count that was never measured is reported as
|
|
1223
|
+
* not measured, never as 0.
|
|
1224
|
+
*
|
|
1225
|
+
* @param {unknown} value
|
|
1226
|
+
* @returns {{ ok: boolean, removed: number|null, error: string|null }}
|
|
1227
|
+
*/
|
|
1228
|
+
function cookieClearAnswer(value) {
|
|
1229
|
+
if (value === null || value === undefined) {
|
|
1230
|
+
return { ok: false, removed: null, error: 'the cookie cleanup was not run' };
|
|
1231
|
+
}
|
|
1232
|
+
if (typeof value === 'object') {
|
|
1233
|
+
const ok = value.ok === true;
|
|
1234
|
+
const count = Number(value.removed);
|
|
1235
|
+
const removed = value.removed === null || value.removed === undefined || !Number.isFinite(count)
|
|
1236
|
+
? null
|
|
1237
|
+
: Math.trunc(count);
|
|
1238
|
+
const detail = typeof value.error === 'string' && value.error !== '' ? value.error : null;
|
|
1239
|
+
return { ok, removed, error: ok ? detail : (detail ?? 'the cookie cleanup failed') };
|
|
1240
|
+
}
|
|
1241
|
+
const ok = value === true;
|
|
1242
|
+
return { ok, removed: null, error: ok ? null : 'the cookie cleanup failed' };
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Run the cleanup, and never let it break the rescue.
|
|
1247
|
+
*
|
|
1248
|
+
* A thrown cleanup is an answer like any other: `{ ok:false, error }` — the
|
|
1249
|
+
* caller records it in the report and goes on to start the host, because a
|
|
1250
|
+
* host that starts with stale cookies is still better than a host that never
|
|
1251
|
+
* starts at all.
|
|
1252
|
+
*
|
|
1253
|
+
* @param {object} deps
|
|
1254
|
+
* @returns {Promise<{ ok: boolean, removed: number|null, error: string|null }>}
|
|
1255
|
+
*/
|
|
1256
|
+
async function runCookieClear(deps) {
|
|
1257
|
+
try {
|
|
1258
|
+
return cookieClearAnswer(await deps.clearCookies());
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
return {
|
|
1261
|
+
ok: false,
|
|
1262
|
+
removed: null,
|
|
1263
|
+
error: error && error.message ? error.message : String(error),
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
378
1268
|
/**
|
|
379
1269
|
* THE FLOW.
|
|
380
1270
|
*
|
|
@@ -398,6 +1288,11 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
|
|
|
398
1288
|
askHealth: () => 'unknown',
|
|
399
1289
|
runRestore: () => ({ ok: false, error: 'no restore command' }),
|
|
400
1290
|
runRelaunch: () => ({ ok: false, error: 'no relaunch command' }),
|
|
1291
|
+
// THE HANDSHAKE COOKIES (see lib/cookie-clear.js). The CLI runs the real
|
|
1292
|
+
// cleanup script; a flow test injects it. The default REFUSES instead of
|
|
1293
|
+
// pretending: a report that says "the cookies were cleared" when nobody
|
|
1294
|
+
// cleared them is worse than a report that says nobody tried.
|
|
1295
|
+
clearCookies: () => ({ ok: false, removed: null, error: 'no cookie cleanup is wired' }),
|
|
401
1296
|
acquireLock: () => true,
|
|
402
1297
|
releaseLock: () => {},
|
|
403
1298
|
removeSchedule: () => {},
|
|
@@ -423,13 +1318,80 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
|
|
|
423
1318
|
health: null,
|
|
424
1319
|
extraRound: false,
|
|
425
1320
|
restored: false,
|
|
1321
|
+
// WHICH COPY WAS RESTORED (2026-11-01). Until now the report said `restored:
|
|
1322
|
+
// true` and nothing else — "which copy did you put back?" was unanswerable
|
|
1323
|
+
// from the report, which is exactly the question a human asks afterwards.
|
|
1324
|
+
restoredFrom: null,
|
|
1325
|
+
// HOW OFTEN a restore was performed. It is 0 or 1 by design (see MAX_RESTORES):
|
|
1326
|
+
// restoring the SAME copy twice changes nothing, and doing it three times was
|
|
1327
|
+
// measured hitting the same wall three times.
|
|
1328
|
+
restores: 0,
|
|
426
1329
|
relaunched: false,
|
|
427
1330
|
ready: false,
|
|
1331
|
+
// THE STRICT-MODE ANSWER: when no copy qualifies, `strictMode` is true and
|
|
1332
|
+
// `restoreCandidates` holds every copy that was looked at, with the reason
|
|
1333
|
+
// each one was refused.
|
|
1334
|
+
strictMode: false,
|
|
1335
|
+
restoreTarget: null,
|
|
1336
|
+
restoreBlocked: false,
|
|
1337
|
+
restoreBlockedReason: null,
|
|
1338
|
+
restoreCandidates: [],
|
|
1339
|
+
// WAS THERE A READY MARKER AND WAS IT REFUSED? A marker of an EARLIER
|
|
1340
|
+
// restart (its `at` is before this restart's pending `at`) is read and
|
|
1341
|
+
// thrown away (see readyIsCurrent). The 2026-11-01 run said nothing at all
|
|
1342
|
+
// about this and looked like a run that had nothing to do, so the refusal
|
|
1343
|
+
// is REPORTED: `staleReadyIgnored` says it happened, `staleReadyAt` says
|
|
1344
|
+
// which marker it was.
|
|
1345
|
+
staleReadyIgnored: false,
|
|
1346
|
+
staleReadyAt: null,
|
|
428
1347
|
error: null,
|
|
1348
|
+
// WHERE the run stood when it ended, so "the rescue failed" and "I never
|
|
1349
|
+
// got going" can never be confused: 'flow' (the flow ran), 'relaunch'
|
|
1350
|
+
// (the host could not be started again). The CLI writes 'import' and
|
|
1351
|
+
// 'config' for a run that could not even read its own marker.
|
|
1352
|
+
stage: 'flow',
|
|
1353
|
+
// HOW OFTEN this run was allowed to try, and how often it did.
|
|
1354
|
+
maxAttempts: maxAttemptsOf(pending),
|
|
1355
|
+
attempts: 0,
|
|
1356
|
+
// The LAST cookie cleanup, and one entry per attempt (never silent).
|
|
1357
|
+
cookieClear: null,
|
|
1358
|
+
attemptLog: [],
|
|
1359
|
+
};
|
|
1360
|
+
// THE READY MARKER OF AN EARLIER RESTART IS NOT A RECEIPT OF THIS ONE.
|
|
1361
|
+
//
|
|
1362
|
+
// Every read of the marker inside this flow goes through `readCurrentReady`,
|
|
1363
|
+
// never through `d.readReady()` directly: a marker written BEFORE this
|
|
1364
|
+
// restart's pending marker (`pending.at`) belongs to the boot that was
|
|
1365
|
+
// replaced, and believing it is exactly the 2026-11-01 blocker — the host
|
|
1366
|
+
// never came up, the old marker said "it is up", the flow waited 0 ms and
|
|
1367
|
+
// reported `result: 'not-needed'` with nothing restored and nothing started
|
|
1368
|
+
// while port 5601 stayed empty for more than two minutes.
|
|
1369
|
+
const pendingAt = pending && pending.at !== undefined ? pending.at : null;
|
|
1370
|
+
// WHAT THE MARKER SAYS THIS RUN MAY RESTORE — read once, reported always.
|
|
1371
|
+
// It is filled in BEFORE the first look at the ready marker so the fields also
|
|
1372
|
+
// exist for a run that ends with "not-needed": an operator reading the report
|
|
1373
|
+
// should never have to guess whether a target existed.
|
|
1374
|
+
const target = restoreTargetInfoOf(pending);
|
|
1375
|
+
report.restoreTarget = target.dir === '' ? null : target.dir;
|
|
1376
|
+
report.restoreBlocked = target.blocked;
|
|
1377
|
+
report.restoreBlockedReason = target.reason === '' ? null : target.reason;
|
|
1378
|
+
report.restoreCandidates = target.candidates;
|
|
1379
|
+
const readCurrentReady = () => {
|
|
1380
|
+
const value = d.readReady();
|
|
1381
|
+
// "Nothing there yet" is not a refused marker: it is the normal first look.
|
|
1382
|
+
if (!value) return value;
|
|
1383
|
+
if (readyIsCurrent(value, pendingAt)) return value;
|
|
1384
|
+
report.staleReadyIgnored = true;
|
|
1385
|
+
report.staleReadyAt = typeof value.at === 'string' ? value.at : null;
|
|
1386
|
+
return null;
|
|
429
1387
|
};
|
|
1388
|
+
// `waitForReady` keeps its signature: it is handed the same dependencies with
|
|
1389
|
+
// ONE reader swapped for the filtered one, so a stale marker cannot make it
|
|
1390
|
+
// return true either.
|
|
1391
|
+
const readyDeps = { ...d, readReady: readCurrentReady };
|
|
430
1392
|
try {
|
|
431
1393
|
// (1) The new host writes the ready marker after its health check.
|
|
432
|
-
const ready = await waitForReady(
|
|
1394
|
+
const ready = await waitForReady(readyDeps, pending.timeoutMs);
|
|
433
1395
|
if (ready) {
|
|
434
1396
|
report.result = 'not-needed';
|
|
435
1397
|
report.ready = true;
|
|
@@ -444,7 +1406,7 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
|
|
|
444
1406
|
// (2) ASK AGAIN. A host that is slow to start is not a host that is dead.
|
|
445
1407
|
report.rechecked = true;
|
|
446
1408
|
await d.sleep(Math.max(0, pending.recheckMs));
|
|
447
|
-
if (
|
|
1409
|
+
if (readCurrentReady()) {
|
|
448
1410
|
report.result = 'not-needed';
|
|
449
1411
|
report.ready = true;
|
|
450
1412
|
return report;
|
|
@@ -485,7 +1447,7 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
|
|
|
485
1447
|
// still starting gets this last chance before anything is restored.
|
|
486
1448
|
report.extraRound = true;
|
|
487
1449
|
await d.sleep(Math.max(0, pending.recheckMs));
|
|
488
|
-
if (
|
|
1450
|
+
if (readCurrentReady()) {
|
|
489
1451
|
report.result = 'not-needed';
|
|
490
1452
|
report.ready = true;
|
|
491
1453
|
return report;
|
|
@@ -512,34 +1474,218 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
|
|
|
512
1474
|
}
|
|
513
1475
|
}
|
|
514
1476
|
|
|
515
|
-
// (4)
|
|
1477
|
+
// (4) THE LOCK: exactly ONE of us restores. And before it: STRICT MODE.
|
|
1478
|
+
//
|
|
1479
|
+
// A restore is only allowed when the marker names a copy the ARMING side was
|
|
1480
|
+
// allowed to pick — certified, and free of a recorded system intervention.
|
|
1481
|
+
// When it names none, the strict-mode decision the user took applies: do NOT
|
|
1482
|
+
// touch anything (no lock, no restore, no relaunch — a relaunch of a broken
|
|
1483
|
+
// profile is not a rescue), and say it out loud with the list of copies that
|
|
1484
|
+
// were refused, so a human knows exactly what to do next.
|
|
1485
|
+
//
|
|
1486
|
+
// "Not touching it" is NOT the same as staying silent: a run that ends
|
|
1487
|
+
// quietly looks like a run that found nothing to do, and that is how the bad
|
|
1488
|
+
// state stayed certified in the first place. (`target` is read at the top of
|
|
1489
|
+
// the flow, so the same fields are in a report that ends 'not-needed'.)
|
|
1490
|
+
if (target.dir === '' || target.blocked === true) {
|
|
1491
|
+
report.result = 'failed';
|
|
1492
|
+
report.stage = 'strict';
|
|
1493
|
+
report.strictMode = true;
|
|
1494
|
+
report.error = 'no trusted restore point: nothing was restored or started'
|
|
1495
|
+
+ (target.reason === '' ? '' : ' (' + target.reason + ')');
|
|
1496
|
+
return report;
|
|
1497
|
+
}
|
|
516
1498
|
if (!d.acquireLock()) {
|
|
517
1499
|
report.result = 'abandoned';
|
|
518
1500
|
report.error = 'another rollback is already running';
|
|
519
1501
|
return report;
|
|
520
1502
|
}
|
|
521
1503
|
try {
|
|
522
|
-
// (5)
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
1504
|
+
// (5) THE PASS: up to `maxAttempts` tries (3 by default, never endless).
|
|
1505
|
+
//
|
|
1506
|
+
// Every try AFTER the first asks the three questions of steps (1)-(3b)
|
|
1507
|
+
// again, because the world changed in between: the host we started may
|
|
1508
|
+
// have come up (a slow boot is not a dead host) and another instance may
|
|
1509
|
+
// be alive (a second start next to a running host is the double start
|
|
1510
|
+
// this whole file exists to prevent). The FIRST try does not ask again:
|
|
1511
|
+
// those three answers are a moment old and cannot have changed.
|
|
1512
|
+
//
|
|
1513
|
+
// The pass order is fixed and must stay that way:
|
|
1514
|
+
// ready? -> alive? -> health? -> restore -> CLEAR THE COOKIES -> start -> wait
|
|
1515
|
+
let restoreNeeded = true;
|
|
1516
|
+
for (let attempt = 1; attempt <= report.maxAttempts; attempt += 1) {
|
|
1517
|
+
if (attempt > 1) {
|
|
1518
|
+
if (readCurrentReady()) {
|
|
1519
|
+
report.result = 'not-needed';
|
|
1520
|
+
report.ready = true;
|
|
1521
|
+
report.error = null;
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
if (d.isWithdrawn()) {
|
|
1525
|
+
report.result = 'decided';
|
|
1526
|
+
report.error = 'the plugin decided on its own: the watchdog stood down';
|
|
1527
|
+
break;
|
|
1528
|
+
}
|
|
1529
|
+
if (await d.probeAlive()) {
|
|
1530
|
+
report.result = 'abandoned';
|
|
1531
|
+
report.error = 'an instance is alive: the host was not started again';
|
|
1532
|
+
break;
|
|
1533
|
+
}
|
|
1534
|
+
const again = healthAnswer(await d.askHealth());
|
|
1535
|
+
report.healthCheck = again.state;
|
|
1536
|
+
report.healthDetail = again.detail;
|
|
1537
|
+
if (again.state === 'healthy') {
|
|
1538
|
+
report.result = 'not-needed';
|
|
1539
|
+
report.health = 'healthy';
|
|
1540
|
+
report.error = 'the host answered: it is up, it was NOT started again'
|
|
1541
|
+
+ (again.detail === null ? '' : ' (' + again.detail + ')');
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
if (again.state === 'unknown') {
|
|
1545
|
+
// There is still no port to ask: one more round, then look again.
|
|
1546
|
+
await d.sleep(Math.max(0, pending.recheckMs));
|
|
1547
|
+
if (readCurrentReady()) {
|
|
1548
|
+
report.result = 'not-needed';
|
|
1549
|
+
report.ready = true;
|
|
1550
|
+
report.error = null;
|
|
1551
|
+
break;
|
|
1552
|
+
}
|
|
1553
|
+
if (d.isWithdrawn()) {
|
|
1554
|
+
report.result = 'decided';
|
|
1555
|
+
report.error = 'the plugin decided on its own: the watchdog stood down';
|
|
1556
|
+
break;
|
|
1557
|
+
}
|
|
1558
|
+
if (await d.probeAlive()) {
|
|
1559
|
+
report.result = 'abandoned';
|
|
1560
|
+
report.error = 'an instance is alive: the host was not started again';
|
|
1561
|
+
break;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
report.attempts = attempt;
|
|
1567
|
+
const entry = {
|
|
1568
|
+
attempt,
|
|
1569
|
+
at: new Date(d.now()).toISOString(),
|
|
1570
|
+
restored: false,
|
|
1571
|
+
// WHICH COPY this attempt put back (null when it did not restore). A
|
|
1572
|
+
// report that only said "restored: true" could never answer "from
|
|
1573
|
+
// what", which is the first thing a human asks afterwards.
|
|
1574
|
+
restoredFrom: null,
|
|
1575
|
+
cookieClear: null,
|
|
1576
|
+
relaunched: false,
|
|
1577
|
+
ready: false,
|
|
1578
|
+
// The marker of an earlier restart was read and refused before this
|
|
1579
|
+
// attempt began: one entry says it, next to what this attempt did.
|
|
1580
|
+
staleReadyIgnored: report.staleReadyIgnored === true,
|
|
1581
|
+
backoffMs: 0,
|
|
1582
|
+
error: null,
|
|
1583
|
+
};
|
|
1584
|
+
report.attemptLog.push(entry);
|
|
1585
|
+
|
|
1586
|
+
// (d) Put the copy back — AT MOST ONCE (2026-11-01).
|
|
1587
|
+
//
|
|
1588
|
+
// Restoring the SAME copy again cannot change anything: the bytes are
|
|
1589
|
+
// identical. Measured on the real machine, three attempts restored the
|
|
1590
|
+
// same copy three times and hit the same wall three times. So a restore
|
|
1591
|
+
// happens once; what may be repeated is the START (`relaunch`), because
|
|
1592
|
+
// a host that died for an unrelated reason can come up on the next try.
|
|
1593
|
+
if (restoreNeeded && report.restores < MAX_RESTORES) {
|
|
1594
|
+
const restored = d.runRestore();
|
|
1595
|
+
report.restores += 1;
|
|
1596
|
+
report.restored = restored !== null && restored !== undefined && restored.ok === true;
|
|
1597
|
+
entry.restored = report.restored;
|
|
1598
|
+
if (!report.restored) {
|
|
1599
|
+
restoreNeeded = false;
|
|
1600
|
+
report.result = 'failed';
|
|
1601
|
+
report.error = (restored && restored.error ? restored.error : 'the restore failed')
|
|
1602
|
+
+ ': the restore is NOT retried (the same copy would be restored again)';
|
|
1603
|
+
entry.error = report.error;
|
|
1604
|
+
break;
|
|
1605
|
+
}
|
|
1606
|
+
restoreNeeded = false;
|
|
1607
|
+
// The copy the watchdog actually put back: what the marker armed, or
|
|
1608
|
+
// what the restore implementation reports it used.
|
|
1609
|
+
const used = restored && typeof restored.dir === 'string' && restored.dir !== ''
|
|
1610
|
+
? restored.dir
|
|
1611
|
+
: target.dir;
|
|
1612
|
+
report.restoredFrom = used === '' ? null : used;
|
|
1613
|
+
entry.restoredFrom = report.restoredFrom;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// (e) THE HANDSHAKE COOKIES, AFTER the restore and BEFORE the start.
|
|
1617
|
+
//
|
|
1618
|
+
// The desktop app mints one `dsh-auth-*` cookie per launch, the cookie
|
|
1619
|
+
// lives 30 days, and the host answers 431 when the Cookie header
|
|
1620
|
+
// overflows (71 cookies, measured). Starting the host again with the
|
|
1621
|
+
// stale cookies therefore kills it again — that is what happened on
|
|
1622
|
+
// 2026-10-30. The cleanup runs once Chromium has left (the host is
|
|
1623
|
+
// confirmed dead above), and it never blocks the rescue: the script
|
|
1624
|
+
// itself always exits 0 for that reason. But it is ALWAYS written into
|
|
1625
|
+
// the report: a cleanup that could not run must never look like one
|
|
1626
|
+
// that did.
|
|
1627
|
+
const cookie = await runCookieClear(d);
|
|
1628
|
+
report.cookieClear = cookie;
|
|
1629
|
+
entry.cookieClear = cookie;
|
|
1630
|
+
if (!cookie.ok) {
|
|
1631
|
+
entry.error = 'the stale cookies could not be cleared: ' + (cookie.error || 'unknown reason');
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// (f) Start the host again — but look at the marker ONE last time: the
|
|
1635
|
+
// host may have finished its boot while the cleanup and the
|
|
1636
|
+
// restore ran, and a host that is up must not be started again.
|
|
1637
|
+
if (readCurrentReady()) {
|
|
1638
|
+
report.result = 'not-needed';
|
|
1639
|
+
report.ready = true;
|
|
1640
|
+
report.error = 'the host came up while the watchdog was working: it was NOT started again';
|
|
1641
|
+
break;
|
|
1642
|
+
}
|
|
1643
|
+
const relaunched = d.runRelaunch();
|
|
1644
|
+
report.relaunched = relaunched !== null && relaunched !== undefined && relaunched.ok === true;
|
|
1645
|
+
entry.relaunched = report.relaunched;
|
|
1646
|
+
if (!report.relaunched) {
|
|
1647
|
+
report.result = 'failed';
|
|
1648
|
+
// "I could not start it" is not "the rescue failed after trying":
|
|
1649
|
+
// the report says where it stood.
|
|
1650
|
+
report.stage = 'relaunch';
|
|
1651
|
+
report.error = relaunched && relaunched.error ? relaunched.error : 'the host could not be started again';
|
|
1652
|
+
entry.error = report.error;
|
|
1653
|
+
break;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// (g) Did it come up this time?
|
|
1657
|
+
const readyAgain = await waitForReady(readyDeps, pending.readyTimeoutMs);
|
|
1658
|
+
report.ready = readyAgain;
|
|
1659
|
+
entry.ready = readyAgain;
|
|
1660
|
+
if (readyAgain) {
|
|
1661
|
+
report.result = 'recovered';
|
|
1662
|
+
report.error = null;
|
|
1663
|
+
break;
|
|
1664
|
+
}
|
|
526
1665
|
report.result = 'failed';
|
|
527
|
-
report.error =
|
|
528
|
-
|
|
1666
|
+
report.error = 'the host did not come up after the restore';
|
|
1667
|
+
if (attempt < report.maxAttempts) {
|
|
1668
|
+
const wait = backoffMsFor(attempt);
|
|
1669
|
+
entry.backoffMs = wait;
|
|
1670
|
+
await d.sleep(wait);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
// The message of a run that ran out of attempts must SAY so: "failed"
|
|
1674
|
+
// alone would hide that the watchdog tried, and how often. It also has to
|
|
1675
|
+
// say that NOTHING ELSE WILL PICK IT UP: this is the last automatic step,
|
|
1676
|
+
// so a human has to look at the report. And it says WHICH copy was put
|
|
1677
|
+
// back and that the restore is not repeated — the two facts the reader
|
|
1678
|
+
// needs to decide what to do next.
|
|
1679
|
+
if (report.result === 'failed' && report.stage === 'flow' && report.relaunched === true) {
|
|
1680
|
+
report.error = 'the host did not come up after ' + report.attempts + ' attempt(s) of '
|
|
1681
|
+
+ report.maxAttempts + ' (last error: ' + String(report.error) + '): the copy '
|
|
1682
|
+
+ (report.restoredFrom === null ? '(unknown)' : String(report.restoredFrom))
|
|
1683
|
+
+ ' was restored ONCE and is not restored again — no further rollback will be attempted';
|
|
529
1684
|
}
|
|
530
|
-
|
|
531
|
-
const relaunched = d.runRelaunch();
|
|
532
|
-
report.relaunched = relaunched !== null && relaunched !== undefined && relaunched.ok === true;
|
|
533
|
-
if (!report.relaunched) {
|
|
1685
|
+
if (report.result === 'unknown') {
|
|
534
1686
|
report.result = 'failed';
|
|
535
|
-
report.error =
|
|
536
|
-
return report;
|
|
1687
|
+
report.error = 'the flow ended without an answer';
|
|
537
1688
|
}
|
|
538
|
-
// (7) Did it come up this time?
|
|
539
|
-
const readyAgain = await waitForReady(d, pending.readyTimeoutMs);
|
|
540
|
-
report.ready = readyAgain;
|
|
541
|
-
report.result = readyAgain ? 'recovered' : 'failed';
|
|
542
|
-
if (!readyAgain) report.error = 'the host did not come up after the restore';
|
|
543
1689
|
} finally {
|
|
544
1690
|
d.releaseLock();
|
|
545
1691
|
}
|
|
@@ -548,18 +1694,47 @@ export async function runWatchdogFlow({ pending, stateDir = '', deps = {} }) {
|
|
|
548
1694
|
report.error = error && error.message ? error.message : String(error);
|
|
549
1695
|
} finally {
|
|
550
1696
|
report.waitedMs = d.now() - startedAt;
|
|
551
|
-
//
|
|
1697
|
+
// ★ 收尾顺序是有理由的,不许调回去(2026-11-01 回归,由 14e9a6b 引入)。
|
|
1698
|
+
//
|
|
1699
|
+
// 为什么必须先写报告:最后一步 d.removeSchedule() 在 CLI 那条路上执行的是
|
|
1700
|
+
// `systemctl --user stop dsh-autorestart-watchdog.service` —— 那正是承载本
|
|
1701
|
+
// 进程的 service(见 scripts/watchdog.mjs 注进去的 removeSchedule)。service
|
|
1702
|
+
// 一停,systemd 就给这个进程发 SIGTERM,于是写在清理之后的报告永远到不了盘
|
|
1703
|
+
// 上:守夜人明明救成功了,客户打开报告看到的却还停在启动那一刻的 `armed`
|
|
1704
|
+
// (「武装好了,什么也没发生」)。这比没有守夜人更糟——下一次出问题,没人
|
|
1705
|
+
// 知道上一次是被救回来的。
|
|
1706
|
+
//
|
|
1707
|
+
// 所以顺序固定为:写报告 → 清 pending → 清理调度项(唯一可能终止自己的动作)。
|
|
1708
|
+
// test/watchdog-report-order.test.mjs 用「清理动作发生的那一刻,盘上必须已经
|
|
1709
|
+
// 是本次救援结果」守住这三行的顺序。
|
|
552
1710
|
try {
|
|
553
|
-
d.
|
|
554
|
-
} catch {
|
|
555
|
-
//
|
|
1711
|
+
d.writeReport(report);
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
// 报告写不出去是必须看得见的失败:stderr 一行日志 + 返回值上的字段,
|
|
1714
|
+
// CLI 用 reportWriteError 决定退出码。但不许因此跳过清理(调度项必须被
|
|
1715
|
+
// 清掉),所以这里只记录、不重抛。
|
|
1716
|
+
report.reportWritten = false;
|
|
1717
|
+
report.reportWriteError = error && error.message ? error.message : String(error);
|
|
1718
|
+
try {
|
|
1719
|
+
console.error(
|
|
1720
|
+
'dsh-autorestart watchdog: the report could not be written to '
|
|
1721
|
+
+ (stateDir === '' ? '(no state directory)' : stateDir)
|
|
1722
|
+
+ ': ' + report.reportWriteError,
|
|
1723
|
+
);
|
|
1724
|
+
} catch {
|
|
1725
|
+
// 连 stderr 都写不出去也不再抛:清理仍然要发生。
|
|
1726
|
+
}
|
|
556
1727
|
}
|
|
557
1728
|
try {
|
|
558
1729
|
if (stateDir !== '') clearPending(stateDir);
|
|
559
1730
|
} catch {
|
|
560
1731
|
// It is gone either way.
|
|
561
1732
|
}
|
|
562
|
-
|
|
1733
|
+
try {
|
|
1734
|
+
d.removeSchedule();
|
|
1735
|
+
} catch {
|
|
1736
|
+
// The scheduling item is cleaned up by the next run if this fails.
|
|
1737
|
+
}
|
|
563
1738
|
}
|
|
564
1739
|
return report;
|
|
565
1740
|
}
|