@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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/README.zh.md +163 -0
- package/cordis.patch.yml +17 -0
- package/helper/restart-helper.mjs +828 -0
- package/lib/client.js +1706 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1401 -0
- package/lib/types/client/RestartPanel.d.ts +5 -0
- package/lib/types/client/api.d.ts +230 -0
- package/lib/types/client/floating.d.ts +2 -0
- package/lib/types/client/index.d.ts +8 -0
- package/lib/types/client/overlay.d.ts +2 -0
- package/lib/types/client/state.d.ts +79 -0
- package/lib/types/config.d.ts +125 -0
- package/lib/types/index.d.ts +39 -0
- package/lib/types/launchd.d.ts +80 -0
- package/lib/types/restart.d.ts +236 -0
- package/lib/types/routes.d.ts +66 -0
- package/lib/types/tools.d.ts +25 -0
- package/package.json +96 -0
|
@@ -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 ({'&':'&','<':'<','>':'>'})[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
|
+
})
|