@zhengjunyao/dsh-restart 0.1.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.
@@ -0,0 +1,828 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-restart — detached restart helper.
4
+ *
5
+ * Spawned detached by the host plugin the moment a restart is requested. It
6
+ * outlives the DSH process, so it can:
7
+ *
8
+ * 1. wait for the old host to release its listening port,
9
+ * 2. relaunch the exact same `dsh` invocation (same argv / cwd / env),
10
+ * 3. stream the new process's stdout+stderr into a log file while keeping an
11
+ * in-memory tail,
12
+ * 4. report every transition to status.json,
13
+ * 5. serve a small recovery console on a fallback port — the only way to show
14
+ * WHY a restart failed, because when the new host dies the main port is
15
+ * dead too and the browser has nothing left to talk to.
16
+ *
17
+ * Plain Node ESM with zero dependencies: it must run even when the profile is
18
+ * broken (a plugin that fails to load is exactly when it is needed).
19
+ *
20
+ * node restart-helper.mjs --spec /path/to/pending-spec.json
21
+ *
22
+ * Exit: 0 once the new host answers (after a short linger); if it never does,
23
+ * the process stays alive serving the console so the failure can be read,
24
+ * copied and retried.
25
+ */
26
+
27
+ import { spawn } from 'node:child_process'
28
+ import { appendFileSync, closeSync, openSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
29
+ import { createServer } from 'node:http'
30
+ import { connect } from 'node:net'
31
+
32
+ // ---------------------------------------------------------------- spec input
33
+
34
+ /** Parse `--key value` pairs (the only CLI shape this helper accepts). */
35
+ function parseArgs(argv) {
36
+ const out = {}
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const token = argv[i]
39
+ if (!token.startsWith('--')) continue
40
+ const key = token.slice(2)
41
+ const value = argv[i + 1]
42
+ if (value === undefined || value.startsWith('--')) out[key] = true
43
+ else {
44
+ out[key] = value
45
+ i++
46
+ }
47
+ }
48
+ return out
49
+ }
50
+
51
+ const args = parseArgs(process.argv.slice(2))
52
+ const specPath = typeof args.spec === 'string' ? args.spec : null
53
+ if (specPath === null) {
54
+ console.error('restart-helper: --spec <path> is required')
55
+ process.exit(2)
56
+ }
57
+
58
+ /** @type {any} */
59
+ let spec
60
+ try {
61
+ spec = JSON.parse(readFileSync(specPath, 'utf8'))
62
+ } catch (error) {
63
+ console.error('restart-helper: cannot read spec: ' + String(error?.message ?? error))
64
+ process.exit(2)
65
+ }
66
+
67
+ const PORT = Number(spec.port) || 3080
68
+ const HOST = typeof spec.host === 'string' ? spec.host : '127.0.0.1'
69
+ const URL_BASE = typeof spec.url === 'string' ? spec.url : `http://${HOST}:${PORT}`
70
+ const FALLBACK_PORT = Number(spec.fallbackPort) || 3099
71
+ const LOG_FILE = typeof spec.logFile === 'string' ? spec.logFile : ''
72
+ const STATUS_FILE = typeof spec.statusFile === 'string' ? spec.statusFile : ''
73
+ /**
74
+ * Single self-contained file written on a failed restart: the one artifact a
75
+ * human can copy wholesale into an AI chat (status.json and the raw log are
76
+ * machine-shaped and scattered).
77
+ */
78
+ const FAILURE_REPORT =
79
+ typeof spec.failureReport === 'string' && spec.failureReport !== ''
80
+ ? spec.failureReport
81
+ : STATUS_FILE === ''
82
+ ? ''
83
+ : STATUS_FILE.replace(/status\.json$/, 'last-failure.md')
84
+ const BOOT_TIMEOUT_MS = Number(spec.bootTimeoutMs) || 120_000
85
+ const KILL_GRACE_MS = Number(spec.killGraceMs) || 6_000
86
+ const PORT_FREE_TIMEOUT_MS = Number(spec.portFreeTimeoutMs) || 25_000
87
+ const MAX_ATTEMPTS = Math.max(1, Number(spec.maxAttempts) || 2)
88
+ const RING_LINES = Math.max(200, Number(spec.ringLines) || 600)
89
+ const LINGER_MS = Math.max(0, Number(spec.lingerMs) || 4_000)
90
+ /**
91
+ * `spawn` (default): this helper relaunches the host itself.
92
+ * `observe`: something else owns the relaunch (a launchd job, a supervisor);
93
+ * the helper only waits, serves the console, and tails the log that owner
94
+ * writes — spawning a second host here would race it for the port.
95
+ */
96
+ const MODE =
97
+ spec.mode === 'observe' && Array.isArray(spec.kickCommand) && spec.kickCommand.length > 0
98
+ ? 'observe'
99
+ : 'spawn'
100
+ /** File the owning launcher writes its output to (observe mode). */
101
+ const OBSERVE_LOG = typeof spec.observeLog === 'string' ? spec.observeLog : ''
102
+ /**
103
+ * How long to wait before kicking a managed host: the HTTP reply that announced
104
+ * this restart has to be on the wire first, and a launchd restart terminates
105
+ * the process that is still writing it.
106
+ */
107
+ const KICK_DELAY_MS = Math.max(0, Number(spec.kickDelayMs) || 1_200)
108
+
109
+ // ------------------------------------------------------------- status + logs
110
+
111
+ const startedAt = Date.now()
112
+ let phase = 'starting'
113
+ let attempt = 1
114
+ let childPid = null
115
+ let childExit = null
116
+ let readyAt = null
117
+ let failure = null
118
+ const ring = []
119
+ const errors = []
120
+
121
+ /** Strip ANSI escapes so the console renders cleanly in a browser. */
122
+ const ANSI = /\u001B\[[0-9;]*[A-Za-z]/g
123
+
124
+ /**
125
+ * Lines that usually carry the reason a boot failed.
126
+ *
127
+ * The stack-frame branch insists on a real file-ish frame (`…/x.js:12:5`,
128
+ * `node:internal/…:1:2`) — a bare `\bat .+:\d+:\d+` also matches timestamps
129
+ * like "restart requested at 2026-09-12T02:08:18Z" and would fill the error
130
+ * list with noise.
131
+ */
132
+ const ERROR_HINT = new RegExp(
133
+ [
134
+ '\\bError\\b', '\\bERROR\\b', 'error:', 'EADDRINUSE', 'ECONNREFUSED', 'ENOENT', 'EACCES',
135
+ 'MODULE_NOT_FOUND', 'Cannot find (module|package)', 'UnhandledPromiseRejection',
136
+ 'uncaughtException', 'FATAL', 'fatal:', 'SyntaxError', 'TypeError', 'ReferenceError',
137
+ 'is not a function', 'failed to load', '加载失败', '启动失败',
138
+ '\\bat\\s+.*(?:\\.(?:js|mjs|cjs|ts|tsx|jsx|json)|node:[\\w/]+):\\d+:\\d+',
139
+ ].join('|'),
140
+ )
141
+
142
+ /** Append one line to the log file and the in-memory ring. */
143
+ function record(line, stream = 'out') {
144
+ const text = String(line).replace(ANSI, '')
145
+ const at = Date.now()
146
+ ring.push({ t: at, s: stream, text })
147
+ if (ring.length > RING_LINES) ring.splice(0, ring.length - RING_LINES)
148
+ // Only the host's own output can explain a failed boot; our 'sys' lines are
149
+ // narration and would otherwise trip the detector on their own wording.
150
+ if (stream !== 'sys' && ERROR_HINT.test(text) && errors.length < 120) errors.push({ t: at, text })
151
+ if (LOG_FILE !== '') {
152
+ try {
153
+ appendFileSync(LOG_FILE, text + '\n')
154
+ } catch {
155
+ /* logging must never kill the helper */
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Split a stream chunk into lines, carrying the partial remainder. */
161
+ function makeLineSplitter(stream) {
162
+ let buffer = ''
163
+ return (chunk) => {
164
+ buffer += chunk.toString('utf8')
165
+ const parts = buffer.split(/\r?\n/)
166
+ buffer = parts.pop() ?? ''
167
+ for (const part of parts) if (part !== '') record(part, stream)
168
+ }
169
+ }
170
+
171
+ /** Fallback port actually bound (null until the console is listening). */
172
+ let activeFallbackPort = null
173
+
174
+ /** Snapshot written to status.json and served at GET /status. */
175
+ function snapshot() {
176
+ return {
177
+ ok: true,
178
+ helper: 'dsh-restart',
179
+ helperPid: process.pid,
180
+ mode: MODE,
181
+ phase,
182
+ attempt,
183
+ maxAttempts: MAX_ATTEMPTS,
184
+ port: PORT,
185
+ url: URL_BASE,
186
+ fallbackPort: activeFallbackPort,
187
+ fallbackUrl: activeFallbackPort === null ? '' : `http://${HOST}:${activeFallbackPort}`,
188
+ oldPid: spec.oldPid ?? null,
189
+ childPid,
190
+ childExit,
191
+ startedAt: new Date(startedAt).toISOString(),
192
+ elapsedMs: Date.now() - startedAt,
193
+ readyAt: readyAt === null ? null : new Date(readyAt).toISOString(),
194
+ bootMs: readyAt === null ? null : readyAt - startedAt,
195
+ failure,
196
+ logFile: LOG_FILE === '' ? null : LOG_FILE,
197
+ statusFile: STATUS_FILE === '' ? null : STATUS_FILE,
198
+ failureReport: FAILURE_REPORT === '' ? null : FAILURE_REPORT,
199
+ errorLines: errors.slice(-25),
200
+ tail: ring.slice(-150).map((entry) => entry.text),
201
+ dshVersion: spec.dshVersion ?? null,
202
+ profile: spec.profile ?? null,
203
+ argv: Array.isArray(spec.args) ? spec.args : [],
204
+ }
205
+ }
206
+
207
+ /** Persist the snapshot (atomic; readers never see a half-written file). */
208
+ function persist() {
209
+ if (STATUS_FILE === '') return
210
+ try {
211
+ const tmp = `${STATUS_FILE}.${process.pid}.tmp`
212
+ writeFileSync(tmp, JSON.stringify(snapshot(), null, 2))
213
+ renameSync(tmp, STATUS_FILE)
214
+ } catch {
215
+ /* best effort */
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Write the one file a human (or an AI) can copy wholesale after a failure.
221
+ *
222
+ * Everything needed to diagnose a broken boot is already in this process, but
223
+ * scattered across status.json, an error list and a raw log — so a failed
224
+ * restart also drops a single self-contained report next to them.
225
+ */
226
+ function buildFailureReport() {
227
+ const errorLines = errors.slice(-40).map((entry) => entry.text)
228
+ const tail = ring.slice(-150).map((entry) => entry.text)
229
+ const report = [
230
+ '# DSH 重启失败报告',
231
+ '',
232
+ `- 时间:${new Date().toISOString()}`,
233
+ `- 目标地址:${URL_BASE}`,
234
+ `- 重启方式:${MODE}${spec.owner === undefined ? '' : `(${spec.owner})`}`,
235
+ `- DSH 版本:${spec.dshVersion ?? '未知'} profile:${spec.profile ?? '未知'}`,
236
+ `- 旧进程 pid:${spec.oldPid ?? '?'} 新进程 pid:${childPid ?? '未起来'}`,
237
+ `- 启动命令:${[spec.file, ...(Array.isArray(spec.args) ? spec.args : [])].join(' ')}`,
238
+ `- 工作目录:${spec.cwd ?? process.cwd()}`,
239
+ `- 尝试次数:${attempt}/${MAX_ATTEMPTS}`,
240
+ childExit === null
241
+ ? null
242
+ : `- 退出码:${childExit.code}${childExit.signal === null ? '' : ` / ${childExit.signal}`}`,
243
+ `- 失败原因:${failure === null ? '未知' : failure.message}`,
244
+ `- 完整日志:${LOG_FILE === '' ? '(未启用)' : LOG_FILE}`,
245
+ `- 恢复控制台:http://${HOST}:${activeFallbackPort ?? FALLBACK_PORT}`,
246
+ '',
247
+ '## 疑似报错行',
248
+ '',
249
+ '```',
250
+ errorLines.length === 0 ? '(未识别出明显的报错行,请直接看下方完整输出)' : errorLines.join('\n'),
251
+ '```',
252
+ '',
253
+ '## 启动输出(最后 150 行)',
254
+ '',
255
+ '```',
256
+ tail.length === 0 ? '(无输出)' : tail.join('\n'),
257
+ '```',
258
+ '',
259
+ ]
260
+ .filter((line) => line !== null)
261
+ .join('\n')
262
+ return report
263
+ }
264
+
265
+ /** Persist the report; a failure here must never mask the failure itself. */
266
+ function writeFailureReport() {
267
+ if (FAILURE_REPORT === '') return
268
+ try {
269
+ writeFileSync(FAILURE_REPORT, buildFailureReport(), { mode: 0o600 })
270
+ record(`[dsh-restart] failure report written: ${FAILURE_REPORT}`, 'sys')
271
+ } catch {
272
+ /* best effort */
273
+ }
274
+ }
275
+
276
+ /** Update phase + persist + log the transition. */
277
+ function setPhase(next, note = '') {
278
+ phase = next
279
+ if (note !== '') record(`[dsh-restart] ${note}`, 'sys')
280
+ if (next === 'failed') writeFailureReport()
281
+ persist()
282
+ }
283
+
284
+ // ------------------------------------------------------------------ probing
285
+
286
+ /** Resolve true when something accepts a TCP connection on host:port. */
287
+ function portOpen(port, host, timeoutMs = 800) {
288
+ return new Promise((resolve) => {
289
+ const socket = connect({ port, host })
290
+ let settled = false
291
+ const done = (value) => {
292
+ if (settled) return
293
+ settled = true
294
+ socket.destroy()
295
+ resolve(value)
296
+ }
297
+ socket.setTimeout(timeoutMs)
298
+ socket.once('connect', () => done(true))
299
+ socket.once('timeout', () => done(false))
300
+ socket.once('error', () => done(false))
301
+ })
302
+ }
303
+
304
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
305
+
306
+ /** Poll until the port is free (old host exited) or the budget runs out. */
307
+ async function waitPortFree(port, host, timeoutMs) {
308
+ const deadline = Date.now() + timeoutMs
309
+ for (;;) {
310
+ if (!(await portOpen(port, host))) return true
311
+ if (Date.now() >= deadline) return false
312
+ await sleep(200)
313
+ }
314
+ }
315
+
316
+ /** Poll until the port answers, bailing out early when the child died. */
317
+ async function waitPortReady(port, host, timeoutMs, child) {
318
+ const deadline = Date.now() + timeoutMs
319
+ for (;;) {
320
+ if (child !== null && child.exitCode !== null) return false
321
+ if (await portOpen(port, host)) return true
322
+ if (Date.now() >= deadline) return false
323
+ await sleep(300)
324
+ }
325
+ }
326
+
327
+ // --------------------------------------------------------------- child spawn
328
+
329
+ /** Launch the new host, wiring both output streams into the log. */
330
+ function launch() {
331
+ const file = spec.file
332
+ const argv = Array.isArray(spec.args) ? spec.args : []
333
+ const cwd = typeof spec.cwd === 'string' && spec.cwd !== '' ? spec.cwd : process.cwd()
334
+ const env = { ...(spec.env ?? process.env), DSH_RESTART_HELPER_PID: String(process.pid) }
335
+ record(`[dsh-restart] attempt ${attempt}/${MAX_ATTEMPTS}: ${file} ${argv.join(' ')}`, 'sys')
336
+ record(`[dsh-restart] cwd: ${cwd}`, 'sys')
337
+ let child
338
+ try {
339
+ child = spawn(file, argv, { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] })
340
+ } catch (error) {
341
+ failure = { kind: 'spawn', message: String(error?.message ?? error) }
342
+ record(`[dsh-restart] spawn failed: ${failure.message}`, 'sys')
343
+ return null
344
+ }
345
+ childPid = child.pid ?? null
346
+ child.on('error', (error) => {
347
+ failure = { kind: 'spawn', message: String(error?.message ?? error) }
348
+ record(`[dsh-restart] child error: ${failure.message}`, 'sys')
349
+ persist()
350
+ })
351
+ child.stdout?.on('data', makeLineSplitter('out'))
352
+ child.stderr?.on('data', makeLineSplitter('err'))
353
+ child.on('exit', (code, signal) => {
354
+ childExit = { code, signal, at: new Date().toISOString() }
355
+ record(`[dsh-restart] child exited: code=${code} signal=${signal}`, 'sys')
356
+ persist()
357
+ })
358
+ child.unref()
359
+ for (const stream of [child.stdout, child.stderr, child.stdin]) stream?.unref?.()
360
+ return child
361
+ }
362
+
363
+ /** Terminate the old host if it is still holding the port. */
364
+ function reapOldHost() {
365
+ const oldPid = Number(spec.oldPid)
366
+ if (!Number.isInteger(oldPid) || oldPid <= 1 || oldPid === process.pid) return
367
+ try {
368
+ process.kill(oldPid, 0) // still alive?
369
+ } catch {
370
+ return
371
+ }
372
+ record(`[dsh-restart] old host ${oldPid} still alive after the port-free wait; sending SIGKILL`, 'sys')
373
+ try {
374
+ process.kill(oldPid, 'SIGKILL')
375
+ } catch (error) {
376
+ record(`[dsh-restart] SIGKILL failed: ${String(error?.message ?? error)}`, 'sys')
377
+ }
378
+ }
379
+
380
+ // ------------------------------------------------------------------- console
381
+
382
+ const HTML = `<!doctype html>
383
+ <html lang="zh-CN"><head><meta charset="utf-8" />
384
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
385
+ <title>DSH 重启控制台</title>
386
+ <style>
387
+ :root{color-scheme:light dark}
388
+ *{box-sizing:border-box}
389
+ body{margin:0;padding:28px;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB",sans-serif;
390
+ background:#f6f7f9;color:#1a1d21}
391
+ @media (prefers-color-scheme:dark){body{background:#16181c;color:#e6e8eb}
392
+ .card{background:#1e2126!important;border-color:#2c3038!important}
393
+ pre{background:#14161a!important;border-color:#2c3038!important}
394
+ .muted{color:#9aa1ab!important}}
395
+ .wrap{max-width:900px;margin:0 auto;display:flex;flex-direction:column;gap:16px}
396
+ h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
397
+ .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
398
+ .card{background:#fff;border:1px solid #e3e5e9;border-radius:10px;padding:16px 18px;
399
+ display:flex;flex-direction:column;gap:12px}
400
+ .badge{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;
401
+ font-size:12px;font-weight:600;border:1px solid transparent}
402
+ .b-wait{background:#fff4e5;color:#9a5b00;border-color:#f2d9b0}
403
+ .b-ok{background:#e7f5ec;color:#1d6b3a;border-color:#b9e0c8}
404
+ .b-bad{background:#fdeaea;color:#a02020;border-color:#f3c2c2}
405
+ .b-run{background:#e8f0fe;color:#1a4fa0;border-color:#bed2f5}
406
+ @media (prefers-color-scheme:dark){
407
+ .b-wait{background:#3a2c14;color:#f0b866;border-color:#5a4520}
408
+ .b-ok{background:#15301f;color:#7fd3a0;border-color:#25503a}
409
+ .b-bad{background:#3a1a1a;color:#f09a9a;border-color:#5c2a2a}
410
+ .b-run{background:#16233d;color:#9dbdf5;border-color:#27395c}}
411
+ .kv{display:grid;grid-template-columns:150px 1fr;gap:6px 14px;font-size:13px}
412
+ .kv div:nth-child(odd){color:#6b7280}
413
+ .muted{color:#6b7280}
414
+ code{font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;background:#f2f3f5;
415
+ border:1px solid #e3e5e9;border-radius:4px;padding:1px 5px}
416
+ pre{margin:0;max-height:420px;overflow:auto;padding:12px;border-radius:8px;background:#f2f3f5;
417
+ border:1px solid #e3e5e9;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;
418
+ white-space:pre-wrap;word-break:break-word}
419
+ button{font:inherit;font-size:13px;padding:7px 14px;border-radius:7px;border:1px solid #d2d6dd;
420
+ background:#fff;color:inherit;cursor:pointer}
421
+ button:hover{border-color:#9aa1ab}
422
+ button.primary{background:#2b6cb0;border-color:#2b6cb0;color:#fff}
423
+ button.primary:hover{background:#255d99}
424
+ .err{color:#c0392b}
425
+ @media (prefers-color-scheme:dark){.err{color:#ff8a80}}
426
+ </style></head>
427
+ <body><div class="wrap">
428
+ <div class="row"><h1>DSH 重启控制台</h1><span id="badge" class="badge b-wait">…</span></div>
429
+ <div class="card">
430
+ <div class="kv" id="kv"></div>
431
+ <div class="row">
432
+ <button class="primary" id="open">打开 DSH</button>
433
+ <button id="retry">重试启动</button>
434
+ <button id="recheck">重新检测</button>
435
+ <button id="copy">复制报错</button>
436
+ <button id="copyAll">复制完整报告</button>
437
+ </div>
438
+ <div class="muted" id="hint" style="font-size:12px"></div>
439
+ </div>
440
+ <div class="card" id="errCard" style="display:none">
441
+ <div class="row"><strong>检测到的报错</strong><span class="muted" id="errCount" style="font-size:12px"></span></div>
442
+ <pre id="err"></pre>
443
+ </div>
444
+ <div class="card">
445
+ <div class="row"><strong>启动日志</strong><span class="muted" id="logMeta" style="font-size:12px"></span></div>
446
+ <pre id="log">加载中…</pre>
447
+ </div>
448
+ </div>
449
+ <script>
450
+ var $ = function(id){ return document.getElementById(id) }
451
+ var badge=$('badge'), kv=$('kv'), log=$('log'), errCard=$('errCard'), errPre=$('err'),
452
+ errCount=$('errCount'), hint=$('hint'), logMeta=$('logMeta')
453
+ var dshUrl = '/', lastLog = '', opened = false
454
+ var LABEL = { 'waiting-port-free':'等待旧进程退出', starting:'正在启动', 'waiting-ready':'等待就绪',
455
+ ready:'已就绪', retrying:'正在重试', failed:'启动失败' }
456
+ var CLASS = { 'waiting-port-free':'b-run', starting:'b-run', 'waiting-ready':'b-run',
457
+ ready:'b-ok', retrying:'b-wait', failed:'b-bad' }
458
+ function esc(t){ return String(t).replace(/[&<>]/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;'})[c] }) }
459
+ function render(s){
460
+ dshUrl = s.url || '/'
461
+ badge.textContent = LABEL[s.phase] || s.phase
462
+ badge.className = 'badge ' + (CLASS[s.phase] || 'b-run')
463
+ var rows = [
464
+ ['DSH 地址', '<code>' + esc(s.url || '') + '</code>'],
465
+ ['本次尝试', s.attempt + ' / ' + s.maxAttempts],
466
+ ['新进程 PID', s.childPid == null ? '—' : s.childPid],
467
+ ['已耗时', (s.elapsedMs/1000).toFixed(1) + ' s'],
468
+ ['启动耗时', s.bootMs == null ? '—' : (s.bootMs/1000).toFixed(1) + ' s'],
469
+ ['退出码', s.childExit == null ? '—' : (s.childExit.code + (s.childExit.signal ? ' / ' + s.childExit.signal : ''))],
470
+ ['DSH 版本', s.dshVersion || '—'],
471
+ ['日志文件', s.logFile ? '<code>' + esc(s.logFile) + '</code>' : '—']
472
+ ]
473
+ if (s.failure && s.failure.message) rows.push(['失败原因', '<span class="err">' + esc(s.failure.message) + '</span>'])
474
+ kv.innerHTML = rows.map(function(r){ return '<div>' + r[0] + '</div><div>' + r[1] + '</div>' }).join('')
475
+ var text = (s.tail || []).join('\\n')
476
+ if (text !== lastLog){ lastLog = text; log.textContent = text || '(暂无输出)'; log.scrollTop = log.scrollHeight }
477
+ logMeta.textContent = (s.tail || []).length + ' 行'
478
+ var errs = s.errorLines || []
479
+ if (errs.length > 0){
480
+ errCard.style.display = ''
481
+ errCount.textContent = errs.length + ' 条'
482
+ errPre.innerHTML = errs.map(function(e){ return '<span class="err">' + esc(e.text) + '</span>' }).join('\\n')
483
+ } else { errCard.style.display = 'none' }
484
+ if (s.phase === 'ready'){
485
+ hint.textContent = '新进程已就绪,正在跳转…'
486
+ if (!opened){ opened = true; setTimeout(function(){ location.href = dshUrl }, 800) }
487
+ } else if (s.phase === 'failed'){
488
+ hint.textContent = '启动失败:请根据上方报错修复后点「重试启动」。'
489
+ } else {
490
+ hint.textContent = '重启进行中,本页每 1.5 秒自动刷新。'
491
+ }
492
+ }
493
+ function poll(){
494
+ fetch('/status', { cache:'no-store' }).then(function(r){ return r.json() }).then(render)
495
+ .catch(function(){ badge.textContent='控制台已退出'; badge.className='badge b-ok' })
496
+ }
497
+ $('open').onclick = function(){ location.href = dshUrl }
498
+ $('retry').onclick = function(){ fetch('/retry', { method:'POST' }).then(function(){ lastLog=''; opened=false; poll() }) }
499
+ $('recheck').onclick = function(){ fetch('/recheck', { method:'POST' }).then(poll) }
500
+ $('copyAll').onclick = function(){
501
+ fetch('/report').then(function(r){ return r.text() }).then(function(t){
502
+ if (navigator.clipboard) navigator.clipboard.writeText(t).then(function(){ hint.textContent = '完整报告已复制,可直接粘贴给 AI 定位' })
503
+ else { var w = window.open('', '_blank'); if (w) w.document.write('<pre>' + esc(t) + '</pre>') }
504
+ })
505
+ }
506
+ $('copy').onclick = function(){
507
+ var text = [badge.textContent, errPre.textContent || '', lastLog].join('\\n\\n')
508
+ if (navigator.clipboard) navigator.clipboard.writeText(text).then(function(){ hint.textContent='已复制' })
509
+ }
510
+ poll(); setInterval(poll, 1500)
511
+ </script></body></html>`
512
+
513
+ /** Start the recovery console; tries FALLBACK_PORT..+9 for a free seat. */
514
+ function startConsole() {
515
+ return new Promise((resolve) => {
516
+ let candidate = FALLBACK_PORT
517
+ const server = createServer(handleConsoleRequest)
518
+ const tryListen = () => {
519
+ server.once('error', () => {
520
+ candidate += 1
521
+ if (candidate > FALLBACK_PORT + 9) {
522
+ record('[dsh-restart] no free fallback port; recovery console disabled', 'sys')
523
+ resolve(null)
524
+ return
525
+ }
526
+ tryListen()
527
+ })
528
+ server.listen(candidate, HOST, () => {
529
+ activeFallbackPort = server.address()?.port ?? candidate
530
+ record(`[dsh-restart] recovery console: http://${HOST}:${activeFallbackPort}`, 'sys')
531
+ resolve(server)
532
+ })
533
+ }
534
+ tryListen()
535
+ })
536
+ }
537
+
538
+ /**
539
+ * Follow the log the owning launcher writes (observe mode).
540
+ *
541
+ * launchd writes the restarted host's stdout/stderr to the plist's
542
+ * StandardOutPath / StandardErrorPath, so that file — not a pipe — is where the
543
+ * boot output lands. Offsets are tracked so each poll only reads what is new,
544
+ * and a truncation (log rotation) resets to the beginning.
545
+ */
546
+ function makeLogFollower(file) {
547
+ let offset = 0
548
+ let remainder = ''
549
+ return () => {
550
+ if (file === '') return
551
+ let info
552
+ try {
553
+ info = statSync(file)
554
+ } catch {
555
+ return
556
+ }
557
+ if (info.size < offset) {
558
+ offset = 0
559
+ remainder = ''
560
+ }
561
+ if (info.size === offset) return
562
+ let text
563
+ try {
564
+ const handle = openSync(file, 'r')
565
+ const length = info.size - offset
566
+ const buffer = Buffer.allocUnsafe(length)
567
+ readSync(handle, buffer, 0, length, offset)
568
+ closeSync(handle)
569
+ text = buffer.toString('utf8')
570
+ } catch {
571
+ return
572
+ }
573
+ offset = info.size
574
+ remainder += text
575
+ const parts = remainder.split(/\r?\n/)
576
+ remainder = parts.pop() ?? ''
577
+ for (const part of parts) if (part !== '') record(part, 'out')
578
+ }
579
+ }
580
+
581
+ /** Set by POST /retry — honoured after a failure. */
582
+ let requestRetry = false
583
+ /** Set by POST /recheck — re-probe the main port without relaunching. */
584
+ let requestRecheck = false
585
+
586
+ /** Console routes: page, /status, /log, /retry, /recheck (CORS-open for the panel). */
587
+ function handleConsoleRequest(req, res) {
588
+ const cors = {
589
+ 'Access-Control-Allow-Origin': '*',
590
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
591
+ 'Access-Control-Allow-Headers': 'Content-Type',
592
+ 'Cache-Control': 'no-store',
593
+ }
594
+ if (req.method === 'OPTIONS') {
595
+ res.writeHead(204, cors)
596
+ res.end()
597
+ return
598
+ }
599
+ const url = new URL(req.url ?? '/', `http://${HOST}`)
600
+ if (url.pathname === '/status') {
601
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
602
+ res.end(JSON.stringify(snapshot()))
603
+ return
604
+ }
605
+ if (url.pathname === '/report') {
606
+ // One copy-ready document for "hand this to an AI and diagnose it".
607
+ res.writeHead(200, { ...cors, 'Content-Type': 'text/markdown; charset=utf-8' })
608
+ res.end(buildFailureReport())
609
+ return
610
+ }
611
+ if (url.pathname === '/log') {
612
+ const lines = Math.max(1, Math.min(2000, Number(url.searchParams.get('lines')) || 300))
613
+ res.writeHead(200, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' })
614
+ res.end(ring.slice(-lines).map((entry) => entry.text).join('\n'))
615
+ return
616
+ }
617
+ if (url.pathname === '/retry' && req.method === 'POST') {
618
+ requestRetry = true
619
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
620
+ res.end('{"ok":true}')
621
+ return
622
+ }
623
+ if (url.pathname === '/recheck' && req.method === 'POST') {
624
+ requestRecheck = true
625
+ res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' })
626
+ res.end('{"ok":true}')
627
+ return
628
+ }
629
+ res.writeHead(200, { ...cors, 'Content-Type': 'text/html; charset=utf-8' })
630
+ res.end(HTML)
631
+ }
632
+
633
+ // --------------------------------------------------------------------- main
634
+
635
+ /** Re-run the owning launcher's kick command (observe mode, manual retry). */
636
+ function runKick() {
637
+ const command = Array.isArray(spec.kickCommand) ? spec.kickCommand : null
638
+ if (command === null || command.length === 0) {
639
+ record('[dsh-restart] no kick command available for this host', 'sys')
640
+ return false
641
+ }
642
+ record(`[dsh-restart] re-running launcher: ${command.join(' ')}`, 'sys')
643
+ try {
644
+ const child = spawn(command[0], command.slice(1), { stdio: 'ignore' })
645
+ child.on('error', (error) => record(`[dsh-restart] kick failed: ${String(error?.message ?? error)}`, 'sys'))
646
+ return true
647
+ } catch (error) {
648
+ record(`[dsh-restart] kick threw: ${String(error?.message ?? error)}`, 'sys')
649
+ return false
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Watch a host that someone else owns (launchd / a supervisor) come back.
655
+ *
656
+ * Deliberately never spawns: the owner is already restarting it, and a second
657
+ * process would race for the port. The owner's log file is followed instead so
658
+ * the console still shows the boot output.
659
+ */
660
+ async function observeBoot() {
661
+ setPhase(
662
+ attempt === 1 ? 'starting' : 'retrying',
663
+ `watching for the managed host to come back (owner: ${spec.owner ?? 'external'})`,
664
+ )
665
+ const follow = makeLogFollower(OBSERVE_LOG)
666
+ const deadline = Date.now() + BOOT_TIMEOUT_MS
667
+ for (;;) {
668
+ follow()
669
+ if (await portOpen(PORT, HOST)) {
670
+ readyAt = Date.now()
671
+ setPhase('ready', `ready after ${((readyAt - startedAt) / 1000).toFixed(1)}s`)
672
+ return 'ready'
673
+ }
674
+ if (Date.now() >= deadline) {
675
+ failure = {
676
+ kind: 'timeout',
677
+ message: `启动超时:${Math.round(BOOT_TIMEOUT_MS / 1000)} 秒内 ${URL_BASE} 没有响应(由 ${spec.owner ?? '外部托管方'} 负责拉起)`,
678
+ }
679
+ record(`[dsh-restart] ${failure.message}`, 'sys')
680
+ persist()
681
+ return 'failed'
682
+ }
683
+ await sleep(400)
684
+ }
685
+ }
686
+
687
+ /** One launch attempt; resolves 'ready' | 'failed'. */
688
+ async function attemptBoot() {
689
+ if (MODE === 'observe') return observeBoot()
690
+ setPhase(attempt === 1 ? 'starting' : 'retrying', `launching attempt ${attempt}`)
691
+ const child = launch()
692
+ if (child === null) {
693
+ failure = failure ?? { kind: 'spawn', message: 'spawn returned no process' }
694
+ return 'failed'
695
+ }
696
+ setPhase('waiting-ready', `waiting for ${URL_BASE} to answer (up to ${Math.round(BOOT_TIMEOUT_MS / 1000)}s)`)
697
+ if (await waitPortReady(PORT, HOST, BOOT_TIMEOUT_MS, child)) {
698
+ readyAt = Date.now()
699
+ setPhase('ready', `ready after ${((readyAt - startedAt) / 1000).toFixed(1)}s`)
700
+ return 'ready'
701
+ }
702
+ const code = child.exitCode
703
+ const tail = errors.slice(-3).map((entry) => entry.text).join(' | ')
704
+ failure = {
705
+ kind: code === null ? 'timeout' : 'exit',
706
+ message: code === null
707
+ ? `启动超时:${Math.round(BOOT_TIMEOUT_MS / 1000)} 秒内 ${URL_BASE} 没有响应`
708
+ : `新进程退出(code=${code})${tail === '' ? '' : ':' + tail}`,
709
+ exitCode: code,
710
+ }
711
+ record(`[dsh-restart] attempt ${attempt} failed: ${failure.message}`, 'sys')
712
+ persist()
713
+ return 'failed'
714
+ }
715
+
716
+ async function main() {
717
+ record('='.repeat(72), 'sys')
718
+ record(`[dsh-restart] restart requested at ${new Date(startedAt).toISOString()}`, 'sys')
719
+ record(`[dsh-restart] old pid ${spec.oldPid ?? '?'}, target ${URL_BASE}`, 'sys')
720
+ record(`[dsh-restart] mode: ${MODE}${MODE === 'observe' ? ` (owner: ${spec.owner ?? 'external'})` : ''}`, 'sys')
721
+ // A stale report from an earlier restart would point at the wrong failure.
722
+ if (FAILURE_REPORT !== '') {
723
+ try {
724
+ unlinkSync(FAILURE_REPORT)
725
+ } catch {
726
+ /* nothing to clear */
727
+ }
728
+ }
729
+ persist()
730
+ await startConsole()
731
+
732
+ if (OBSERVE_LOG !== '') {
733
+ // Keep following the owner's log for the helper's whole life, so the console
734
+ // stays live after readiness instead of freezing at boot.
735
+ const follow = makeLogFollower(OBSERVE_LOG)
736
+ setInterval(follow, 1_000)
737
+ record(`[dsh-restart] following ${OBSERVE_LOG}`, 'sys')
738
+ }
739
+
740
+ if (MODE === 'observe') {
741
+ // The owner will terminate this host; give the reply time to land first.
742
+ if (KICK_DELAY_MS > 0) await sleep(KICK_DELAY_MS)
743
+ runKick()
744
+ }
745
+
746
+ setPhase('waiting-port-free', `waiting for ${HOST}:${PORT} to free up`)
747
+ // In observe mode the owner is restarting the host right now: the port will
748
+ // be free for the length of one process boot. Never kill a pid we do not own.
749
+ const freeBudget = MODE === 'observe' ? Math.min(PORT_FREE_TIMEOUT_MS, 15_000) : PORT_FREE_TIMEOUT_MS
750
+ if (!(await waitPortFree(PORT, HOST, freeBudget))) {
751
+ record('[dsh-restart] port still busy after the wait', 'sys')
752
+ if (MODE === 'spawn') {
753
+ reapOldHost()
754
+ await waitPortFree(PORT, HOST, KILL_GRACE_MS)
755
+ }
756
+ }
757
+ // A supervisor (or a manual start) may have relaunched already: do not double-boot.
758
+ if (await portOpen(PORT, HOST)) {
759
+ readyAt = Date.now()
760
+ setPhase('ready', 'the port is already answering — another launcher won the race')
761
+ return finish()
762
+ }
763
+
764
+ for (;;) {
765
+ const outcome = await attemptBoot()
766
+ if (outcome === 'ready') return finish()
767
+ if (attempt < MAX_ATTEMPTS) {
768
+ attempt++
769
+ await sleep(1500)
770
+ continue
771
+ }
772
+ setPhase('failed', 'all attempts exhausted — the recovery console stays up for inspection')
773
+ if ((await waitForManualRetry()) === 'ready') return finish()
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Sit on a failed restart until someone acts on the console.
779
+ *
780
+ * @returns 'ready' when the port came back on its own, 'retry' when the operator
781
+ * asked for another attempt (the caller then re-enters the boot loop).
782
+ */
783
+ async function waitForManualRetry() {
784
+ for (;;) {
785
+ await sleep(500)
786
+ if (requestRecheck) {
787
+ requestRecheck = false
788
+ if (await portOpen(PORT, HOST)) {
789
+ readyAt = Date.now()
790
+ setPhase('ready', 'the port is answering again')
791
+ return 'ready'
792
+ }
793
+ }
794
+ if (requestRetry) {
795
+ requestRetry = false
796
+ attempt = 1
797
+ failure = null
798
+ childExit = null
799
+ errors.length = 0
800
+ record('[dsh-restart] manual retry requested', 'sys')
801
+ if (MODE === 'observe') runKick()
802
+ return 'retry'
803
+ }
804
+ }
805
+ }
806
+
807
+ /** Linger briefly, then exit — the new host owns the browser from here. */
808
+ async function finish() {
809
+ await sleep(LINGER_MS)
810
+ process.exit(0)
811
+ }
812
+
813
+ process.on('uncaughtException', (error) => {
814
+ record(`[dsh-restart] uncaught: ${String(error?.stack ?? error)}`, 'sys')
815
+ failure = { kind: 'helper', message: String(error?.message ?? error) }
816
+ persist()
817
+ })
818
+ process.on('unhandledRejection', (reason) => {
819
+ record(`[dsh-restart] unhandled rejection: ${String(reason)}`, 'sys')
820
+ persist()
821
+ })
822
+
823
+ main().catch((error) => {
824
+ record(`[dsh-restart] fatal: ${String(error?.stack ?? error)}`, 'sys')
825
+ failure = { kind: 'helper', message: String(error?.message ?? error) }
826
+ setPhase('failed', 'helper crashed')
827
+ process.exitCode = 1
828
+ })