dsh-autorestart 0.8.1 → 0.8.2

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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * THE FOUR STAGES OF A RESTART — and what the user is told during each one.
3
+ *
4
+ * WHY THIS FILE EXISTS (user report, 2026-10-29):
5
+ *
6
+ * "I clicked 'restart in 0 seconds' and then nothing moved for ten-odd
7
+ * seconds. It was backing up 156 MB the whole time. I thought the button was
8
+ * dead, or that '0 seconds' was a lie, or that there was a bug."
9
+ *
10
+ * The restart really has four stages, and each one has to be VISIBLE at once,
11
+ * because "the screen does nothing" is indistinguishable from "it is broken":
12
+ *
13
+ * waiting the gate is still waiting for the conversations to become idle
14
+ * snapshotting the pre-restart snapshot is being written (this is the long one)
15
+ * restarting the host is about to exit; from here nothing can be cancelled
16
+ * back the new host is up and the sessions continue
17
+ *
18
+ * TWO RULES that make the difference between "a progress report" and "a fake
19
+ * progress bar":
20
+ *
21
+ * 1. Every number here comes from the real copy loop (`lib/backup.js`): the
22
+ * bytes that were actually written and the bytes there are to write. When
23
+ * there is no measurement yet, the text says nothing about progress
24
+ * instead of inventing it.
25
+ * 2. A slow copy is SAID OUT LOUD (`slow: 'total'` after five minutes,
26
+ * `slow: 'stalled'` when the byte counter has not moved for a minute) —
27
+ * that is the opposite of pretending: the user learns that the disk is
28
+ * busy and that it is still working.
29
+ *
30
+ * Everything in this file is pure: it takes numbers and returns an object. The
31
+ * host, the state route, the health route and the client all read the same
32
+ * shape, so no two surfaces can disagree about what stage the restart is in.
33
+ *
34
+ * ESM only, Node builtins only (nothing here imports anything).
35
+ */
36
+
37
+ /** The stages, in the order they happen. */
38
+ export const RESTART_PHASES = Object.freeze(['waiting', 'snapshotting', 'restarting', 'back']);
39
+
40
+ /** After this long the snapshot phase is reported as slow (5 minutes). */
41
+ export const SLOW_TOTAL_MS = 300000;
42
+
43
+ /** After this long without a byte of progress the snapshot is reported as stalled. */
44
+ export const SLOW_STALL_MS = 60000;
45
+
46
+ /** @param {unknown} value */
47
+ function finiteNumber(value) {
48
+ const number = Number(value);
49
+ return Number.isFinite(number) ? number : null;
50
+ }
51
+
52
+ /** @param {unknown} value */
53
+ function nonNegative(value) {
54
+ const number = finiteNumber(value);
55
+ return number === null ? null : Math.max(0, number);
56
+ }
57
+
58
+ /**
59
+ * Is this one of the four stages?
60
+ *
61
+ * @param {unknown} phase
62
+ * @returns {boolean}
63
+ */
64
+ export function isRestartPhase(phase) {
65
+ return typeof phase === 'string' && RESTART_PHASES.includes(phase);
66
+ }
67
+
68
+ /**
69
+ * The stage that is shown, when the browser cannot show a progress line.
70
+ *
71
+ * @param {string} phase
72
+ * @returns {boolean} whether the restart may still be called off
73
+ */
74
+ export function isCancellable(phase) {
75
+ // Once the host is shutting down, a cancel would be a lie: the snapshot is
76
+ // already the rollback point and the process is on its way out.
77
+ return phase === 'waiting' || phase === 'snapshotting';
78
+ }
79
+
80
+ /**
81
+ * Why a cancel is refused at this stage (null when it is allowed).
82
+ *
83
+ * @param {string} phase
84
+ * @returns {string|null}
85
+ */
86
+ export function cancelRefusalReason(phase) {
87
+ if (isCancellable(phase)) return null;
88
+ if (phase === 'restarting') return 'the host is already restarting';
89
+ if (phase === 'back') return 'the restart is already finished';
90
+ return 'there is no restart to cancel';
91
+ }
92
+
93
+ /**
94
+ * "1.2 MB" / "156 MB" — the size the user actually sees.
95
+ *
96
+ * Rounded on purpose: `153.7 MB` is noise, `154 MB` is a fact. Below one
97
+ * megabyte the kilobyte value is used, so a small profile still shows motion.
98
+ *
99
+ * @param {number|null} bytes
100
+ * @returns {string|null}
101
+ */
102
+ export function formatBytes(bytes) {
103
+ const value = nonNegative(bytes);
104
+ if (value === null) return null;
105
+ if (value < 1024) return value + ' B';
106
+ if (value < 1024 * 1024) return Math.round(value / 1024) + ' KB';
107
+ return Math.round((value / (1024 * 1024)) * 10) / 10 + ' MB';
108
+ }
109
+
110
+ /**
111
+ * How long the rest of the copy will take, from the speed so far.
112
+ *
113
+ * It is only offered when there is something to base it on (real bytes, at
114
+ * least a second of real copying and a total to compare with). The result is
115
+ * capped at "a long time", so a copy that slowed to a crawl does not promise
116
+ * "4 seconds left" for ten minutes.
117
+ *
118
+ * @param {{ bytes?: number|null, total?: number|null, elapsedMs?: number|null }} [progress]
119
+ * @returns {number|null} seconds, or null when it cannot be estimated
120
+ */
121
+ export function estimateRemainingSeconds({ bytes = null, total = null, elapsedMs = null } = {}) {
122
+ const done = nonNegative(bytes);
123
+ const all = nonNegative(total);
124
+ const elapsed = nonNegative(elapsedMs);
125
+ if (done === null || all === null || elapsed === null) return null;
126
+ if (done <= 0 || all <= 0 || elapsed < 1000) return null;
127
+ if (done >= all) return 0;
128
+ const perByte = elapsed / done;
129
+ const seconds = Math.ceil((all - done) * perByte / 1000);
130
+ if (!Number.isFinite(seconds)) return null;
131
+ return Math.min(Math.max(0, seconds), 3600);
132
+ }
133
+
134
+ /**
135
+ * The stage, as the interface shows it.
136
+ *
137
+ * @param {{
138
+ * phase?: string,
139
+ * startedAt?: number|null,
140
+ * at?: number|null,
141
+ * progress?: { files?: number|null, bytes?: number|null, total?: number|null, filesTotal?: number|null, at?: number|null }|null,
142
+ * blocking?: number|null,
143
+ * now?: number,
144
+ * }} [options]
145
+ * @returns {{
146
+ * phase: string,
147
+ * startedAt: number|null,
148
+ * at: number|null,
149
+ * elapsedMs: number|null,
150
+ * progress: { files: number|null, total: number|null, filesTotal: number|null, bytes: number|null, percent: number|null, ageMs: number|null, at: number|null }|null,
151
+ * slow: 'total'|'stalled'|null,
152
+ * cancellable: boolean,
153
+ * cancelReason: string|null,
154
+ * estimateSeconds: number|null,
155
+ * text: { zh: string, en: string },
156
+ * }}
157
+ */
158
+ export function describeRestartPhase({
159
+ phase = 'idle',
160
+ startedAt = null,
161
+ at = null,
162
+ progress = null,
163
+ blocking = null,
164
+ now = Date.now(),
165
+ } = {}) {
166
+ const stage = isRestartPhase(phase) ? phase : 'idle';
167
+ const started = finiteNumber(startedAt);
168
+ const moment = finiteNumber(at);
169
+ const clock = finiteNumber(now) ?? Date.now();
170
+ const elapsedMs = started === null ? null : Math.max(0, clock - started);
171
+
172
+ let measured = null;
173
+ if (progress && typeof progress === 'object') {
174
+ const bytes = nonNegative(progress.bytes);
175
+ const total = nonNegative(progress.total);
176
+ const files = nonNegative(progress.files);
177
+ const filesTotal = nonNegative(progress.filesTotal);
178
+ const measuredAt = finiteNumber(progress.at);
179
+ const percent = bytes !== null && total !== null && total > 0
180
+ ? Math.min(100, Math.round((bytes / total) * 100))
181
+ : null;
182
+ measured = {
183
+ files,
184
+ total,
185
+ filesTotal,
186
+ bytes,
187
+ percent,
188
+ // The age of the measurement: a counter that stopped moving is what
189
+ // "stalled" means, and it is measured from the last update, not invented.
190
+ ageMs: measuredAt === null ? null : Math.max(0, clock - measuredAt),
191
+ at: measuredAt,
192
+ };
193
+ }
194
+
195
+ let slow = null;
196
+ if (stage === 'snapshotting') {
197
+ if (elapsedMs !== null && elapsedMs >= SLOW_TOTAL_MS) slow = 'total';
198
+ else if (measured !== null && measured.ageMs !== null && measured.ageMs >= SLOW_STALL_MS) slow = 'stalled';
199
+ }
200
+
201
+ const seconds = estimateRemainingSeconds({
202
+ bytes: measured === null ? null : measured.bytes,
203
+ total: measured === null ? null : measured.total,
204
+ elapsedMs,
205
+ });
206
+
207
+ const estimateSeconds = stage === 'snapshotting' ? seconds : null;
208
+ const done = measured === null ? null : formatBytes(measured.bytes);
209
+
210
+ let text;
211
+ if (stage === 'waiting') {
212
+ const count = nonNegative(blocking);
213
+ const suffixZh = count && count > 0 ? '(还有 ' + count + ' 个会话在忙)' : '';
214
+ const suffixEn = count && count > 0 ? ' (' + count + (count === 1 ? ' conversation' : ' conversations') + ' busy)' : '';
215
+ text = {
216
+ zh: '正在等待所有会话空闲…' + suffixZh,
217
+ en: 'Waiting for every conversation to be idle…' + suffixEn,
218
+ };
219
+ } else if (stage === 'snapshotting') {
220
+ // The number appears ONLY when it was measured. "预计 X 秒" needs a real
221
+ // speed behind it, otherwise the text says what it knows and nothing more.
222
+ const etaZh = estimateSeconds === null ? '' : ',预计 ' + estimateSeconds + ' 秒';
223
+ const etaEn = estimateSeconds === null ? '' : ', about ' + estimateSeconds + 's left';
224
+ const doneZh = done === null ? '' : ',已完成 ' + done;
225
+ const doneEn = done === null ? '' : ', ' + done + ' done';
226
+ text = {
227
+ zh: '正在备份配置和插件' + etaZh + doneZh + '…',
228
+ en: 'Backing up the configuration and the plugins' + etaEn + doneEn + '…',
229
+ };
230
+ if (slow !== null) {
231
+ text = {
232
+ zh: '备份比预期慢(可能磁盘很忙),还在继续…' + (done === null ? '' : '(已完成 ' + done + ')'),
233
+ en: 'The backup is slower than expected (the disk may be busy); it is still running…'
234
+ + (done === null ? '' : ' (' + done + ' done)'),
235
+ };
236
+ }
237
+ } else if (stage === 'restarting') {
238
+ text = {
239
+ zh: '正在重启,稍后自动继续…',
240
+ en: 'Restarting; the work continues by itself in a moment…',
241
+ };
242
+ } else if (stage === 'back') {
243
+ text = {
244
+ zh: '已回来,接着干',
245
+ en: 'Back — carrying on where we left off',
246
+ };
247
+ } else {
248
+ text = { zh: '', en: '' };
249
+ }
250
+
251
+ return {
252
+ phase: stage,
253
+ startedAt: started,
254
+ at: moment,
255
+ elapsedMs,
256
+ progress: measured,
257
+ slow,
258
+ cancellable: isCancellable(stage),
259
+ cancelReason: cancelRefusalReason(stage),
260
+ estimateSeconds,
261
+ text,
262
+ };
263
+ }