dsh-autorestart 0.8.1 → 0.8.3
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 +81 -0
- package/README.md +60 -10
- package/client.js +1038 -49
- package/docs/README.zh.md +8 -6
- package/index.js +538 -44
- package/lib/backup.js +279 -9
- package/lib/checkpoint.js +153 -0
- package/lib/diagnostics.js +18 -0
- package/lib/restart-phase.js +304 -0
- package/lib/watchdog-disarm.js +506 -0
- package/lib/watchdog.js +86 -0
- package/locale/en.json +27 -5
- package/locale/zh.json +27 -5
- package/package.json +1 -1
- package/scripts/drill-watchdog.mjs +135 -2
- package/scripts/watchdog.mjs +107 -0
package/lib/backup.js
CHANGED
|
@@ -204,6 +204,164 @@ function copyEntry(source, destination) {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Measure a tree so the copy can report a REAL total (files and bytes).
|
|
209
|
+
*
|
|
210
|
+
* The plugin tree of a desktop profile is ~150 MB; walking it costs a few tens
|
|
211
|
+
* of milliseconds and it is the only way to say "154 MB of 156 MB done" instead
|
|
212
|
+
* of making a number up.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} target
|
|
215
|
+
* @returns {{ files: number, bytes: number }}
|
|
216
|
+
*/
|
|
217
|
+
export function measureTree(target) {
|
|
218
|
+
return directorySize(target);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Copy a directory tree FILE BY FILE, reporting real progress and honouring a
|
|
223
|
+
* cancel request.
|
|
224
|
+
*
|
|
225
|
+
* `fs.cpSync` (what this used to be) copies 150 MB in one call: no progress, no
|
|
226
|
+
* cancel, and no way to tell a busy disk from a dead host. The loop below copies
|
|
227
|
+
* one file at a time, adds up the bytes that really landed and asks the caller
|
|
228
|
+
* between files whether it should stop.
|
|
229
|
+
*
|
|
230
|
+
* IT YIELDS TO THE EVENT LOOP on purpose: the copy runs inside the host
|
|
231
|
+
* process, and a copy that never yields blocks the very route the "cancel"
|
|
232
|
+
* button posts to — the user could not call it off even though the code says
|
|
233
|
+
* they can. Every `yieldEveryFiles` files (and every `yieldEveryMs`
|
|
234
|
+
* milliseconds) the loop waits one tick, so the server stays answerable and the
|
|
235
|
+
* progress reaches the interface while the copy is running.
|
|
236
|
+
*
|
|
237
|
+
* @param {{
|
|
238
|
+
* source: string,
|
|
239
|
+
* destination: string,
|
|
240
|
+
* onProgress?: Function|null,
|
|
241
|
+
* shouldCancel?: Function|null,
|
|
242
|
+
* progressEveryFiles?: number,
|
|
243
|
+
* yieldEveryFiles?: number,
|
|
244
|
+
* yieldEveryMs?: number,
|
|
245
|
+
* yieldToEventLoop?: Function|null,
|
|
246
|
+
* now?: Function,
|
|
247
|
+
* }} options
|
|
248
|
+
* @returns {Promise<{ ok: boolean, files: number, bytes: number, cancelled: boolean, error: string|null }>}
|
|
249
|
+
*/
|
|
250
|
+
export async function copyTreeWithProgress({
|
|
251
|
+
source,
|
|
252
|
+
destination,
|
|
253
|
+
onProgress = null,
|
|
254
|
+
shouldCancel = null,
|
|
255
|
+
progressEveryFiles = 25,
|
|
256
|
+
yieldEveryFiles = 40,
|
|
257
|
+
yieldEveryMs = 120,
|
|
258
|
+
yieldToEventLoop = null,
|
|
259
|
+
now = Date.now,
|
|
260
|
+
} = {}) {
|
|
261
|
+
const from = typeof source === 'string' ? source : '';
|
|
262
|
+
const into = typeof destination === 'string' ? destination : '';
|
|
263
|
+
if (from === '' || into === '') {
|
|
264
|
+
return { ok: false, files: 0, bytes: 0, cancelled: false, error: 'copyTreeWithProgress requires a source and a destination' };
|
|
265
|
+
}
|
|
266
|
+
const pause = typeof yieldToEventLoop === 'function'
|
|
267
|
+
? yieldToEventLoop
|
|
268
|
+
: () => new Promise((resolve) => { setImmediate(resolve); });
|
|
269
|
+
const clock = typeof now === 'function' ? now : Date.now;
|
|
270
|
+
let files = 0;
|
|
271
|
+
let bytes = 0;
|
|
272
|
+
let cancelled = false;
|
|
273
|
+
let error = null;
|
|
274
|
+
const tick = { files: 0, at: clock(), yielded: clock() };
|
|
275
|
+
const report = () => {
|
|
276
|
+
if (typeof onProgress !== 'function') return;
|
|
277
|
+
tick.at = clock();
|
|
278
|
+
try {
|
|
279
|
+
onProgress({ files, bytes });
|
|
280
|
+
} catch {
|
|
281
|
+
// A broken progress callback must never fail the backup itself.
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const walk = async (sourceDir, targetDir) => {
|
|
286
|
+
let entries;
|
|
287
|
+
try {
|
|
288
|
+
entries = fs.readdirSync(sourceDir, { withFileTypes: true });
|
|
289
|
+
} catch (failure) {
|
|
290
|
+
// A directory that cannot be read is skipped, exactly like before: a
|
|
291
|
+
// single unreadable plugin directory must not break the whole snapshot.
|
|
292
|
+
error = error === null ? (failure && failure.message ? failure.message : String(failure)) : error;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
297
|
+
} catch {
|
|
298
|
+
/* it may already exist */
|
|
299
|
+
}
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
if (typeof shouldCancel === 'function' && shouldCancel() === true) {
|
|
302
|
+
cancelled = true;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const childSource = path.join(sourceDir, entry.name);
|
|
306
|
+
const childTarget = path.join(targetDir, entry.name);
|
|
307
|
+
if (entry.isDirectory()) {
|
|
308
|
+
await walk(childSource, childTarget);
|
|
309
|
+
if (cancelled) return;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
fs.copyFileSync(childSource, childTarget);
|
|
314
|
+
files += 1;
|
|
315
|
+
try {
|
|
316
|
+
bytes += fs.statSync(childTarget).size;
|
|
317
|
+
} catch {
|
|
318
|
+
/* the size is advisory */
|
|
319
|
+
}
|
|
320
|
+
} catch (failure) {
|
|
321
|
+
error = error === null ? (failure && failure.message ? failure.message : String(failure)) : error;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
tick.files += 1;
|
|
325
|
+
const moment = clock();
|
|
326
|
+
if (tick.files >= progressEveryFiles || moment - tick.at >= 150) {
|
|
327
|
+
tick.files = 0;
|
|
328
|
+
report();
|
|
329
|
+
}
|
|
330
|
+
if (moment - tick.yielded >= yieldEveryMs || tick.files >= yieldEveryFiles) {
|
|
331
|
+
tick.yielded = moment;
|
|
332
|
+
// eslint-disable-next-line no-await-in-loop
|
|
333
|
+
await pause();
|
|
334
|
+
if (typeof shouldCancel === 'function' && shouldCancel() === true) {
|
|
335
|
+
cancelled = true;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
await walk(from, into);
|
|
343
|
+
report();
|
|
344
|
+
if (cancelled) return { ok: false, files, bytes, cancelled: true, error: null };
|
|
345
|
+
return { ok: true, files, bytes, cancelled: false, error };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Remove a half-written backup (a cancelled copy must not look like a snapshot).
|
|
350
|
+
*
|
|
351
|
+
* @param {string} dir
|
|
352
|
+
* @returns {boolean}
|
|
353
|
+
*/
|
|
354
|
+
export function removeBackupDir(dir) {
|
|
355
|
+
const target = typeof dir === 'string' ? dir.trim() : '';
|
|
356
|
+
if (target === '') return false;
|
|
357
|
+
try {
|
|
358
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
359
|
+
return true;
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
207
365
|
/**
|
|
208
366
|
* Keep only the newest `maxBackups` backup directories.
|
|
209
367
|
*
|
|
@@ -301,6 +459,11 @@ export function readManifest(backupDir) {
|
|
|
301
459
|
* rollback of "a plugin upgrade broke the boot" actually possible; without it only
|
|
302
460
|
* the configuration files are copied.
|
|
303
461
|
*
|
|
462
|
+
* THE COPY IS INTERRUPTIBLE AND MEASURED: `onProgress` is called with the files
|
|
463
|
+
* and bytes that really landed (plus the measured total), and `shouldCancel` is
|
|
464
|
+
* asked between files. A cancelled copy removes its own half-written snapshot,
|
|
465
|
+
* so a cancel never leaves something that looks like a usable backup.
|
|
466
|
+
*
|
|
304
467
|
* Never throws: a backup that cannot be written is reported as `{ ok: false }` so
|
|
305
468
|
* the caller can decide whether the restart may still proceed.
|
|
306
469
|
*
|
|
@@ -310,27 +473,36 @@ export function readManifest(backupDir) {
|
|
|
310
473
|
* includePlugins?: boolean,
|
|
311
474
|
* maxBackups?: number,
|
|
312
475
|
* now?: number,
|
|
476
|
+
* onProgress?: ((progress: { files: number, bytes: number, total: number, filesTotal: number }) => void)|null,
|
|
477
|
+
* shouldCancel?: (() => boolean)|null,
|
|
478
|
+
* measure?: ((profileDir: string, includePlugins: boolean) => { files: number, bytes: number })|null,
|
|
313
479
|
* }} options
|
|
314
|
-
* @returns {{ ok: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
|
|
480
|
+
* @returns {{ ok: boolean, cancelled: boolean, dir: string|null, stamp: string|null, at: number|null, atLocal: string|null, durationMs: number, bytes: number, totalBytes: number, fileCount: number, files: string[], includePlugins: boolean, pruned: string[], error: string|null }}
|
|
315
481
|
*/
|
|
316
|
-
export function createBackup({
|
|
482
|
+
export async function createBackup({
|
|
317
483
|
profileDir,
|
|
318
484
|
backupsDir,
|
|
319
485
|
includePlugins = true,
|
|
320
486
|
maxBackups = DEFAULT_MAX_BACKUPS,
|
|
321
487
|
now = Date.now(),
|
|
488
|
+
onProgress = null,
|
|
489
|
+
shouldCancel = null,
|
|
490
|
+
measure = null,
|
|
322
491
|
} = {}) {
|
|
323
492
|
const profile = typeof profileDir === 'string' ? profileDir.trim() : '';
|
|
324
493
|
const root = typeof backupsDir === 'string' ? backupsDir.trim() : '';
|
|
325
494
|
const startedAt = Date.now();
|
|
326
495
|
const base = {
|
|
327
496
|
ok: false,
|
|
497
|
+
cancelled: false,
|
|
328
498
|
dir: null,
|
|
329
499
|
stamp: null,
|
|
330
500
|
at: null,
|
|
331
501
|
atLocal: null,
|
|
332
502
|
durationMs: 0,
|
|
333
503
|
bytes: 0,
|
|
504
|
+
totalBytes: 0,
|
|
505
|
+
fileCount: 0,
|
|
334
506
|
files: [],
|
|
335
507
|
includePlugins: includePlugins === true,
|
|
336
508
|
pruned: [],
|
|
@@ -340,37 +512,131 @@ export function createBackup({
|
|
|
340
512
|
if (root === '') return { ...base, error: 'createBackup requires backupsDir' };
|
|
341
513
|
if (!fs.existsSync(profile)) return { ...base, error: 'the profile directory does not exist' };
|
|
342
514
|
|
|
515
|
+
// MEASURE FIRST: the progress report needs a denominator that is not made up.
|
|
516
|
+
const measured = (() => {
|
|
517
|
+
try {
|
|
518
|
+
if (typeof measure === 'function') {
|
|
519
|
+
const answer = measure(profile, includePlugins === true);
|
|
520
|
+
return {
|
|
521
|
+
files: Math.max(0, Number(answer && answer.files) || 0),
|
|
522
|
+
bytes: Math.max(0, Number(answer && answer.bytes) || 0),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
const total = { files: 0, bytes: 0 };
|
|
526
|
+
for (const name of CONFIG_FILES) {
|
|
527
|
+
try {
|
|
528
|
+
total.bytes += fs.statSync(path.join(profile, name)).size;
|
|
529
|
+
total.files += 1;
|
|
530
|
+
} catch {
|
|
531
|
+
/* a missing config file is not a problem */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (includePlugins === true) {
|
|
535
|
+
const tree = directorySize(path.join(profile, PLUGINS_DIR));
|
|
536
|
+
total.bytes += tree.bytes;
|
|
537
|
+
total.files += tree.files;
|
|
538
|
+
}
|
|
539
|
+
return total;
|
|
540
|
+
} catch {
|
|
541
|
+
return { files: 0, bytes: 0 };
|
|
542
|
+
}
|
|
543
|
+
})();
|
|
544
|
+
|
|
343
545
|
const stamp = backupStamp(now);
|
|
344
546
|
const dir = path.join(root, stamp);
|
|
345
547
|
const files = [];
|
|
346
548
|
let bytes = 0;
|
|
549
|
+
let fileCount = 0;
|
|
550
|
+
let cancelled = false;
|
|
551
|
+
const isCancelled = () => {
|
|
552
|
+
if (cancelled === true) return true;
|
|
553
|
+
if (typeof shouldCancel !== 'function') return false;
|
|
554
|
+
try {
|
|
555
|
+
if (shouldCancel() === true) {
|
|
556
|
+
cancelled = true;
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
} catch {
|
|
560
|
+
// A broken cancel callback must not abort anything.
|
|
561
|
+
}
|
|
562
|
+
return false;
|
|
563
|
+
};
|
|
564
|
+
const report = (approx) => {
|
|
565
|
+
if (typeof onProgress !== 'function') return;
|
|
566
|
+
try {
|
|
567
|
+
onProgress({
|
|
568
|
+
files: fileCount,
|
|
569
|
+
bytes,
|
|
570
|
+
total: measured.bytes,
|
|
571
|
+
filesTotal: measured.files,
|
|
572
|
+
// The estimate is passed along so the caller does not have to redo the
|
|
573
|
+
// arithmetic; it is null until there is a real measurement to divide.
|
|
574
|
+
...(approx ? { approximate: true } : {}),
|
|
575
|
+
});
|
|
576
|
+
} catch {
|
|
577
|
+
// Progress reporting never fails a backup.
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
347
581
|
try {
|
|
348
582
|
fs.mkdirSync(path.join(dir, CONFIG_DIR), { recursive: true });
|
|
349
583
|
} catch (error) {
|
|
350
|
-
return { ...base, stamp, error: 'could not create the backup directory: ' + (error && error.message ? error.message : String(error)) };
|
|
584
|
+
return { ...base, stamp, totalBytes: measured.bytes, error: 'could not create the backup directory: ' + (error && error.message ? error.message : String(error)) };
|
|
351
585
|
}
|
|
352
586
|
|
|
587
|
+
let configBytes = 0;
|
|
353
588
|
for (const name of CONFIG_FILES) {
|
|
589
|
+
if (isCancelled()) break;
|
|
354
590
|
const source = path.join(profile, name);
|
|
355
591
|
if (!fs.existsSync(source)) continue;
|
|
356
592
|
if (copyEntry(source, path.join(dir, CONFIG_DIR, name))) {
|
|
357
593
|
files.push(path.join(CONFIG_DIR, name));
|
|
594
|
+
fileCount += 1;
|
|
358
595
|
try {
|
|
359
|
-
|
|
596
|
+
configBytes += fs.statSync(path.join(dir, CONFIG_DIR, name)).size;
|
|
597
|
+
bytes = configBytes;
|
|
360
598
|
} catch {
|
|
361
599
|
// Size reporting is advisory only.
|
|
362
600
|
}
|
|
601
|
+
report(false);
|
|
363
602
|
}
|
|
364
603
|
}
|
|
365
604
|
|
|
366
|
-
if (includePlugins === true) {
|
|
605
|
+
if (!cancelled && includePlugins === true) {
|
|
367
606
|
const source = path.join(profile, PLUGINS_DIR);
|
|
368
|
-
if (fs.existsSync(source)
|
|
369
|
-
|
|
370
|
-
|
|
607
|
+
if (fs.existsSync(source)) {
|
|
608
|
+
const copied = await copyTreeWithProgress({
|
|
609
|
+
source,
|
|
610
|
+
destination: path.join(dir, PLUGINS_DIR),
|
|
611
|
+
shouldCancel: isCancelled,
|
|
612
|
+
onProgress: (progress) => {
|
|
613
|
+
fileCount = files.length + progress.files;
|
|
614
|
+
bytes = configBytes + progress.bytes;
|
|
615
|
+
report(false);
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
if (copied.cancelled) {
|
|
619
|
+
cancelled = true;
|
|
620
|
+
} else if (copied.ok || copied.files > 0) {
|
|
621
|
+
files.push(PLUGINS_DIR);
|
|
622
|
+
fileCount = files.length + copied.files;
|
|
623
|
+
bytes = configBytes + copied.bytes;
|
|
624
|
+
}
|
|
371
625
|
}
|
|
372
626
|
}
|
|
373
627
|
|
|
628
|
+
if (cancelled) {
|
|
629
|
+
removeBackupDir(dir);
|
|
630
|
+
return {
|
|
631
|
+
...base,
|
|
632
|
+
cancelled: true,
|
|
633
|
+
stamp,
|
|
634
|
+
totalBytes: measured.bytes,
|
|
635
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
636
|
+
error: 'the backup was cancelled',
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
374
640
|
const manifest = {
|
|
375
641
|
stamp,
|
|
376
642
|
at: Number(now),
|
|
@@ -381,11 +647,13 @@ export function createBackup({
|
|
|
381
647
|
includePlugins: includePlugins === true,
|
|
382
648
|
files,
|
|
383
649
|
bytes,
|
|
650
|
+
totalBytes: measured.bytes,
|
|
651
|
+
fileCount,
|
|
384
652
|
};
|
|
385
653
|
try {
|
|
386
654
|
fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2), 'utf8');
|
|
387
655
|
} catch (error) {
|
|
388
|
-
return { ...base, stamp, files, bytes, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
|
|
656
|
+
return { ...base, stamp, files, bytes, totalBytes: measured.bytes, fileCount, error: 'could not write the backup manifest: ' + (error && error.message ? error.message : String(error)) };
|
|
389
657
|
}
|
|
390
658
|
|
|
391
659
|
const pruned = pruneBackups(root, maxBackups);
|
|
@@ -398,6 +666,8 @@ export function createBackup({
|
|
|
398
666
|
atLocal: manifest.atLocal,
|
|
399
667
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
400
668
|
bytes,
|
|
669
|
+
totalBytes: measured.bytes,
|
|
670
|
+
fileCount,
|
|
401
671
|
files,
|
|
402
672
|
pruned,
|
|
403
673
|
};
|
package/lib/checkpoint.js
CHANGED
|
@@ -30,6 +30,7 @@ import fs from 'node:fs';
|
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
32
32
|
import { PHASES, selectRecoverableSessions } from './lifecycle.js';
|
|
33
|
+
import { RESTART_PHASES } from './restart-phase.js';
|
|
33
34
|
|
|
34
35
|
/** Current on-disk schema version. */
|
|
35
36
|
export const STATE_VERSION = 1;
|
|
@@ -101,6 +102,7 @@ const TOP_LEVEL_KEYS = new Set([
|
|
|
101
102
|
'bootStartedAt',
|
|
102
103
|
'generation',
|
|
103
104
|
'phase',
|
|
105
|
+
'restartPhase',
|
|
104
106
|
'restart',
|
|
105
107
|
'sessions',
|
|
106
108
|
'parkedGoals',
|
|
@@ -173,6 +175,11 @@ export function createEmptyState(now = Date.now()) {
|
|
|
173
175
|
bootStartedAt: null,
|
|
174
176
|
generation: 0,
|
|
175
177
|
phase: 'idle',
|
|
178
|
+
// WHICH STAGE THE RESTART IS IN (see lib/restart-phase.js): null when no
|
|
179
|
+
// restart is under way, otherwise `{ phase, startedAt, at, progress }`.
|
|
180
|
+
// It lives in the checkpoint (not in memory) so the interface can read it
|
|
181
|
+
// out of the state route from the very first moment after the click.
|
|
182
|
+
restartPhase: null,
|
|
176
183
|
restart: null,
|
|
177
184
|
sessions: {},
|
|
178
185
|
seenFeatureVersions: {},
|
|
@@ -182,6 +189,146 @@ export function createEmptyState(now = Date.now()) {
|
|
|
182
189
|
};
|
|
183
190
|
}
|
|
184
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Coerce the recorded restart stage into the documented shape.
|
|
194
|
+
*
|
|
195
|
+
* A record that does not describe one of the four stages is dropped: a broken
|
|
196
|
+
* stage must never leave a spinner on screen for ever.
|
|
197
|
+
*
|
|
198
|
+
* @param {unknown} raw
|
|
199
|
+
* @param {number} now
|
|
200
|
+
* @returns {{ record: object|null, changed: boolean }}
|
|
201
|
+
*/
|
|
202
|
+
function normalizeRestartPhase(raw, now) {
|
|
203
|
+
if (raw == null) return { record: null, changed: false };
|
|
204
|
+
if (!isObject(raw)) return { record: null, changed: true };
|
|
205
|
+
if (!RESTART_PHASES.includes(raw.phase)) return { record: null, changed: true };
|
|
206
|
+
const startedAt = isFiniteNumber(raw.startedAt) ? raw.startedAt : now;
|
|
207
|
+
const at = isFiniteNumber(raw.at) ? raw.at : startedAt;
|
|
208
|
+
let progress = null;
|
|
209
|
+
let changed = !isFiniteNumber(raw.startedAt) || !isFiniteNumber(raw.at);
|
|
210
|
+
if (isObject(raw.progress)) {
|
|
211
|
+
progress = {
|
|
212
|
+
files: isFiniteNumber(raw.progress.files) ? Math.max(0, Math.trunc(raw.progress.files)) : 0,
|
|
213
|
+
bytes: isFiniteNumber(raw.progress.bytes) ? Math.max(0, Math.trunc(raw.progress.bytes)) : 0,
|
|
214
|
+
total: isFiniteNumber(raw.progress.total) ? Math.max(0, Math.trunc(raw.progress.total)) : 0,
|
|
215
|
+
filesTotal: isFiniteNumber(raw.progress.filesTotal) ? Math.max(0, Math.trunc(raw.progress.filesTotal)) : 0,
|
|
216
|
+
at: isFiniteNumber(raw.progress.at) ? raw.progress.at : at,
|
|
217
|
+
};
|
|
218
|
+
} else if (raw.progress != null) {
|
|
219
|
+
changed = true;
|
|
220
|
+
}
|
|
221
|
+
const record = {
|
|
222
|
+
phase: raw.phase,
|
|
223
|
+
startedAt,
|
|
224
|
+
at,
|
|
225
|
+
progress,
|
|
226
|
+
// HOW BIG THE PRE-RESTART BACKUP WAS. It is kept across the stage change on
|
|
227
|
+
// purpose: a hosted profile can be 92 KB and the copy takes ~5 ms, so a page
|
|
228
|
+
// that polls every 2 s would otherwise never learn that a backup happened
|
|
229
|
+
// (user report, 2026-10-29). null = nothing was measured.
|
|
230
|
+
snapshotBytes: isFiniteNumber(raw.snapshotBytes) ? Math.max(0, Math.trunc(raw.snapshotBytes)) : null,
|
|
231
|
+
blocked: isFiniteNumber(raw.blocked) ? Math.max(0, Math.trunc(raw.blocked)) : 0,
|
|
232
|
+
};
|
|
233
|
+
return { record, changed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Record the stage the restart is in — the ONE thing the interface reads.
|
|
238
|
+
*
|
|
239
|
+
* Writing it is deliberately cheap and synchronous: it is called the moment the
|
|
240
|
+
* user clicks, before any backup starts, so the first stage is on disk in
|
|
241
|
+
* milliseconds rather than after ten seconds of copying.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} state the checkpoint state (mutated in place)
|
|
244
|
+
* @param {{ phase: string, now?: number, blocked?: number, restartStage?: boolean }} options
|
|
245
|
+
* @returns {object|null} the recorded stage
|
|
246
|
+
*/
|
|
247
|
+
export function setRestartPhase(state, { phase, now = Date.now(), blocked = 0, restartStage = true } = {}) {
|
|
248
|
+
if (!isObject(state)) return null;
|
|
249
|
+
if (!RESTART_PHASES.includes(phase)) return state.restartPhase ?? null;
|
|
250
|
+
const moment = isFiniteNumber(now) ? now : Date.now();
|
|
251
|
+
const previous = isObject(state.restartPhase) ? state.restartPhase : null;
|
|
252
|
+
// A new restart starts its own clock; a stage change inside the same restart
|
|
253
|
+
// keeps the start time, so "elapsed" is the whole restart and not one stage.
|
|
254
|
+
const sameRestart = restartStage && previous !== null && RESTART_PHASES.includes(previous.phase)
|
|
255
|
+
&& !(phase === 'waiting' && previous.phase !== 'waiting');
|
|
256
|
+
const startedAt = sameRestart ? previous.startedAt : moment;
|
|
257
|
+
// THE SIZE OF THE SNAPSHOT, measured once and then CARRIED: the live byte
|
|
258
|
+
// counter while the copy runs, the final size afterwards. A 92 KB profile is
|
|
259
|
+
// copied in ~5 ms, so this number is the only thing a polling interface can
|
|
260
|
+
// still say about a backup that really happened (user report, 2026-10-29).
|
|
261
|
+
const measuredBytes = previous !== null && isFiniteNumber(previous.snapshotBytes)
|
|
262
|
+
? Math.max(0, Math.trunc(previous.snapshotBytes))
|
|
263
|
+
: (previous !== null && isObject(previous.progress) && isFiniteNumber(previous.progress.bytes)
|
|
264
|
+
? Math.max(0, Math.trunc(previous.progress.bytes))
|
|
265
|
+
: null);
|
|
266
|
+
const record = {
|
|
267
|
+
phase,
|
|
268
|
+
startedAt,
|
|
269
|
+
at: moment,
|
|
270
|
+
// The progress of a previous stage is meaningless in the next one: the
|
|
271
|
+
// snapshot numbers must not be shown while the host is restarting.
|
|
272
|
+
progress: phase === 'snapshotting' && previous !== null && previous.phase === 'snapshotting'
|
|
273
|
+
? previous.progress
|
|
274
|
+
: null,
|
|
275
|
+
// A NEW restart starts with nothing measured; a stage change inside one
|
|
276
|
+
// restart keeps the size it already has.
|
|
277
|
+
snapshotBytes: sameRestart ? measuredBytes : null,
|
|
278
|
+
blocked: isFiniteNumber(blocked) ? Math.max(0, Math.trunc(blocked)) : 0,
|
|
279
|
+
};
|
|
280
|
+
state.restartPhase = record;
|
|
281
|
+
state.updatedAt = moment;
|
|
282
|
+
return record;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Update the REAL snapshot progress (files and bytes that were copied).
|
|
287
|
+
*
|
|
288
|
+
* @param {object} state the checkpoint state (mutated in place)
|
|
289
|
+
* @param {{ files?: number, bytes?: number, total?: number, filesTotal?: number, now?: number }} progress
|
|
290
|
+
* @returns {object|null} the recorded stage
|
|
291
|
+
*/
|
|
292
|
+
export function setRestartProgress(state, { files = 0, bytes = 0, total = 0, filesTotal = 0, now = Date.now() } = {}) {
|
|
293
|
+
if (!isObject(state)) return null;
|
|
294
|
+
const moment = isFiniteNumber(now) ? now : Date.now();
|
|
295
|
+
const previous = isObject(state.restartPhase) ? state.restartPhase : null;
|
|
296
|
+
const number = (value) => (isFiniteNumber(value) ? Math.max(0, Math.trunc(value)) : 0);
|
|
297
|
+
state.restartPhase = {
|
|
298
|
+
phase: 'snapshotting',
|
|
299
|
+
startedAt: previous !== null && isFiniteNumber(previous.startedAt) ? previous.startedAt : moment,
|
|
300
|
+
at: moment,
|
|
301
|
+
progress: {
|
|
302
|
+
files: number(files),
|
|
303
|
+
bytes: number(bytes),
|
|
304
|
+
total: number(total),
|
|
305
|
+
filesTotal: number(filesTotal),
|
|
306
|
+
at: moment,
|
|
307
|
+
},
|
|
308
|
+
// The live byte counter IS the size of the snapshot so far: keeping it here
|
|
309
|
+
// means the number survives the moment the copy ends.
|
|
310
|
+
snapshotBytes: number(bytes),
|
|
311
|
+
blocked: previous !== null && isFiniteNumber(previous.blocked) ? previous.blocked : 0,
|
|
312
|
+
};
|
|
313
|
+
state.updatedAt = moment;
|
|
314
|
+
return state.restartPhase;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Forget the stage (the restart was cancelled, or it failed before exiting).
|
|
319
|
+
*
|
|
320
|
+
* @param {object} state
|
|
321
|
+
* @param {{ now?: number }} [options]
|
|
322
|
+
* @returns {boolean} whether a stage was cleared
|
|
323
|
+
*/
|
|
324
|
+
export function clearRestartPhase(state, { now = Date.now() } = {}) {
|
|
325
|
+
if (!isObject(state)) return false;
|
|
326
|
+
if (state.restartPhase == null) return false;
|
|
327
|
+
state.restartPhase = null;
|
|
328
|
+
state.updatedAt = isFiniteNumber(now) ? now : Date.now();
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
185
332
|
/**
|
|
186
333
|
* Normalize the record of the one-time questions.
|
|
187
334
|
*
|
|
@@ -634,6 +781,12 @@ export function normalizeState(raw, now = Date.now()) {
|
|
|
634
781
|
if (typeof raw.phase === 'string' && PHASES.includes(raw.phase)) state.phase = raw.phase;
|
|
635
782
|
else if (raw.phase != null) repaired = true;
|
|
636
783
|
|
|
784
|
+
if (raw.restartPhase != null) {
|
|
785
|
+
const recorded = normalizeRestartPhase(raw.restartPhase, now);
|
|
786
|
+
state.restartPhase = recorded.record;
|
|
787
|
+
if (recorded.changed) repaired = true;
|
|
788
|
+
}
|
|
789
|
+
|
|
637
790
|
if (isFiniteNumber(raw.updatedAt)) state.updatedAt = raw.updatedAt;
|
|
638
791
|
else if (raw.updatedAt != null) repaired = true;
|
|
639
792
|
|
package/lib/diagnostics.js
CHANGED
|
@@ -181,6 +181,24 @@ export function buildDiagnostics(payload = {}) {
|
|
|
181
181
|
lines.push('watchdog available: ' + (field(watchdog, 'available', false) === true ? 'yes' : 'no'))
|
|
182
182
|
lines.push('watchdog last result: ' + redactSecrets(field(watchdogReport, 'result', 'never ran')))
|
|
183
183
|
lines.push('watchdog last tier: ' + redactSecrets(field(watchdogReport, 'tier', 'unknown')))
|
|
184
|
+
// THE DISARM: did the host stand the watchdog down? A failed disarm leaves an
|
|
185
|
+
// armed one behind (the 0.8.1 incident), so its outcome and the first error
|
|
186
|
+
// belong in the text the user can hand to us.
|
|
187
|
+
const disarm = field(source, 'watchdogDisarm', null)
|
|
188
|
+
if (disarm && typeof disarm === 'object') {
|
|
189
|
+
lines.push('watchdog disarm: ' + (field(disarm, 'ok', false) === true ? 'ok' : 'FAILED'))
|
|
190
|
+
lines.push('watchdog disarm source: ' + redactSecrets(field(disarm, 'source', 'unknown')))
|
|
191
|
+
lines.push('watchdog disarm attempts: ' + redactSecrets(String(field(disarm, 'attempts', 0))))
|
|
192
|
+
lines.push('watchdog disarm pending left: ' + (field(disarm, 'pending', false) === true ? 'yes' : 'no'))
|
|
193
|
+
const disarmError = field(disarm, 'error', null)
|
|
194
|
+
if (disarmError && typeof disarmError === 'object') {
|
|
195
|
+
lines.push('watchdog disarm error: '
|
|
196
|
+
+ redactSecrets(field(disarmError, 'name', 'error'))
|
|
197
|
+
+ ': ' + redactSecrets(field(disarmError, 'message', '')))
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
lines.push('watchdog disarm: never ran')
|
|
201
|
+
}
|
|
184
202
|
const stateDir = truncatePath(field(source, 'stateDir', ''))
|
|
185
203
|
if (stateDir !== '') lines.push('dsh home: ' + redactSecrets(stateDir))
|
|
186
204
|
lines.push('--- end ---')
|