dsh-autorestart 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +329 -12
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +5911 -3832
- package/cordis.patch.yml +37 -1
- package/docs/README.zh.md +219 -9
- package/index.js +2266 -193
- package/lib/access.js +212 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +180 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/node-runtime.js +144 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/locale/en.json +247 -64
- package/locale/zh.json +247 -64
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +302 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-autorestart — the durable record of ONE restart.
|
|
3
|
+
*
|
|
4
|
+
* A restart has TWO halves that live in two different processes:
|
|
5
|
+
*
|
|
6
|
+
* - the instance that is being replaced takes the pre-restart snapshot and
|
|
7
|
+
* then dies (the restart command kills it — that IS what restarting the
|
|
8
|
+
* host means);
|
|
9
|
+
* - the instance that replaces it has to prove that it really came up.
|
|
10
|
+
*
|
|
11
|
+
* Neither half can write the whole truth on its own, so the record lives in a
|
|
12
|
+
* FILE that both halves touch:
|
|
13
|
+
*
|
|
14
|
+
* 1. the OLD instance writes `result: 'pending'` BEFORE it runs the restart
|
|
15
|
+
* command — together with the snapshot it took (directory name, how long
|
|
16
|
+
* it took, which older snapshots were pruned);
|
|
17
|
+
* 2. the NEW instance reads that record on boot, runs the four boot checks
|
|
18
|
+
* (listener / routes / plugin / recovery) and rewrites it as `ok` or
|
|
19
|
+
* `failed`, with the time it verified itself and how long the whole
|
|
20
|
+
* restart took.
|
|
21
|
+
*
|
|
22
|
+
* If the new instance never comes up, the file stays `pending` — which is
|
|
23
|
+
* exactly what the operator has to see: a restart that was started and never
|
|
24
|
+
* finished. A record that says nothing is worse than no record at all, so the
|
|
25
|
+
* verdict is ALSO exposed by `/__dsh_autorestart/health?state=1` and by the
|
|
26
|
+
* `continuum_status` tool.
|
|
27
|
+
*
|
|
28
|
+
* The time is reported in BOTH zones: the snapshot directory is named in UTC
|
|
29
|
+
* (`2026-10-29T00-25-20-779Z`), while the operator reads the wall clock of
|
|
30
|
+
* the machine (`08:25`). Comparing the two without a conversion is how a
|
|
31
|
+
* perfectly fresh snapshot looks like an eight-hour-old leftover — so the
|
|
32
|
+
* record carries the local time of the snapshot as well.
|
|
33
|
+
*
|
|
34
|
+
* ESM only, Node builtins only. No DSH / npm imports.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { randomBytes } from 'node:crypto';
|
|
40
|
+
|
|
41
|
+
/** The file the verdict lives in (inside the state directory). */
|
|
42
|
+
export const RESTART_VERDICT_FILE = 'last-restart-verdict.json';
|
|
43
|
+
|
|
44
|
+
/** The three results a restart can have. */
|
|
45
|
+
export const RESTART_RESULTS = Object.freeze({
|
|
46
|
+
/** The restart was started; nobody confirmed it yet. */
|
|
47
|
+
PENDING: 'pending',
|
|
48
|
+
/** The new instance came up and every check passed. */
|
|
49
|
+
OK: 'ok',
|
|
50
|
+
/** The new instance came up but something did not work. */
|
|
51
|
+
FAILED: 'failed',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** The four checks the new instance runs on itself. */
|
|
55
|
+
export const RESTART_CHECK_NAMES = Object.freeze(['listener', 'routes', 'plugin', 'recovery']);
|
|
56
|
+
|
|
57
|
+
/** @param {unknown} value */
|
|
58
|
+
function isObject(value) {
|
|
59
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {unknown} value */
|
|
63
|
+
function isFiniteNumber(value) {
|
|
64
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {unknown} value @param {string|null} fallback */
|
|
68
|
+
function nullableString(value, fallback = null) {
|
|
69
|
+
return typeof value === 'string' && value !== '' ? value : fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {unknown} value */
|
|
73
|
+
function nullableNumber(value) {
|
|
74
|
+
return isFiniteNumber(value) ? value : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The local time of an epoch, with the UTC offset — the time the operator
|
|
79
|
+
* sees on the machine (`2026-10-29T08:25:20+08:00`).
|
|
80
|
+
*
|
|
81
|
+
* @param {number|null|undefined} at
|
|
82
|
+
* @returns {string|null}
|
|
83
|
+
*/
|
|
84
|
+
export function localIsoTime(at) {
|
|
85
|
+
if (!isFiniteNumber(at)) return null;
|
|
86
|
+
const date = new Date(Number(at));
|
|
87
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
88
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
89
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
90
|
+
const absolute = Math.abs(offsetMinutes);
|
|
91
|
+
const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
|
|
92
|
+
const minutes = String(absolute % 60).padStart(2, '0');
|
|
93
|
+
const local = new Date(Number(at) + offsetMinutes * 60000).toISOString();
|
|
94
|
+
return `${local.slice(0, 19)}${sign}${hours}:${minutes}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The snapshot part of the verdict: WHICH snapshot was taken, how long it
|
|
99
|
+
* took and which older snapshots were pruned.
|
|
100
|
+
*
|
|
101
|
+
* @param {{
|
|
102
|
+
* requested?: boolean,
|
|
103
|
+
* backup?: object|null,
|
|
104
|
+
* profileDir?: string|null,
|
|
105
|
+
* durationMs?: number|null,
|
|
106
|
+
* error?: string|null,
|
|
107
|
+
* }} [input]
|
|
108
|
+
* @returns {object}
|
|
109
|
+
*/
|
|
110
|
+
export function buildSnapshotRecord({
|
|
111
|
+
requested = false,
|
|
112
|
+
backup = null,
|
|
113
|
+
profileDir = null,
|
|
114
|
+
durationMs = null,
|
|
115
|
+
error = null,
|
|
116
|
+
} = {}) {
|
|
117
|
+
const taken = isObject(backup) && backup.ok === true;
|
|
118
|
+
const stamp = taken ? nullableString(backup.stamp) : null;
|
|
119
|
+
const at = taken ? nullableNumber(backup.at) : null;
|
|
120
|
+
return {
|
|
121
|
+
requested: requested === true,
|
|
122
|
+
taken,
|
|
123
|
+
dir: taken ? nullableString(backup.dir) : null,
|
|
124
|
+
stamp,
|
|
125
|
+
at,
|
|
126
|
+
// The human-readable time of the snapshot: the directory name is UTC.
|
|
127
|
+
atLocal: at === null ? null : localIsoTime(at),
|
|
128
|
+
durationMs: isFiniteNumber(durationMs) ? Math.trunc(durationMs) : null,
|
|
129
|
+
bytes: taken ? nullableNumber(backup.bytes) : null,
|
|
130
|
+
files: taken && Array.isArray(backup.files) ? backup.files.map(String) : [],
|
|
131
|
+
pruned: taken && Array.isArray(backup.pruned) ? backup.pruned.map(String) : [],
|
|
132
|
+
profileDir: nullableString(profileDir),
|
|
133
|
+
error: nullableString(error) ?? (taken ? null : (nullableString(isObject(backup) ? backup.error : null) ?? 'the snapshot was not taken')),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Normalize one boot check.
|
|
139
|
+
*
|
|
140
|
+
* @param {unknown} raw
|
|
141
|
+
* @returns {{ name: string, ok: boolean, detail: string|null }}
|
|
142
|
+
*/
|
|
143
|
+
function normalizeCheck(raw) {
|
|
144
|
+
if (!isObject(raw)) return { name: 'unknown', ok: false, detail: null };
|
|
145
|
+
return {
|
|
146
|
+
name: typeof raw.name === 'string' && raw.name !== '' ? raw.name : 'unknown',
|
|
147
|
+
ok: raw.ok === true,
|
|
148
|
+
detail: nullableString(raw.detail),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build a verdict record from a loose input.
|
|
154
|
+
*
|
|
155
|
+
* @param {{
|
|
156
|
+
* result?: string,
|
|
157
|
+
* mode?: string|null,
|
|
158
|
+
* generation?: number|null,
|
|
159
|
+
* requestedAt?: number|null,
|
|
160
|
+
* startedAt?: number|null,
|
|
161
|
+
* verifiedAt?: number|null,
|
|
162
|
+
* durationMs?: number|null,
|
|
163
|
+
* bootId?: string|null,
|
|
164
|
+
* previousBootId?: string|null,
|
|
165
|
+
* snapshot?: object|null,
|
|
166
|
+
* checks?: any[],
|
|
167
|
+
* reason?: string|null,
|
|
168
|
+
* now?: number,
|
|
169
|
+
* }} [input]
|
|
170
|
+
* @returns {object}
|
|
171
|
+
*/
|
|
172
|
+
export function buildRestartVerdict(input = {}) {
|
|
173
|
+
const now = isFiniteNumber(input.now) ? Number(input.now) : Date.now();
|
|
174
|
+
const result = input.result === RESTART_RESULTS.OK
|
|
175
|
+
? RESTART_RESULTS.OK
|
|
176
|
+
: input.result === RESTART_RESULTS.FAILED
|
|
177
|
+
? RESTART_RESULTS.FAILED
|
|
178
|
+
: RESTART_RESULTS.PENDING;
|
|
179
|
+
const startedAt = nullableNumber(input.startedAt);
|
|
180
|
+
const verifiedAt = nullableNumber(input.verifiedAt);
|
|
181
|
+
const durationMs = isFiniteNumber(input.durationMs)
|
|
182
|
+
? Math.trunc(Number(input.durationMs))
|
|
183
|
+
: (startedAt !== null && verifiedAt !== null ? Math.max(0, verifiedAt - startedAt) : null);
|
|
184
|
+
const snapshot = isObject(input.snapshot) ? input.snapshot : buildSnapshotRecord({ requested: false });
|
|
185
|
+
return {
|
|
186
|
+
result,
|
|
187
|
+
mode: nullableString(input.mode),
|
|
188
|
+
generation: nullableNumber(input.generation),
|
|
189
|
+
requestedAt: nullableNumber(input.requestedAt),
|
|
190
|
+
startedAt,
|
|
191
|
+
verifiedAt,
|
|
192
|
+
durationMs,
|
|
193
|
+
bootId: nullableString(input.bootId),
|
|
194
|
+
previousBootId: nullableString(input.previousBootId),
|
|
195
|
+
snapshot: {
|
|
196
|
+
requested: snapshot.requested === true,
|
|
197
|
+
taken: snapshot.taken === true,
|
|
198
|
+
dir: nullableString(snapshot.dir),
|
|
199
|
+
stamp: nullableString(snapshot.stamp),
|
|
200
|
+
at: nullableNumber(snapshot.at),
|
|
201
|
+
atLocal: nullableString(snapshot.atLocal),
|
|
202
|
+
durationMs: nullableNumber(snapshot.durationMs),
|
|
203
|
+
bytes: nullableNumber(snapshot.bytes),
|
|
204
|
+
files: Array.isArray(snapshot.files) ? snapshot.files.map(String) : [],
|
|
205
|
+
pruned: Array.isArray(snapshot.pruned) ? snapshot.pruned.map(String) : [],
|
|
206
|
+
profileDir: nullableString(snapshot.profileDir),
|
|
207
|
+
error: nullableString(snapshot.error),
|
|
208
|
+
},
|
|
209
|
+
checks: Array.isArray(input.checks) ? input.checks.map(normalizeCheck) : [],
|
|
210
|
+
reason: nullableString(input.reason),
|
|
211
|
+
updatedAt: now,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The verdict of a set of checks: `ok` only when EVERY check passed.
|
|
217
|
+
*
|
|
218
|
+
* @param {any[]} checks
|
|
219
|
+
* @returns {{ ok: boolean, failed: string[], passed: string[] }}
|
|
220
|
+
*/
|
|
221
|
+
export function evaluateRestartChecks(checks) {
|
|
222
|
+
const list = Array.isArray(checks) ? checks : [];
|
|
223
|
+
const failed = []
|
|
224
|
+
const passed = []
|
|
225
|
+
for (const check of list) {
|
|
226
|
+
const normalized = normalizeCheck(check)
|
|
227
|
+
if (normalized.ok) passed.push(normalized.name)
|
|
228
|
+
else failed.push(normalized.name)
|
|
229
|
+
}
|
|
230
|
+
return { ok: failed.length === 0 && list.length > 0, failed, passed }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Write the verdict into the state directory (atomically: a temporary file
|
|
235
|
+
* plus a rename, so a half-written record can never be read).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} stateDir
|
|
238
|
+
* @param {object} verdict
|
|
239
|
+
* @returns {{ ok: boolean, path: string, error: string|null }}
|
|
240
|
+
*/
|
|
241
|
+
export function writeRestartVerdict(stateDir, verdict) {
|
|
242
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
243
|
+
if (dir === '') return { ok: false, path: '', error: 'writeRestartVerdict requires stateDir' };
|
|
244
|
+
const target = path.join(dir, RESTART_VERDICT_FILE);
|
|
245
|
+
const record = buildRestartVerdict(verdict)
|
|
246
|
+
try {
|
|
247
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
248
|
+
const temporary = `${target}.tmp-${randomBytes(6).toString('hex')}`
|
|
249
|
+
fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
|
|
250
|
+
fs.renameSync(temporary, target)
|
|
251
|
+
return { ok: true, path: target, error: null }
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
path: target,
|
|
256
|
+
error: String(error && error.message ? error.message : error),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read the verdict of the last restart. Never throws: an unreadable or
|
|
263
|
+
* corrupt record is reported as `null` (there is nothing to show).
|
|
264
|
+
*
|
|
265
|
+
* @param {string} stateDir
|
|
266
|
+
* @returns {object|null}
|
|
267
|
+
*/
|
|
268
|
+
export function readRestartVerdict(stateDir) {
|
|
269
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
270
|
+
if (dir === '') return null;
|
|
271
|
+
try {
|
|
272
|
+
let text = fs.readFileSync(path.join(dir, RESTART_VERDICT_FILE), 'utf8')
|
|
273
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
|
274
|
+
const parsed = JSON.parse(text)
|
|
275
|
+
if (!isObject(parsed)) return null
|
|
276
|
+
return buildRestartVerdict(parsed)
|
|
277
|
+
} catch {
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Remove the verdict (used when a fresh restart cycle starts).
|
|
284
|
+
*
|
|
285
|
+
* @param {string} stateDir
|
|
286
|
+
* @returns {boolean} whether a record was removed
|
|
287
|
+
*/
|
|
288
|
+
export function clearRestartVerdict(stateDir) {
|
|
289
|
+
const dir = typeof stateDir === 'string' ? stateDir.trim() : '';
|
|
290
|
+
if (dir === '') return false;
|
|
291
|
+
try {
|
|
292
|
+
fs.rmSync(path.join(dir, RESTART_VERDICT_FILE), { force: true })
|
|
293
|
+
return true
|
|
294
|
+
} catch {
|
|
295
|
+
return false
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The compact shape of the verdict that the health route and the status tool
|
|
301
|
+
* report. It is deliberately flat: a UI (or a human with `curl`) has to read
|
|
302
|
+
* it without knowing the internals.
|
|
303
|
+
*
|
|
304
|
+
* @param {object|null} verdict
|
|
305
|
+
* @returns {object|null}
|
|
306
|
+
*/
|
|
307
|
+
export function summarizeRestartVerdict(verdict) {
|
|
308
|
+
if (!isObject(verdict)) return null
|
|
309
|
+
const record = buildRestartVerdict(verdict)
|
|
310
|
+
const failed = record.checks.filter((check) => !check.ok).map((check) => check.name)
|
|
311
|
+
return {
|
|
312
|
+
result: record.result,
|
|
313
|
+
mode: record.mode,
|
|
314
|
+
generation: record.generation,
|
|
315
|
+
requestedAt: record.requestedAt,
|
|
316
|
+
startedAt: record.startedAt,
|
|
317
|
+
verifiedAt: record.verifiedAt,
|
|
318
|
+
durationMs: record.durationMs,
|
|
319
|
+
bootId: record.bootId,
|
|
320
|
+
previousBootId: record.previousBootId,
|
|
321
|
+
snapshot: record.snapshot,
|
|
322
|
+
checks: record.checks,
|
|
323
|
+
failedChecks: failed,
|
|
324
|
+
reason: record.reason,
|
|
325
|
+
updatedAt: record.updatedAt,
|
|
326
|
+
}
|
|
327
|
+
}
|