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/index.js
CHANGED
|
@@ -49,9 +49,17 @@ import {
|
|
|
49
49
|
setHandoff,
|
|
50
50
|
setRecoveryOutcome,
|
|
51
51
|
setPhase,
|
|
52
|
+
setRestartPhase,
|
|
53
|
+
setRestartProgress,
|
|
54
|
+
clearRestartPhase,
|
|
52
55
|
stampResumedGoal,
|
|
53
56
|
writeState,
|
|
54
57
|
} from './lib/checkpoint.js'
|
|
58
|
+
import {
|
|
59
|
+
cancelRefusalReason,
|
|
60
|
+
describeRestartPhase,
|
|
61
|
+
RESTART_PHASES,
|
|
62
|
+
} from './lib/restart-phase.js'
|
|
55
63
|
import {
|
|
56
64
|
bootPlan,
|
|
57
65
|
composeHandoff,
|
|
@@ -117,13 +125,18 @@ import {
|
|
|
117
125
|
} from './lib/watchdog-tier.js'
|
|
118
126
|
import {
|
|
119
127
|
buildPending,
|
|
128
|
+
clearPending,
|
|
120
129
|
clearReady,
|
|
121
|
-
|
|
130
|
+
readPending,
|
|
122
131
|
readReport,
|
|
123
132
|
writePending,
|
|
124
|
-
writeReady,
|
|
125
133
|
writeReport,
|
|
126
134
|
} from './lib/watchdog.js'
|
|
135
|
+
import {
|
|
136
|
+
disarmWatchdog as runDisarmWatchdog,
|
|
137
|
+
markReady,
|
|
138
|
+
readDisarmRecord,
|
|
139
|
+
} from './lib/watchdog-disarm.js'
|
|
127
140
|
import {
|
|
128
141
|
ARBITERS,
|
|
129
142
|
planWatchdogArbiter,
|
|
@@ -374,7 +387,7 @@ function snapGraceChoice(value, fallback = DEFAULT_RESTART_GRACE_MS) {
|
|
|
374
387
|
* `pluginVersion()`), but a bundled installation may not ship that file — so
|
|
375
388
|
* the constant below is the last resort. A test keeps the two in sync.
|
|
376
389
|
*/
|
|
377
|
-
const PLUGIN_VERSION_FALLBACK = '0.8.
|
|
390
|
+
const PLUGIN_VERSION_FALLBACK = '0.8.3'
|
|
378
391
|
|
|
379
392
|
/**
|
|
380
393
|
* The version of the graceful-shutdown FEATURE, not the version of the package.
|
|
@@ -1398,12 +1411,16 @@ function backupsDirFor(dir) {
|
|
|
1398
1411
|
* error: string|null,
|
|
1399
1412
|
* }}
|
|
1400
1413
|
*/
|
|
1401
|
-
export function takePreRestartSnapshot({
|
|
1414
|
+
export async function takePreRestartSnapshot({
|
|
1402
1415
|
config = {},
|
|
1403
1416
|
stateDir = '',
|
|
1404
1417
|
log = console,
|
|
1405
1418
|
profile = null,
|
|
1406
1419
|
createBackup: createBackupFn = createBackup,
|
|
1420
|
+
// The REAL progress of the copy, and the way out of it: both are handed to
|
|
1421
|
+
// the backup itself, so the numbers on screen are the numbers on disk.
|
|
1422
|
+
onProgress = null,
|
|
1423
|
+
shouldCancel = null,
|
|
1407
1424
|
} = {}) {
|
|
1408
1425
|
const startedAt = Date.now()
|
|
1409
1426
|
const info = profile && typeof profile === 'object'
|
|
@@ -1427,17 +1444,33 @@ export function takePreRestartSnapshot({
|
|
|
1427
1444
|
}
|
|
1428
1445
|
let backup = null
|
|
1429
1446
|
try {
|
|
1430
|
-
backup = createBackupFn({
|
|
1447
|
+
backup = await createBackupFn({
|
|
1431
1448
|
profileDir,
|
|
1432
1449
|
backupsDir: backupsDirFor(stateDir),
|
|
1433
1450
|
includePlugins: config.backupIncludePlugins !== false,
|
|
1434
1451
|
maxBackups: config.maxBackups,
|
|
1452
|
+
onProgress: typeof onProgress === 'function' ? onProgress : null,
|
|
1453
|
+
shouldCancel: typeof shouldCancel === 'function' ? shouldCancel : null,
|
|
1435
1454
|
})
|
|
1436
1455
|
} catch (error) {
|
|
1437
1456
|
// A backup that throws must not take the restart down with it.
|
|
1438
1457
|
backup = { ok: false, error: String(error) }
|
|
1439
1458
|
}
|
|
1440
1459
|
const durationMs = Math.max(0, Date.now() - startedAt)
|
|
1460
|
+
if (backup && backup.cancelled === true) {
|
|
1461
|
+
// The user called it off: the half-written snapshot was already removed by
|
|
1462
|
+
// the copy loop, and there is nothing to report as a rollback point.
|
|
1463
|
+
log.warn('the pre-restart backup was cancelled by the user', { durationMs })
|
|
1464
|
+
return {
|
|
1465
|
+
backup,
|
|
1466
|
+
profileDir,
|
|
1467
|
+
profile: info,
|
|
1468
|
+
requested: true,
|
|
1469
|
+
durationMs,
|
|
1470
|
+
cancelled: true,
|
|
1471
|
+
error: null,
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1441
1474
|
if (backup && backup.ok === true) {
|
|
1442
1475
|
log.warn('pre-restart backup written', {
|
|
1443
1476
|
dir: backup.dir,
|
|
@@ -1645,6 +1678,17 @@ export function createRuntime(ctx, config, options = {}) {
|
|
|
1645
1678
|
let recoverTimer
|
|
1646
1679
|
let disposed = false
|
|
1647
1680
|
let lastPersistError
|
|
1681
|
+
/**
|
|
1682
|
+
* THE LAST DISARM of the watchdog: the record of "the host is up, so nothing
|
|
1683
|
+
* may roll it back" (see lib/watchdog-disarm.js). It is kept on `runtime` so
|
|
1684
|
+
* the settings page and the diagnostics can show a FAILED disarm instead of
|
|
1685
|
+
* the silence that caused the 0.8.2 incident.
|
|
1686
|
+
*/
|
|
1687
|
+
let watchdogDisarm = readDisarmRecord(path.dirname(stateFile))
|
|
1688
|
+
/** The disarm that is currently running (one at a time; null when idle). */
|
|
1689
|
+
let disarmInFlight = null
|
|
1690
|
+
/** A disarm was asked for while one was running: run once more afterwards. */
|
|
1691
|
+
let disarmPendingAgain = false
|
|
1648
1692
|
/**
|
|
1649
1693
|
* The tool registrations that failed. A failed registration is recorded and
|
|
1650
1694
|
* logged, never thrown: the plugin still loads and the other tools still work.
|
|
@@ -2605,6 +2649,96 @@ const titleOfSummary = (item) => {
|
|
|
2605
2649
|
*/
|
|
2606
2650
|
const stateDirOf = () => path.dirname(stateFile)
|
|
2607
2651
|
|
|
2652
|
+
/**
|
|
2653
|
+
* THE STAGE LINE of a restart ("waiting / backing up / restarting / back").
|
|
2654
|
+
*
|
|
2655
|
+
* It is written ONCE per stage change and persisted IMMEDIATELY, because the
|
|
2656
|
+
* whole point of it is the first second after the click: a stage that reaches
|
|
2657
|
+
* the interface only after the 156 MB snapshot would tell the user nothing
|
|
2658
|
+
* about the ten seconds they spent wondering whether the button worked.
|
|
2659
|
+
*
|
|
2660
|
+
* @param {string} phase one of RESTART_PHASES
|
|
2661
|
+
* @param {{ blocked?: number, now?: number }} [options]
|
|
2662
|
+
* @returns {object|null} the recorded stage
|
|
2663
|
+
*/
|
|
2664
|
+
const markRestartStage = (phase, { blocked = 0, now = Date.now() } = {}) => {
|
|
2665
|
+
if (!RESTART_PHASES.includes(phase)) return null
|
|
2666
|
+
const record = setRestartPhase(state, { phase, blocked, now })
|
|
2667
|
+
persist()
|
|
2668
|
+
return record
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* Record the REAL progress of the running snapshot (files and bytes).
|
|
2673
|
+
*
|
|
2674
|
+
* The copy loop calls this between files; the checkpoint is written with a
|
|
2675
|
+
* small throttle, because a state write per copied file would slow the copy
|
|
2676
|
+
* down for no gain. The in-memory record is always current, so a `state=1`
|
|
2677
|
+
* poll that arrives between two writes still sees the newest numbers.
|
|
2678
|
+
*
|
|
2679
|
+
* @param {{ files?: number, bytes?: number, total?: number, filesTotal?: number, now?: number }} progress
|
|
2680
|
+
*/
|
|
2681
|
+
let lastProgressPersist = 0
|
|
2682
|
+
const recordSnapshotProgress = (progress) => {
|
|
2683
|
+
const now = Date.now()
|
|
2684
|
+
const record = setRestartProgress(state, { ...progress, now })
|
|
2685
|
+
if (now - lastProgressPersist >= 250) {
|
|
2686
|
+
lastProgressPersist = now
|
|
2687
|
+
persist()
|
|
2688
|
+
}
|
|
2689
|
+
return record
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
/** Forget the stage (cancel, or a restart that could not be started). */
|
|
2693
|
+
const clearRestartStage = () => {
|
|
2694
|
+
if (clearRestartPhase(state)) persist()
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/**
|
|
2698
|
+
* The stage as every surface shows it (state route, health route, client).
|
|
2699
|
+
*
|
|
2700
|
+
* @returns {object} the described stage (always an object, never null)
|
|
2701
|
+
*/
|
|
2702
|
+
const restartStageInfo = () => {
|
|
2703
|
+
const record = state.restartPhase
|
|
2704
|
+
const blocked = (() => {
|
|
2705
|
+
try {
|
|
2706
|
+
return countBlockerTasks(gate().blockedBy)
|
|
2707
|
+
} catch {
|
|
2708
|
+
return 0
|
|
2709
|
+
}
|
|
2710
|
+
})()
|
|
2711
|
+
return describeRestartPhase({
|
|
2712
|
+
phase: record && typeof record === 'object' ? record.phase : 'idle',
|
|
2713
|
+
startedAt: record && typeof record === 'object' ? record.startedAt : null,
|
|
2714
|
+
at: record && typeof record === 'object' ? record.at : null,
|
|
2715
|
+
progress: record && typeof record === 'object' ? record.progress : null,
|
|
2716
|
+
blocking: record && typeof record === 'object' && Number.isFinite(record.blocked) ? record.blocked : blocked,
|
|
2717
|
+
// The measured size of the pre-restart snapshot: it survives the stage
|
|
2718
|
+
// change, so a page that missed the copy can still report it.
|
|
2719
|
+
snapshotBytes: record && typeof record === 'object' ? record.snapshotBytes : null,
|
|
2720
|
+
now: Date.now(),
|
|
2721
|
+
})
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
/**
|
|
2725
|
+
* A cancel request that arrived while the snapshot was being written.
|
|
2726
|
+
*
|
|
2727
|
+
* The backup loop asks this between files: the copy stops, its half-written
|
|
2728
|
+
* directory is removed, and the host never restarts. A flag (not a thrown
|
|
2729
|
+
* error) because the loop is synchronous and must finish its bookkeeping.
|
|
2730
|
+
*/
|
|
2731
|
+
let snapshotCancelRequested = false
|
|
2732
|
+
const requestSnapshotCancel = () => {
|
|
2733
|
+
snapshotCancelRequested = true
|
|
2734
|
+
return true
|
|
2735
|
+
}
|
|
2736
|
+
const snapshotCancelled = () => snapshotCancelRequested === true
|
|
2737
|
+
const resetSnapshotCancel = () => {
|
|
2738
|
+
snapshotCancelRequested = false
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
|
|
2608
2742
|
/**
|
|
2609
2743
|
* The durable record of the last restart: the snapshot that was taken, the
|
|
2610
2744
|
* verdict of the new instance and its four boot checks.
|
|
@@ -2616,17 +2750,14 @@ const titleOfSummary = (item) => {
|
|
|
2616
2750
|
/**
|
|
2617
2751
|
* The version of THIS plugin (the one the user runs).
|
|
2618
2752
|
*
|
|
2753
|
+
* It delegates to the module-level reader ON PURPOSE: a local copy used to
|
|
2754
|
+
* look for `../package.json`, which is one directory ABOVE the plugin, so
|
|
2755
|
+
* every answer was `unknown` — and the health route reported that instead of
|
|
2756
|
+
* the version that is really loaded.
|
|
2757
|
+
*
|
|
2619
2758
|
* @returns {string}
|
|
2620
2759
|
*/
|
|
2621
|
-
const
|
|
2622
|
-
try {
|
|
2623
|
-
const raw = fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')
|
|
2624
|
-
const parsed = JSON.parse(raw)
|
|
2625
|
-
return typeof parsed.version === 'string' && parsed.version !== '' ? parsed.version : 'unknown'
|
|
2626
|
-
} catch {
|
|
2627
|
-
return 'unknown'
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2760
|
+
const ownVersion = () => pluginVersion()
|
|
2630
2761
|
|
|
2631
2762
|
/**
|
|
2632
2763
|
* Write (or update) the durable record of the restart.
|
|
@@ -2960,7 +3091,7 @@ const titleOfSummary = (item) => {
|
|
|
2960
3091
|
* @returns {string}
|
|
2961
3092
|
*/
|
|
2962
3093
|
const diagnosticsText = () => buildDiagnostics({
|
|
2963
|
-
version:
|
|
3094
|
+
version: ownVersion(),
|
|
2964
3095
|
platform: env.platform,
|
|
2965
3096
|
hostKind: hostKindOf().kind,
|
|
2966
3097
|
isDesktopApp: env.isDesktopApp === true,
|
|
@@ -2970,6 +3101,9 @@ const titleOfSummary = (item) => {
|
|
|
2970
3101
|
lastRestart: lastRestartVerdict(),
|
|
2971
3102
|
stateDir: stateDirOf(),
|
|
2972
3103
|
watchdog: watchdogInfo(),
|
|
3104
|
+
// DID THE DISARM WORK? A failed disarm leaves an armed watchdog behind,
|
|
3105
|
+
// and that is the first thing a bug report has to show.
|
|
3106
|
+
watchdogDisarm: summarizeDisarm(watchdogDisarm),
|
|
2973
3107
|
})
|
|
2974
3108
|
|
|
2975
3109
|
/**
|
|
@@ -3540,13 +3674,50 @@ const titleOfSummary = (item) => {
|
|
|
3540
3674
|
// app relaunch and the custom restart command share this single entry, so
|
|
3541
3675
|
// a hosted host that is restarted by a command is just as recoverable as
|
|
3542
3676
|
// the desktop app.
|
|
3677
|
+
//
|
|
3678
|
+
// THE STAGE IS ON SCREEN BEFORE THE COPY STARTS, and the copy reports the
|
|
3679
|
+
// files and bytes it really writes. That is the fix of "I clicked restart
|
|
3680
|
+
// and nothing happened for ten seconds": the ten seconds ARE the snapshot,
|
|
3681
|
+
// and now they say so.
|
|
3543
3682
|
const stateDir = path.dirname(stateFile)
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3683
|
+
resetSnapshotCancel()
|
|
3684
|
+
markRestartStage('snapshotting')
|
|
3685
|
+
let cancelled = false
|
|
3686
|
+
let snapshot
|
|
3687
|
+
try {
|
|
3688
|
+
snapshot = await snapshotFn({
|
|
3689
|
+
config,
|
|
3690
|
+
stateDir,
|
|
3691
|
+
log,
|
|
3692
|
+
profile: resolveProfileDirDetailed(),
|
|
3693
|
+
onProgress: (progress) => {
|
|
3694
|
+
recordSnapshotProgress(progress)
|
|
3695
|
+
},
|
|
3696
|
+
shouldCancel: () => snapshotCancelled(),
|
|
3697
|
+
})
|
|
3698
|
+
} finally {
|
|
3699
|
+
// The flag belongs to ONE snapshot; a stale `true` would cancel the
|
|
3700
|
+
// next restart before it began.
|
|
3701
|
+
const wasCancelled = snapshotCancelled()
|
|
3702
|
+
resetSnapshotCancel()
|
|
3703
|
+
// Two ways a copy can be called off: the flag this process set (the
|
|
3704
|
+
// cancel route) or the snapshot reporting it itself. Both mean the same
|
|
3705
|
+
// thing, and both must stop the restart.
|
|
3706
|
+
cancelled = wasCancelled || (snapshot !== null && typeof snapshot === 'object' && snapshot.cancelled === true)
|
|
3707
|
+
}
|
|
3708
|
+
if (cancelled) {
|
|
3709
|
+
// The user called it off while the files were being copied: the
|
|
3710
|
+
// half-written snapshot is already gone (createBackup removes it), the
|
|
3711
|
+
// host does not restart, and the request is WITHDRAWN — a restart the
|
|
3712
|
+
// user cancelled must not be retried five seconds later.
|
|
3713
|
+
log.warn('the restart was cancelled while the snapshot was being written')
|
|
3714
|
+
clearRestartStage()
|
|
3715
|
+
releaseRequesters()
|
|
3716
|
+
cancelRestart(state, { now: Date.now() })
|
|
3717
|
+
persist()
|
|
3718
|
+
stopPolling()
|
|
3719
|
+
return
|
|
3720
|
+
}
|
|
3550
3721
|
// The snapshot is the rollback point of BOTH branches.
|
|
3551
3722
|
const backup = snapshot && snapshot.backup ? snapshot.backup : null
|
|
3552
3723
|
const profileDir = snapshot && snapshot.profileDir ? snapshot.profileDir : null
|
|
@@ -3677,6 +3848,10 @@ const titleOfSummary = (item) => {
|
|
|
3677
3848
|
// looked at "the spawn did not throw", so a command that does not
|
|
3678
3849
|
// exist or that exits with an error was reported as a successful
|
|
3679
3850
|
// restart.
|
|
3851
|
+
//
|
|
3852
|
+
// FROM HERE ON THE RESTART CANNOT BE CANCELLED, and the interface says
|
|
3853
|
+
// so: the host is on its way out.
|
|
3854
|
+
markRestartStage('restarting')
|
|
3680
3855
|
const outcome = await runCommandFn({
|
|
3681
3856
|
command,
|
|
3682
3857
|
timeoutMs: config.restartCommandTimeoutMs,
|
|
@@ -3814,8 +3989,15 @@ const titleOfSummary = (item) => {
|
|
|
3814
3989
|
watchScript,
|
|
3815
3990
|
})
|
|
3816
3991
|
log.warn('scheduled detached restart:', result)
|
|
3817
|
-
if (!result || result.ok !== true)
|
|
3818
|
-
|
|
3992
|
+
if (!result || result.ok !== true) {
|
|
3993
|
+
markRestartStage('waiting', { blocked: 0 })
|
|
3994
|
+
rollbackRestart((result && result.error) || 'detached restart refused')
|
|
3995
|
+
} else {
|
|
3996
|
+
// The helper is scheduled: the host is going down, and from here on a
|
|
3997
|
+
// cancel would be a promise this plugin cannot keep.
|
|
3998
|
+
markRestartStage('restarting')
|
|
3999
|
+
armGateWatchdog()
|
|
4000
|
+
}
|
|
3819
4001
|
}
|
|
3820
4002
|
} catch (error) {
|
|
3821
4003
|
rollbackRestart(String(error))
|
|
@@ -4059,6 +4241,162 @@ const titleOfSummary = (item) => {
|
|
|
4059
4241
|
get bootId() {
|
|
4060
4242
|
return bootId
|
|
4061
4243
|
},
|
|
4244
|
+
/**
|
|
4245
|
+
* THE STATE DIRECTORY of this boot — the checkpoint, the watchdog markers
|
|
4246
|
+
* and the scheduling-item leftovers all live in it.
|
|
4247
|
+
*
|
|
4248
|
+
* It is exposed ON PURPOSE: the health route is a module-level function
|
|
4249
|
+
* and used to read the directory from a variable that only existed inside
|
|
4250
|
+
* this factory. The lookup threw `ReferenceError: stateDirOf is not
|
|
4251
|
+
* defined` on every request, an empty `catch {}` ate it, and a healthy
|
|
4252
|
+
* host left an armed watchdog behind (the 0.8.2 incident). Anything a
|
|
4253
|
+
* route needs must be reachable through `runtime`, never through a
|
|
4254
|
+
* closure it cannot see.
|
|
4255
|
+
*/
|
|
4256
|
+
get stateDir() {
|
|
4257
|
+
return path.dirname(stateFile)
|
|
4258
|
+
},
|
|
4259
|
+
/**
|
|
4260
|
+
* The version of the plugin that is actually LOADED in this process.
|
|
4261
|
+
*
|
|
4262
|
+
* The health route reports it so a real-machine check can tell "the new
|
|
4263
|
+
* code is running" from "the old code is still loaded".
|
|
4264
|
+
*/
|
|
4265
|
+
get pluginVersion() {
|
|
4266
|
+
return ownVersion()
|
|
4267
|
+
},
|
|
4268
|
+
/**
|
|
4269
|
+
* DISARM THE WATCHDOG: "this host is up, so nothing may roll it back".
|
|
4270
|
+
*
|
|
4271
|
+
* The ready marker is written on the FIRST tick of the first attempt (so a
|
|
4272
|
+
* caller inside an HTTP route has it on disk before it answers), the
|
|
4273
|
+
* pending marker and the scheduling item are cleaned with retries, and the
|
|
4274
|
+
* record is kept on `runtime` and written next to the markers.
|
|
4275
|
+
*
|
|
4276
|
+
* One run at a time: a second call while one is running returns the same
|
|
4277
|
+
* promise instead of starting a second `schtasks` dance.
|
|
4278
|
+
*
|
|
4279
|
+
* @param {string} [source] where the call came from ('boot', 'health-route', …)
|
|
4280
|
+
* @returns {Promise<object>} the record
|
|
4281
|
+
*/
|
|
4282
|
+
disarmWatchdog(source = 'boot') {
|
|
4283
|
+
if (disarmInFlight !== null) {
|
|
4284
|
+
// One run at a time (two `schtasks /Delete` calls fight each other),
|
|
4285
|
+
// but a request that arrives DURING a run is remembered: a pending
|
|
4286
|
+
// marker written in the meantime must still be cleaned, not lost.
|
|
4287
|
+
disarmPendingAgain = true
|
|
4288
|
+
return disarmInFlight
|
|
4289
|
+
}
|
|
4290
|
+
const one = (where) => {
|
|
4291
|
+
const port = Number(runtime.webPort)
|
|
4292
|
+
return runDisarmWatchdog({
|
|
4293
|
+
stateDir: stateDirOf(),
|
|
4294
|
+
bootId,
|
|
4295
|
+
port: Number.isFinite(port) && port > 0 ? port : null,
|
|
4296
|
+
source: where,
|
|
4297
|
+
log,
|
|
4298
|
+
})
|
|
4299
|
+
.then((record) => {
|
|
4300
|
+
watchdogDisarm = record
|
|
4301
|
+
if (record.ok === true) {
|
|
4302
|
+
log.info('the watchdog is disarmed (' + where + ')', {
|
|
4303
|
+
attempts: record.attempts,
|
|
4304
|
+
tier: record.tier,
|
|
4305
|
+
})
|
|
4306
|
+
}
|
|
4307
|
+
return record
|
|
4308
|
+
})
|
|
4309
|
+
.catch((error) => {
|
|
4310
|
+
// The disarm must never reject: the health route and the boot path
|
|
4311
|
+
// both call it without a `catch`.
|
|
4312
|
+
watchdogDisarm = {
|
|
4313
|
+
at: new Date().toISOString(),
|
|
4314
|
+
source: String(where),
|
|
4315
|
+
bootId,
|
|
4316
|
+
attempts: 0,
|
|
4317
|
+
ok: false,
|
|
4318
|
+
ready: false,
|
|
4319
|
+
pending: null,
|
|
4320
|
+
tier: null,
|
|
4321
|
+
scheduleRemoved: null,
|
|
4322
|
+
scheduleError: null,
|
|
4323
|
+
errors: [{
|
|
4324
|
+
attempt: 0,
|
|
4325
|
+
step: 'disarm',
|
|
4326
|
+
name: error && error.name ? String(error.name) : 'Error',
|
|
4327
|
+
message: error && error.message ? String(error.message) : String(error),
|
|
4328
|
+
}],
|
|
4329
|
+
}
|
|
4330
|
+
log.warn('the watchdog could not be disarmed:', String(error))
|
|
4331
|
+
return watchdogDisarm
|
|
4332
|
+
})
|
|
4333
|
+
}
|
|
4334
|
+
const drive = async () => {
|
|
4335
|
+
let record = await one(source)
|
|
4336
|
+
while (disarmPendingAgain === true) {
|
|
4337
|
+
disarmPendingAgain = false
|
|
4338
|
+
record = await one(String(source) + '+again')
|
|
4339
|
+
}
|
|
4340
|
+
return record
|
|
4341
|
+
}
|
|
4342
|
+
const running = drive().then(
|
|
4343
|
+
(record) => {
|
|
4344
|
+
disarmInFlight = null
|
|
4345
|
+
disarmPendingAgain = false
|
|
4346
|
+
return record
|
|
4347
|
+
},
|
|
4348
|
+
(error) => {
|
|
4349
|
+
disarmInFlight = null
|
|
4350
|
+
disarmPendingAgain = false
|
|
4351
|
+
throw error
|
|
4352
|
+
},
|
|
4353
|
+
)
|
|
4354
|
+
disarmInFlight = running
|
|
4355
|
+
return running
|
|
4356
|
+
},
|
|
4357
|
+
/**
|
|
4358
|
+
* The record of the last disarm (null when this host never disarmed one).
|
|
4359
|
+
*/
|
|
4360
|
+
get watchdogDisarm() {
|
|
4361
|
+
return watchdogDisarm
|
|
4362
|
+
},
|
|
4363
|
+
// THE STAGE OF A RESTART, as every surface reads it. `markRestartStage`
|
|
4364
|
+
// writes it and persists immediately (that is what makes the first stage
|
|
4365
|
+
// appear within the first second after the click), `recordSnapshotProgress`
|
|
4366
|
+
// carries the REAL bytes of the running copy, and `restartStageInfo` is the
|
|
4367
|
+
// single description (text included) that the state route, the health route
|
|
4368
|
+
// and the settings page share.
|
|
4369
|
+
markRestartStage,
|
|
4370
|
+
recordSnapshotProgress,
|
|
4371
|
+
clearRestartStage,
|
|
4372
|
+
restartStageInfo,
|
|
4373
|
+
requestSnapshotCancel,
|
|
4374
|
+
snapshotCancelled,
|
|
4375
|
+
resetSnapshotCancel,
|
|
4376
|
+
/**
|
|
4377
|
+
* The stage of a restart, cut down for the health route (no long texts).
|
|
4378
|
+
*
|
|
4379
|
+
* @returns {object} `{ phase, at, blocking, progress, slow, cancellable }`
|
|
4380
|
+
*/
|
|
4381
|
+
restartStageBrief() {
|
|
4382
|
+
const info = restartStageInfo()
|
|
4383
|
+
return {
|
|
4384
|
+
phase: info.phase,
|
|
4385
|
+
at: info.at,
|
|
4386
|
+
elapsedMs: info.elapsedMs,
|
|
4387
|
+
// How many tasks still hold the gate: the centred overlay prints the
|
|
4388
|
+
// number, so it travels as a number and not only inside `text`.
|
|
4389
|
+
blocking: info.blocking,
|
|
4390
|
+
// The size of the pre-restart snapshot, on every stage (bytes).
|
|
4391
|
+
snapshotBytes: info.snapshotBytes,
|
|
4392
|
+
progress: info.progress === null
|
|
4393
|
+
? null
|
|
4394
|
+
: { files: info.progress.files, bytes: info.progress.bytes, total: info.progress.total, percent: info.progress.percent, ageMs: info.progress.ageMs },
|
|
4395
|
+
slow: info.slow,
|
|
4396
|
+
cancellable: info.cancellable,
|
|
4397
|
+
cancelReason: info.cancelReason,
|
|
4398
|
+
}
|
|
4399
|
+
},
|
|
4062
4400
|
/**
|
|
4063
4401
|
* Where the effective `backupIncludePlugins` value came from:
|
|
4064
4402
|
* `config` (the profile row), `fallback-file` (the degraded path) or
|
|
@@ -5149,6 +5487,10 @@ function registerTools(ctx, runtime, config) {
|
|
|
5149
5487
|
if (typeof args.defer_seconds === 'number' && Number.isFinite(args.defer_seconds) && args.defer_seconds > 0) {
|
|
5150
5488
|
state.restart.requestedAt = now + Math.min(args.defer_seconds, 3600) * 1000
|
|
5151
5489
|
}
|
|
5490
|
+
// THE FIRST STAGE, RIGHT NOW — not when the backup starts. The interface
|
|
5491
|
+
// polls this out of the state route, so the click produces a visible
|
|
5492
|
+
// answer in well under a second.
|
|
5493
|
+
runtime.markRestartStage('waiting', { blocked: countBlockerTasks(runtime.gate().blockedBy) })
|
|
5152
5494
|
runtime.persist()
|
|
5153
5495
|
runtime.startPolling()
|
|
5154
5496
|
log.info('restart requested', { generation: state.generation, sessionId, resume, parkGoals })
|
|
@@ -6077,6 +6419,9 @@ function buildParkRoute(ctx, runtime, config) {
|
|
|
6077
6419
|
resume: true,
|
|
6078
6420
|
now: Date.now(),
|
|
6079
6421
|
})
|
|
6422
|
+
// The first stage, in the same breath as the request: the answer to
|
|
6423
|
+
// this POST is already "waiting for the sessions", never silence.
|
|
6424
|
+
runtime.markRestartStage('waiting', { blocked: blockingSessionIds(runtime).length })
|
|
6080
6425
|
runtime.persist()
|
|
6081
6426
|
runtime.startPolling()
|
|
6082
6427
|
}
|
|
@@ -6172,6 +6517,28 @@ function buildCancelRoute(ctx, runtime, config) {
|
|
|
6172
6517
|
})
|
|
6173
6518
|
return
|
|
6174
6519
|
}
|
|
6520
|
+
// THE STAGE DECIDES WHETHER A CANCEL IS HONEST.
|
|
6521
|
+
//
|
|
6522
|
+
// While the snapshot is being written the copy is stopped between two
|
|
6523
|
+
// files and its half-written directory is removed — the host never
|
|
6524
|
+
// restarts. Once the host is on its way out (`restarting`) a cancel
|
|
6525
|
+
// would be a promise this plugin cannot keep, so it is REFUSED with the
|
|
6526
|
+
// reason, and the button is greyed out on the same answer.
|
|
6527
|
+
const stage = runtime.restartStageInfo()
|
|
6528
|
+
if (stage.cancellable !== true) {
|
|
6529
|
+
const reason = cancelRefusalReason(stage.phase) || 'the host is already restarting'
|
|
6530
|
+
runtime.log.warn(`the cancel route refused the request of session ${sessionId}: ${reason}`)
|
|
6531
|
+
answer(409, {
|
|
6532
|
+
ok: false,
|
|
6533
|
+
cancelled: false,
|
|
6534
|
+
reason: 'not-cancellable',
|
|
6535
|
+
stage: stage.phase,
|
|
6536
|
+
error: reason,
|
|
6537
|
+
remainingRequesters: 0,
|
|
6538
|
+
sessionId,
|
|
6539
|
+
})
|
|
6540
|
+
return
|
|
6541
|
+
}
|
|
6175
6542
|
const outcome = cancelOwnRestart(runtime.state, { sessionId, now: Date.now() })
|
|
6176
6543
|
if (!outcome.removed) {
|
|
6177
6544
|
// Nothing was changed: the caller is not a requester (or the
|
|
@@ -6194,7 +6561,14 @@ function buildCancelRoute(ctx, runtime, config) {
|
|
|
6194
6561
|
}
|
|
6195
6562
|
// Release ONLY this session's continuation request.
|
|
6196
6563
|
runtime.releaseRequesters([sessionId])
|
|
6197
|
-
if (outcome.cancelled)
|
|
6564
|
+
if (outcome.cancelled) {
|
|
6565
|
+
// The whole restart is off. If the snapshot is being written right
|
|
6566
|
+
// now, the copy stops at the next file and removes its half-written
|
|
6567
|
+
// directory (the host never restarts); otherwise the stage just goes.
|
|
6568
|
+
if (stage.phase === 'snapshotting') runtime.requestSnapshotCancel()
|
|
6569
|
+
runtime.clearRestartStage()
|
|
6570
|
+
runtime.stopPolling()
|
|
6571
|
+
}
|
|
6198
6572
|
runtime.persist()
|
|
6199
6573
|
runtime.log.info(
|
|
6200
6574
|
outcome.cancelled
|
|
@@ -7377,6 +7751,39 @@ function buildSleepRoute(ctx, runtime, config, sleep) {
|
|
|
7377
7751
|
}
|
|
7378
7752
|
|
|
7379
7753
|
/** Expose a tiny read-only health route for humans and verification scripts. */
|
|
7754
|
+
/**
|
|
7755
|
+
* The disarm record, cut down to what a reader outside the process needs.
|
|
7756
|
+
*
|
|
7757
|
+
* The health route answers with it, so a real-machine check can tell "the
|
|
7758
|
+
* watchdog was stood down" from "it is still armed" without reading a log.
|
|
7759
|
+
*
|
|
7760
|
+
* @param {object|null} record
|
|
7761
|
+
* @returns {object|null}
|
|
7762
|
+
*/
|
|
7763
|
+
function summarizeDisarm(record) {
|
|
7764
|
+
if (!record || typeof record !== 'object') return null
|
|
7765
|
+
const errors = Array.isArray(record.errors) ? record.errors : []
|
|
7766
|
+
return {
|
|
7767
|
+
ok: record.ok === true,
|
|
7768
|
+
at: typeof record.at === 'string' ? record.at : null,
|
|
7769
|
+
source: typeof record.source === 'string' ? record.source : null,
|
|
7770
|
+
attempts: Number.isFinite(Number(record.attempts)) ? Number(record.attempts) : 0,
|
|
7771
|
+
ready: record.ready === true,
|
|
7772
|
+
pending: record.pending === true,
|
|
7773
|
+
tier: typeof record.tier === 'string' ? record.tier : null,
|
|
7774
|
+
scheduleRemoved: record.scheduleRemoved === true ? true : (record.scheduleRemoved === false ? false : null),
|
|
7775
|
+
// Only the first failure: the route answer must stay small, and one named
|
|
7776
|
+
// error is what makes the failure diagnosable.
|
|
7777
|
+
error: errors.length > 0
|
|
7778
|
+
? {
|
|
7779
|
+
step: typeof errors[0].step === 'string' ? errors[0].step : null,
|
|
7780
|
+
name: typeof errors[0].name === 'string' ? errors[0].name : null,
|
|
7781
|
+
message: typeof errors[0].message === 'string' ? errors[0].message : null,
|
|
7782
|
+
}
|
|
7783
|
+
: null,
|
|
7784
|
+
}
|
|
7785
|
+
}
|
|
7786
|
+
|
|
7380
7787
|
function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
7381
7788
|
const routePath = typeof config.healthPath === 'string' && config.healthPath.startsWith('/')
|
|
7382
7789
|
? config.healthPath
|
|
@@ -7471,16 +7878,25 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
|
7471
7878
|
}
|
|
7472
7879
|
}
|
|
7473
7880
|
const verdict = runtime.gate()
|
|
7881
|
+
// The restart stage (waiting / snapshotting / restarting / back), the
|
|
7882
|
+
// real snapshot progress and the "is a cancel still honest" answer —
|
|
7883
|
+
// computed ONCE per answer so every field of the payload agrees.
|
|
7884
|
+
const stage = runtime.restartStageInfo()
|
|
7474
7885
|
// Best effort: label the blocking sessions with their titles when the
|
|
7475
7886
|
// session controller knows them. The title is optional, so an entry
|
|
7476
7887
|
// without one is sent exactly as the gate produced it. The goal
|
|
7477
7888
|
// reason (`goal-active` / `goal-exhausted`) is attached as well, so
|
|
7478
7889
|
// the client can tell a goal-driven blocker from a busy session.
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7890
|
+
// A blocker that is not an object (an empty string still reported by
|
|
7891
|
+
// an older route, a null) is not a blocker: it has no session and no
|
|
7892
|
+
// name, and rendering one produced the bare ",," the user saw. It is
|
|
7893
|
+
// dropped HERE, so the interface never receives one.
|
|
7894
|
+
const blockedBy = goalAwareBlockingDetails(runtime, verdict.blockedBy)
|
|
7895
|
+
.filter((entry) => entry && typeof entry === 'object')
|
|
7896
|
+
.map((entry) => {
|
|
7897
|
+
const title = runtime.sessionTitle(entry.sessionId)
|
|
7898
|
+
return title ? { ...entry, title } : entry
|
|
7899
|
+
})
|
|
7484
7900
|
stateBody = JSON.stringify({
|
|
7485
7901
|
ok: true,
|
|
7486
7902
|
pendingRestart,
|
|
@@ -7488,6 +7904,12 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
|
7488
7904
|
generation: live.generation,
|
|
7489
7905
|
waiters,
|
|
7490
7906
|
blockedBy,
|
|
7907
|
+
// THE ONE NUMBER both client surfaces read: the capsule and the
|
|
7908
|
+
// panel it opens. Counting the blockers in the browser is what made
|
|
7909
|
+
// "1 个会话在忙" sit next to "还在等 0 个任务" — the two surfaces
|
|
7910
|
+
// counted different filtered copies of the same list (user report,
|
|
7911
|
+
// 2026-10-29). A blocker without a readable name is still a task.
|
|
7912
|
+
busyCount: countBlockerTasks(verdict.blockedBy),
|
|
7491
7913
|
allowRestart: verdict.allow === true,
|
|
7492
7914
|
// WHY the gate says what it says. Without this, "allowRestart is
|
|
7493
7915
|
// false and blockedBy is empty" looks like a contradiction: the
|
|
@@ -7506,6 +7928,14 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
|
7506
7928
|
// long the restart has been waiting, why, and who holds it — the
|
|
7507
7929
|
// request itself is never dropped.
|
|
7508
7930
|
longWait: runtime.longWaitInfo(),
|
|
7931
|
+
// THE STAGE OF THE RESTART, for the pill and the settings page:
|
|
7932
|
+
// which of the four stages is on screen, the real copy progress of
|
|
7933
|
+
// the snapshot, whether it is going slower than expected, and
|
|
7934
|
+
// whether a cancel would still be honest (`cancellable`).
|
|
7935
|
+
restartPhase: stage,
|
|
7936
|
+
// The copy progress on its own as well, so a client that only wants
|
|
7937
|
+
// the numbers does not have to walk the stage object.
|
|
7938
|
+
progress: stage.progress,
|
|
7509
7939
|
updatedAt: live.updatedAt,
|
|
7510
7940
|
// The park route, so the client never has to guess the prefix, and
|
|
7511
7941
|
// the goals this plugin already disarmed (they are re-armed after
|
|
@@ -7630,27 +8060,64 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
|
7630
8060
|
// THE READY MARKER: this host answers its own health route, so it is
|
|
7631
8061
|
// up. The watchdog (which waits for the relaunched host) sees the
|
|
7632
8062
|
// marker and stands down — no rollback of a host that is fine.
|
|
8063
|
+
//
|
|
8064
|
+
// THE STATE DIRECTORY COMES FROM `runtime`, NEVER FROM A CLOSURE:
|
|
8065
|
+
// 0.8.2 read it through a variable that only existed inside the runtime
|
|
8066
|
+
// factory, so every request threw `ReferenceError: stateDirOf is not
|
|
8067
|
+
// defined`, the `catch {}` swallowed it, and a healthy host left the
|
|
8068
|
+
// pending marker AND the scheduled task behind (the watchdog would have
|
|
8069
|
+
// rolled a healthy profile back). The failure is recorded now, and the
|
|
8070
|
+
// marker is written first because the marker alone is what stops a
|
|
8071
|
+
// rollback.
|
|
7633
8072
|
try {
|
|
7634
|
-
const
|
|
7635
|
-
if (
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
8073
|
+
const stateDir = typeof runtime.stateDir === 'string' ? runtime.stateDir : ''
|
|
8074
|
+
if (stateDir !== '') {
|
|
8075
|
+
const marked = markReady({
|
|
8076
|
+
stateDir,
|
|
8077
|
+
bootId: runtime.bootId,
|
|
8078
|
+
port: runtime.webPort ?? null,
|
|
8079
|
+
})
|
|
8080
|
+
if (marked.ok !== true) {
|
|
8081
|
+
runtime.watchdogReadyError = {
|
|
8082
|
+
at: Date.now(),
|
|
8083
|
+
message: marked.detail,
|
|
8084
|
+
name: marked.error && marked.error.name ? String(marked.error.name) : null,
|
|
7645
8085
|
}
|
|
7646
8086
|
}
|
|
8087
|
+
// The marker alone stands the watchdog down; the pending marker and
|
|
8088
|
+
// the scheduling item are cleaned with retries in the background, so
|
|
8089
|
+
// `schtasks` never delays this answer.
|
|
8090
|
+
runtime.disarmWatchdog('health-route')
|
|
8091
|
+
}
|
|
8092
|
+
} catch (error) {
|
|
8093
|
+
// NEVER silent: a swallowed error here leaves an armed watchdog behind.
|
|
8094
|
+
// It is recorded on `runtime` (this scope has no logger) so the settings
|
|
8095
|
+
// page and the diagnostics can show it instead of pretending all is well.
|
|
8096
|
+
runtime.watchdogReadyError = {
|
|
8097
|
+
at: Date.now(),
|
|
8098
|
+
name: error && error.name ? String(error.name) : 'Error',
|
|
8099
|
+
message: String(error && error.message ? error.message : error),
|
|
8100
|
+
}
|
|
8101
|
+
// One retry with the bare minimum: the marker alone is what the
|
|
8102
|
+
// watchdog actually waits for.
|
|
8103
|
+
try {
|
|
8104
|
+
markReady({
|
|
8105
|
+
stateDir: typeof runtime.stateDir === 'string' ? runtime.stateDir : '',
|
|
8106
|
+
bootId: runtime.bootId,
|
|
8107
|
+
port: runtime.webPort ?? null,
|
|
8108
|
+
})
|
|
8109
|
+
runtime.watchdogReadyError.retry = 'ok'
|
|
8110
|
+
} catch (again) {
|
|
8111
|
+
runtime.watchdogReadyError.retry = String(again && again.message ? again.message : again)
|
|
7647
8112
|
}
|
|
7648
|
-
} catch {
|
|
7649
|
-
// The marker is a convenience: the watchdog also asks the health route.
|
|
7650
8113
|
}
|
|
7651
8114
|
const body = JSON.stringify({
|
|
7652
8115
|
ok: true,
|
|
7653
8116
|
plugin: name,
|
|
8117
|
+
// The version of the code that is LOADED (not the fallback constant):
|
|
8118
|
+
// a real-machine check can tell "the new build is running" from "the
|
|
8119
|
+
// old one is still in memory".
|
|
8120
|
+
version: runtime.pluginVersion ?? PLUGIN_VERSION_FALLBACK,
|
|
7654
8121
|
bootId: runtime.bootId,
|
|
7655
8122
|
pid: process.pid,
|
|
7656
8123
|
phase: state.phase,
|
|
@@ -7662,6 +8129,15 @@ function registerHealthRoute(ctx, runtime, config, sleep) {
|
|
|
7662
8129
|
manualRestartBlocks: runtime.manualRestartBlocks ?? 0,
|
|
7663
8130
|
indexTaps: runtime.indexTaps ?? 0,
|
|
7664
8131
|
configSchema: Config !== undefined,
|
|
8132
|
+
// DID THE DISARM WORK? A failed disarm means an armed watchdog is still
|
|
8133
|
+
// out there; it must be visible from outside the process, not only in
|
|
8134
|
+
// a log line nobody reads.
|
|
8135
|
+
watchdog: summarizeDisarm(runtime.watchdogDisarm),
|
|
8136
|
+
// THE RESTART STAGE, read from the health route as well: a script (or a
|
|
8137
|
+
// user watching the console) can tell "waiting" from "backing up 120 MB
|
|
8138
|
+
// of 156 MB" without opening the interface.
|
|
8139
|
+
restartPhase: runtime.restartStageBrief(),
|
|
8140
|
+
progress: runtime.restartStageBrief().progress,
|
|
7665
8141
|
schemastery: Config === undefined
|
|
7666
8142
|
? (schemastery === undefined ? 'unresolved' : 'construction-failed')
|
|
7667
8143
|
: (schemasterySource === 'bare-specifier' ? 'bare' : 'asar-fallback'),
|
|
@@ -7938,6 +8414,12 @@ export function apply(ctx, rawConfig, options = {}) {
|
|
|
7938
8414
|
// Record this boot in the checkpoint even when there is nothing to recover.
|
|
7939
8415
|
persistSafely(runtime)
|
|
7940
8416
|
|
|
8417
|
+
// THE LAST STAGE OF THE RESTART: this host IS the new one, so the interface
|
|
8418
|
+
// says "back — carrying on". Written here (not after the recovery) so a boot
|
|
8419
|
+
// that finds nothing to resume still shows the end of the story instead of a
|
|
8420
|
+
// spinner left over from the old process.
|
|
8421
|
+
if (runtime.isNewBoot) runtime.markRestartStage('back')
|
|
8422
|
+
|
|
7941
8423
|
trackAgents(ctx, runtime, config)
|
|
7942
8424
|
|
|
7943
8425
|
// The boot recovery pass is the plugin's primary boot duty, so it is scheduled
|
|
@@ -7955,8 +8437,7 @@ export function apply(ctx, rawConfig, options = {}) {
|
|
|
7955
8437
|
})
|
|
7956
8438
|
sleep.restore()
|
|
7957
8439
|
}
|
|
7958
|
-
if (runtime.isNewBoot && config.resumeOnBoot) {
|
|
7959
|
-
runtime.recoverTimer = ctx.timeout(() => {
|
|
8440
|
+
if (runtime.isNewBoot && config.resumeOnBoot) { runtime.recoverTimer = ctx.timeout(() => {
|
|
7960
8441
|
recoverAfterRestart(ctx, runtime, config)
|
|
7961
8442
|
.then(() => {
|
|
7962
8443
|
runtime.recovery = { ran: true, ok: true, detail: 'the boot recovery finished' }
|
|
@@ -7995,6 +8476,19 @@ export function apply(ctx, rawConfig, options = {}) {
|
|
|
7995
8476
|
registerSystemPrompt(ctx, runtime)
|
|
7996
8477
|
registerHealthRoute(ctx, runtime, config, sleep)
|
|
7997
8478
|
|
|
8479
|
+
// DISARM THE WATCHDOG AT BOOT — WITHOUT WAITING FOR ANYBODY TO ASK.
|
|
8480
|
+
//
|
|
8481
|
+
// This is the fix of the 0.8.2 incident: the ready marker and the cleanup of
|
|
8482
|
+
// the pending marker / scheduled task hung on the health route being hit.
|
|
8483
|
+
// The plugin is fully loaded at this point (every route is registered, the
|
|
8484
|
+
// boot recovery is scheduled), so this instance is "up" in the only sense
|
|
8485
|
+
// the watchdog cares about: it must not be rolled back.
|
|
8486
|
+
//
|
|
8487
|
+
// The call never rejects (see runtime.disarmWatchdog) and never blocks the
|
|
8488
|
+
// load: the marker is written synchronously on its first tick, the
|
|
8489
|
+
// `schtasks` cleanup runs with retries behind it.
|
|
8490
|
+
runtime.disarmWatchdog('boot')
|
|
8491
|
+
|
|
7998
8492
|
ctx.effect(() => () => {
|
|
7999
8493
|
runtime.markDisposed()
|
|
8000
8494
|
runtime.stopPolling()
|