memory-eternal 0.10.0 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/watchdog.js CHANGED
@@ -1,393 +1,528 @@
1
- // 记忆核心 · 看门狗(独立进程保活 lib/web.js)。
2
- //
3
- // 用法(经 CLI):dsh-memory watchdog [--port 7999] [--interval 5] [--max-restart 10]
4
- //
5
- // 行为:
6
- // - 启动时探测端口:活则只监督不接管;死则 spawn 新 web server
7
- // - 周期性探活:HTTP GET /memory-eternal/api/overview(不是我们服务视为死)
8
- // - 探到死:清理残留进程 → spawn 新 web server → restart 计数 +1
9
- // - restart 超过 max-restart(默认 10)→ fatal 退出,不再保活
10
- // - SIGINT/SIGTERM:优雅退出(停掉 watchdog 自己拉起的 web,但不动用户手动起的)
11
- // - 所有状态输出到 stderr(看门狗 daemon 的标准约定)
12
- //
13
- // 独立性:watchdog 完全独立于 DSH 宿主——即便旧版插件(无 autoWeb)跑的
14
- // DSH 环境,watchdog 也能拉起并保活 web server,让浏览器始终可达。
15
-
16
- import http from 'node:http'
17
- import net from 'node:net'
18
- import fs from 'node:fs'
19
- import os from 'node:os'
20
- import { spawn } from 'node:child_process'
21
- import path from 'node:path'
22
- import { fileURLToPath } from 'node:url'
23
- import process from 'node:process'
24
- import { nodeBinary, childEnv } from './node-bin.js'
25
-
26
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
27
- const WEB_JS = path.join(__dirname, 'web.js')
28
- const API_PREFIX = '/memory-eternal/api'
29
- // -- 单例锁 -------------------------------------------------------------------
30
- // 每个 DSH 启动都会 spawn 一个 detached watchdog,而旧代码没有任何存活检查 ——
31
- // 多次重启就堆积多个 watchdog 常驻轮询同一端口(dsh-memory-eternal#6)。
32
- // 这里用一个 pid 锁文件:同端口已有活着的 watchdog 就让它直接退出。
33
- const LOCK_NAME = 'memory-eternal-watchdog.json'
34
-
35
- /** 锁文件路径($DSH_HOME 下,与共享配置同处)。 */
36
- export function watchdogLockPath(env = process.env) {
37
- return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), LOCK_NAME)
38
- }
39
-
40
- /** 进程是否存活(Windows 下 process.kill(pid,0) 同样可用;EPERM 说明进程在但无权限)。 */
41
- export function isPidAlive(pid) {
42
- const n = Number(pid)
43
- if (!n || n <= 0) return false
44
- if (n === process.pid) return true
45
- try { process.kill(n, 0); return true } catch (error) { return error?.code === 'EPERM' }
46
- }
47
-
48
- /**
49
- * 读取锁文件,归一化成 { watchdogs: [{pid, port, vault, startedAt}] }。
50
- * 兼容早期单槽格式(顶层就是 {pid, port}):多端口/多 vault 并存时需要多槽。
51
- */
52
- export function readWatchdogLock(env = process.env) {
53
- let raw = null
54
- try { raw = JSON.parse(fs.readFileSync(watchdogLockPath(env), 'utf8')) } catch { return { watchdogs: [] } }
55
- if (!raw || typeof raw !== 'object') return { watchdogs: [] }
56
- if (Array.isArray(raw.watchdogs)) return { watchdogs: raw.watchdogs.filter((w) => w && w.pid) }
57
- if (raw.pid) return { watchdogs: [raw] } // 旧格式
58
- return { watchdogs: [] }
59
- }
60
-
61
- function writeWatchdogLock(env, list) {
62
- const file = watchdogLockPath(env)
63
- try {
64
- fs.mkdirSync(path.dirname(file), { recursive: true })
65
- fs.writeFileSync(file, JSON.stringify({ watchdogs: list }))
66
- } catch { /* 写不了锁不阻塞保活 */ }
67
- }
68
-
69
- /**
70
- * 尝试取得看门狗锁(按端口分槽)。
71
- * @returns {{acquired:boolean, file:string, previous:object|null, reaped:number[]}} acquired=false 表示同端口已有活着的看门狗,调用方应直接退出。
72
- */
73
- export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid } = {}) {
74
- const file = watchdogLockPath(env)
75
- const cur = readWatchdogLock(env)
76
- const samePort = cur.watchdogs.filter((w) => Number(w.port) === Number(port) && w.pid !== pid)
77
- const alive = samePort.find((w) => isPidAlive(w.pid))
78
- if (alive) return { acquired: false, file, previous: alive, reaped: [] }
79
- // 清掉死掉的槽(含其它端口的陈旧记录),保留仍然存活的其它端口
80
- const reaped = []
81
- const kept = []
82
- for (const w of cur.watchdogs) {
83
- if (Number(w.port) === Number(port)) { reaped.push(w.pid); continue }
84
- if (isPidAlive(w.pid)) kept.push(w)
85
- else reaped.push(w.pid)
86
- }
87
- const previous = samePort[0] || (reaped.length ? { pid: reaped[0] } : null)
88
- kept.push({ pid, port: Number(port), vault, startedAt: new Date().toISOString() })
89
- writeWatchdogLock(env, kept)
90
- return { acquired: true, file, previous, reaped }
91
- }
92
-
93
- /** 释放锁(只清自己那一槽;没有其它槽就删文件)。 */
94
- export function releaseWatchdogLock({ env = process.env, pid = process.pid } = {}) {
95
- const cur = readWatchdogLock(env)
96
- const kept = cur.watchdogs.filter((w) => w.pid !== pid)
97
- if (kept.length === cur.watchdogs.length) return false
98
- if (!kept.length) { try { fs.unlinkSync(watchdogLockPath(env)) } catch { /* 已不存在 */ } return true }
99
- writeWatchdogLock(env, kept)
100
- return true
101
- }
102
-
103
- /**
104
- * 解析进程列表(纯函数,便于单测)。
105
- * 支持 `ps -eo pid,args` 与 Windows 的 `ConvertTo-Csv` 两种输入。
106
- * @returns {Array<{pid:number, port:number, command:string}>} 只保留命令行里带 watchdog.js 的 node 进程
107
- */
108
- export function parseWatchdogProcesses(text, platform = process.platform) {
109
- const out = []
110
- for (const raw of String(text || '').split(/\r?\n/)) {
111
- const line = raw.trim()
112
- if (!line || !line.includes('watchdog.js')) continue
113
- let pid = 0
114
- let command = line
115
- const csv = /^"?(\d+)"?,(.*)$/.exec(line) // Windows: "1234","node ... watchdog.js --port 7999"
116
- const ps = /^(\d+)\s+(.*)$/.exec(line) // POSIX: 1234 node ... watchdog.js --port 7999
117
- if (csv) { pid = Number(csv[1]); command = csv[2].replace(/^"|"$/g, '') }
118
- else if (ps) { pid = Number(ps[1]); command = ps[2] }
119
- if (!pid) continue
120
- const m = /--port\s+(\d+)/.exec(command)
121
- out.push({ pid, port: m ? Number(m[1]) : 7999, command })
122
- }
123
- return out
124
- }
125
-
126
- /** 列出机器上所有 watchdog 进程(平台相关,失败返回空数组)。 */
127
- async function listWatchdogProcesses(exec, platform = process.platform) {
128
- if (platform === 'win32') {
129
- const ps = "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"
130
- const out = await exec('powershell', ['-NoProfile', '-Command', ps])
131
- return parseWatchdogProcesses(out, 'win32')
132
- }
133
- const out = await exec('ps', ['-eo', 'pid,args'])
134
- return parseWatchdogProcesses(out, 'linux')
135
- }
136
-
137
- /**
138
- * 回收历史遗留的孤儿看门狗(新锁只能防新增,管不了升级前已经在跑的)。
139
- * 只杀「命令行是 watchdog.js + 端口相同 + pid 不是自己 + 不在锁里存活」的进程。
140
- * @returns {Promise<{scanned:number, killed:number[], skipped:number[]}>}
141
- */
142
- export async function reapStaleWatchdogs({ port = 7999, keepPid = process.pid, env = process.env, exec, list, kill } = {}) {
143
- const runExec = exec || (async (cmd, args) => {
144
- const { spawn } = await import('node:child_process')
145
- return await new Promise((resolve) => {
146
- const c = spawn(cmd, args, { windowsHide: true })
147
- let buf = ''
148
- c.stdout.on('data', (d) => { buf += d })
149
- c.on('error', () => resolve(''))
150
- c.on('close', () => resolve(buf))
151
- })
152
- })
153
- const procs = list ? await list() : await listWatchdogProcesses(runExec)
154
- const lock = readWatchdogLock(env)
155
- const lockedAlive = new Set(lock.watchdogs.filter((w) => isPidAlive(w.pid)).map((w) => Number(w.pid)))
156
- // 绝不能杀自己(也不杀父进程):直接跑 lib/watchdog.js --reap 时,调用者自己的命令行
157
- // 就含 watchdog.js + 同端口、且不在锁里 —— 早期实现在这里把自己 SIGTERM 了,
158
- // 表现为「没有任何输出 + 退出码 1」。并发 reap 的另一个进程同理:命令行带 --reap 就放过。
159
- const protectedPids = new Set([Number(keepPid), process.pid, process.ppid].filter((n) => n > 0))
160
- const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
161
- const killed = []
162
- const skipped = []
163
- for (const p of procs) {
164
- if (Number(p.port) !== Number(port)) { skipped.push(p.pid); continue }
165
- if (protectedPids.has(Number(p.pid))) { skipped.push(p.pid); continue }
166
- if (String(p.command || '').includes('--reap')) { skipped.push(p.pid); continue } // 另一个正在 reap 的进程
167
- if (lockedAlive.has(Number(p.pid))) { skipped.push(p.pid); continue } // 锁里活着的是当前合法实例
168
- if (!isPidAlive(p.pid)) { skipped.push(p.pid); continue }
169
- if (killFn(p.pid)) killed.push(p.pid); else skipped.push(p.pid)
170
- }
171
- return { scanned: procs.length, killed, skipped }
172
- }
173
-
174
- const ts = () => new Date().toISOString().slice(11, 19) // HH:MM:SS
175
- const log = (...args) => process.stderr.write(`[${ts()}] [watchdog] ${args.join(' ')}\n`)
176
-
177
- /** 探测某端口是否在响应 + 是我们的服务(响应含 vaultDir 标记)。 */
178
- export async function probe(port, timeoutMs = 1000) {
179
- return new Promise((resolve) => {
180
- const req = http.request({ host: '127.0.0.1', port, path: `${API_PREFIX}/overview`, method: 'GET', timeout: timeoutMs }, (res) => {
181
- if (res.statusCode !== 200) return resolve(null)
182
- const chunks = []
183
- res.on('data', (c) => chunks.push(c))
184
- res.on('end', () => {
185
- try {
186
- const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
187
- if (data && data.ok === true && typeof data.vaultDir === 'string') return resolve(data)
188
- resolve(null)
189
- } catch { resolve(null) }
190
- })
191
- res.on('error', () => resolve(null))
192
- })
193
- req.on('error', () => resolve(null))
194
- req.on('timeout', () => { req.destroy(); resolve(null) })
195
- req.end()
196
- })
197
- }
198
-
199
- /** 探测端口是否有任何 TCP 监听(即便不是我们的服务也占用)。 */
200
- export async function probeTcp(port, timeoutMs = 600) {
201
- return new Promise((resolve) => {
202
- const s = new net.Socket()
203
- s.setTimeout(timeoutMs)
204
- s.once('connect', () => { s.destroy(); resolve(true) })
205
- s.once('timeout', () => { s.destroy(); resolve(false) })
206
- s.once('error', () => resolve(false))
207
- s.connect(port, '127.0.0.1')
208
- })
209
- }
210
-
211
- /**
212
- * 启动看门狗。返回对象含 .stop() 优雅退出。
213
- * @param {object} opts
214
- * @param {number} [opts.port=7999]
215
- * @param {string} [opts.vaultRoot] // 默认 ~/.dsh/memory-vault
216
- * @param {number} [opts.interval=5000]
217
- * @param {number} [opts.maxRestart=10]
218
- * @param {boolean} [opts.autoStart=true] // 首次探测到死时是否自动拉起
219
- */
220
- export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRestart = 10, autoStart = true } = {}) {
221
- let child = null // 我们 spawn 的 web 进程(最近一次)
222
- const spawned = new Map() // child -> 它监听的端口(用于回收多余实例)
223
- let ownedByUs = false
224
- let restartCount = 0
225
- let stopped = false
226
- let timer = null
227
-
228
- // 找空闲端口:port, port+1, ... 最多 +10
229
- const findFreePort = async (start) => {
230
- for (let p = start; p < start + 10; p++) {
231
- if (await probeTcp(p) === false) return p
232
- }
233
- return start
234
- }
235
-
236
- const spawnWeb = async () => {
237
- if (child && !child.killed) {
238
- try { child.kill('SIGTERM') } catch {}
239
- }
240
- const targetPort = await findFreePort(port)
241
- const args = [WEB_JS, '--port', String(targetPort)]
242
- if (vaultRoot) args.push('--vault', vaultRoot)
243
- log(`spawn web: ${nodeBinary()} ${args.join(' ')}`)
244
- const c = spawn(nodeBinary(), args, {
245
- detached: true,
246
- stdio: 'ignore',
247
- env: childEnv({ MEMORY_VAULT_DIR: vaultRoot || process.env.MEMORY_VAULT_DIR || '' }),
248
- windowsHide: true,
249
- })
250
- // spawn 失败默认静默,显式记录(Electron 宿主下曾因此完全无迹可查)
251
- c.on('error', (error) => {
252
- log(`web spawn failed (${nodeBinary()}): ${error?.message || error}`)
253
- })
254
- c.on('exit', (code, sig) => {
255
- spawned.delete(c)
256
- if (!stopped && code !== 0) log(`web exited code=${code} sig=${sig}`)
257
- })
258
- c.unref()
259
- spawned.set(c, targetPort)
260
- child = c
261
- ownedByUs = true
262
- return targetPort
263
- }
264
-
265
- /**
266
- * 回收「跑到备用端口上的多余实例」。
267
- *
268
- * findFreePort 会在目标端口被占时退到 port+1…(曾经因此留下一个 8000 的重复 web
269
- * 进程常驻 66MB,而 tick 只探目标端口,永远发现不了)。目标端口确认可用后,
270
- * 把不在目标端口上的自家子进程收掉即可(只杀我们自己 spawn 的,不动用户手起的)。
271
- */
272
- const reapExtraChildren = () => {
273
- for (const [c, p] of spawned) {
274
- if (p === port) continue
275
- if (c.exitCode !== null || c.killed) { spawned.delete(c); continue }
276
- try { c.kill('SIGTERM') } catch {}
277
- spawned.delete(c)
278
- log(`目标端口 ${port} 已可用 → 回收多余实例(端口 ${p},pid ${c.pid})`)
279
- }
280
- }
281
-
282
- const tick = async () => {
283
- if (stopped) return
284
- const alive = await probe(port)
285
- if (alive) {
286
- // 是我们的服务
287
- if (!ownedByUs) {
288
- log(`port ${port} 已存活(外部实例)→ 切换为保活模式(不接管)`)
289
- ownedByUs = true // 至少逻辑上"知道"它活了
290
- }
291
- reapExtraChildren()
292
- return
293
- }
294
- // 端口死
295
- if (restartCount >= maxRestart) {
296
- log(`restart 次数达上限 ${maxRestart} → 退出看门狗(请人工排查)`)
297
- stop()
298
- return
299
- }
300
- if (!autoStart) {
301
- log(`port ${port} 已死,但 --no-restart 设置,跳过`)
302
- return
303
- }
304
- restartCount += 1
305
- log(`port ${port} 离线 → 第 ${restartCount} 次拉起 web server`)
306
- try {
307
- const newPort = await spawnWeb()
308
- // 等待 ready
309
- for (let i = 0; i < 30; i++) {
310
- await new Promise((r) => setTimeout(r, 500))
311
- const a = await probe(newPort)
312
- if (a) {
313
- log(`web 在 ${newPort} 就绪(vault: ${a.vaultDir})`)
314
- if (newPort !== port) log(`提示:目标端口 ${port} 被占,watchdog 在 ${newPort} 上保活`)
315
- return
316
- }
317
- }
318
- log(`web 未能在 15s 内就绪`)
319
- } catch (error) {
320
- log(`spawn 失败:${error?.message || error}`)
321
- }
322
- }
323
-
324
- const stop = () => {
325
- if (stopped) return
326
- stopped = true
327
- if (timer) { clearInterval(timer); timer = null }
328
- for (const c of spawned.keys()) {
329
- if (c.exitCode === null && !c.killed) {
330
- try { c.kill('SIGTERM') } catch {}
331
- setTimeout(() => { try { c.kill('SIGKILL') } catch {} }, 1000).unref()
332
- }
333
- }
334
- spawned.clear()
335
- log('看门狗退出')
336
- process.exit(0)
337
- }
338
-
339
- // 首次探测
340
- ;(async () => {
341
- const alive = await probe(port)
342
- if (alive) {
343
- log(`port ${port} 已活(外部实例),启动保活监督(间隔 ${interval}ms)`)
344
- } else if (autoStart) {
345
- log(`port ${port} 离线 → 首次拉起 web`)
346
- restartCount += 1
347
- try {
348
- const np = await spawnWeb()
349
- for (let i = 0; i < 30; i++) {
350
- await new Promise((r) => setTimeout(r, 500))
351
- if (await probe(np)) { log(`web 在 ${np} 就绪`); break }
352
- }
353
- } catch (e) { log(`首次拉起失败:${e?.message || e}`) }
354
- }
355
- timer = setInterval(tick, interval)
356
- })()
357
-
358
- process.on('SIGINT', stop)
359
- process.on('SIGTERM', stop)
360
- return { stop, get port() { return port }, get restartCount() { return restartCount } }
361
- }
362
-
363
- // 脚本入口:node lib/watchdog.js [--port N] [--interval MS] [--max-restart N] [--vault DIR]
364
- if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
365
- const argv = process.argv.slice(2)
366
- const argOf = (name) => {
367
- const i = argv.indexOf(name)
368
- return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null
369
- }
370
- const port = Number(argOf('--port')) || 7999
371
- const interval = Number(argOf('--interval')) || 5000
372
- const maxRestart = Number(argOf('--max-restart')) || 10
373
- const vaultRoot = argOf('--vault') || undefined
374
- const autoStart = !argv.includes('--no-restart')
375
- if (argv.includes('--reap')) {
376
- const out = await reapStaleWatchdogs({ port, keepPid: 0 })
377
- const msg = `回收孤儿看门狗:扫描 ${out.scanned} 个,清理 ${out.killed.length} 个${out.killed.length ? '(pid ' + out.killed.join(', ') + ')' : ''},保留 ${out.skipped.length} 个`
378
- // 必须同步写:process.exit() 会截断 stderr 的异步写入(重定向/管道时表现为「完全没有输出」)
379
- try { fs.writeSync(2, `[watchdog] ${msg}\n`) } catch { log(msg) }
380
- process.exitCode = 0
381
- } else {
382
- const lock = acquireWatchdogLock({ port, vault: vaultRoot || '' })
383
- if (!lock.acquired) {
384
- log('同端口已有看门狗在跑(pid ' + (lock.previous && lock.previous.pid) + ',启动于 ' + (lock.previous && lock.previous.startedAt) + ')→ 本次启动退出,避免进程堆积')
385
- process.exitCode = 0
386
- } else {
387
- if (lock.previous) log('清理陈旧看门狗记录(pid ' + lock.previous.pid + ' 已不存在)')
388
- process.on('exit', () => releaseWatchdogLock())
389
- startWatchdog({ port, interval, maxRestart, vaultRoot, autoStart })
390
- log('看门狗已启动(pid ' + process.pid + ',端口 ' + port + ')')
391
- }
392
- }
1
+ // 记忆核心 · 看门狗(独立进程保活 lib/web.js)。
2
+ //
3
+ // 用法(经 CLI):dsh-memory watchdog [--port 7999] [--interval 5] [--max-restart 10]
4
+ //
5
+ // 行为:
6
+ // - 启动时探测端口:活则只监督不接管;死则 spawn 新 web server
7
+ // - 周期性探活:HTTP GET /memory-eternal/api/overview(不是我们服务视为死)
8
+ // - 探到死:清理残留进程 → spawn 新 web server → restart 计数 +1
9
+ // - restart 超过 max-restart(默认 10)→ fatal 退出,不再保活
10
+ // - SIGINT/SIGTERM:优雅退出(停掉 watchdog 自己拉起的 web,但不动用户手动起的)
11
+ // - 所有状态输出到 stderr(看门狗 daemon 的标准约定)
12
+ //
13
+ // 独立性:watchdog 完全独立于 DSH 宿主——即便旧版插件(无 autoWeb)跑的
14
+ // DSH 环境,watchdog 也能拉起并保活 web server,让浏览器始终可达。
15
+
16
+ import http from 'node:http'
17
+ import net from 'node:net'
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
20
+ import { spawn } from 'node:child_process'
21
+ import path from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import process from 'node:process'
24
+ import { nodeBinary, childEnv } from './node-bin.js'
25
+
26
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
27
+ const WEB_JS = path.join(__dirname, 'web.js')
28
+ const PKG_JSON = path.join(__dirname, '..', 'package.json')
29
+ const API_PREFIX = '/memory-eternal/api'
30
+ // -- 单例锁 -------------------------------------------------------------------
31
+ // 每个 DSH 启动都会 spawn 一个 detached watchdog,而旧代码没有任何存活检查 ——
32
+ // 多次重启就堆积多个 watchdog 常驻轮询同一端口(dsh-memory-eternal#6)。
33
+ // 这里用一个 pid 锁文件:同端口已有活着的 watchdog 就让它直接退出。
34
+ const LOCK_NAME = 'memory-eternal-watchdog.json'
35
+
36
+ /** 锁文件路径($DSH_HOME 下,与共享配置同处)。 */
37
+ export function watchdogLockPath(env = process.env) {
38
+ return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), LOCK_NAME)
39
+ }
40
+
41
+ /**
42
+ * 当前包的版本号(写进锁文件,用于发现「常驻实例是旧版」这一漂移,issue #19)。
43
+ * 读不到就返回空串(不影响保活)。
44
+ */
45
+ export function currentPkgVersion() {
46
+ try { return String(JSON.parse(fs.readFileSync(PKG_JSON, 'utf8')).version || '') } catch { return '' }
47
+ }
48
+
49
+ /** 进程是否存活(Windows 下 process.kill(pid,0) 同样可用;EPERM 说明进程在但无权限)。 */
50
+ export function isPidAlive(pid) {
51
+ const n = Number(pid)
52
+ if (!n || n <= 0) return false
53
+ if (n === process.pid) return true
54
+ try { process.kill(n, 0); return true } catch (error) { return error?.code === 'EPERM' }
55
+ }
56
+
57
+ /**
58
+ * 读取锁文件,归一化成 { watchdogs: [{pid, port, vault, startedAt}] }。
59
+ * 兼容早期单槽格式(顶层就是 {pid, port}):多端口/多 vault 并存时需要多槽。
60
+ */
61
+ export function readWatchdogLock(env = process.env) {
62
+ let raw = null
63
+ try { raw = JSON.parse(fs.readFileSync(watchdogLockPath(env), 'utf8')) } catch { return { watchdogs: [] } }
64
+ if (!raw || typeof raw !== 'object') return { watchdogs: [] }
65
+ if (Array.isArray(raw.watchdogs)) return { watchdogs: raw.watchdogs.filter((w) => w && w.pid) }
66
+ if (raw.pid) return { watchdogs: [raw] } // 旧格式
67
+ return { watchdogs: [] }
68
+ }
69
+
70
+ function writeWatchdogLock(env, list) {
71
+ const file = watchdogLockPath(env)
72
+ try {
73
+ fs.mkdirSync(path.dirname(file), { recursive: true })
74
+ fs.writeFileSync(file, JSON.stringify({ watchdogs: list }))
75
+ } catch { /* 写不了锁不阻塞保活 */ }
76
+ }
77
+
78
+ /**
79
+ * 尝试取得看门狗锁(按端口分槽)。
80
+ * @returns {{acquired:boolean, file:string, previous:object|null, reaped:number[]}} acquired=false 表示同端口已有活着的看门狗,调用方应直接退出。
81
+ */
82
+ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid, pkgVersion = currentPkgVersion() } = {}) {
83
+ const file = watchdogLockPath(env)
84
+ const cur = readWatchdogLock(env)
85
+ const samePort = cur.watchdogs.filter((w) => Number(w.port) === Number(port) && w.pid !== pid)
86
+ const alive = samePort.find((w) => isPidAlive(w.pid))
87
+ if (alive) return { acquired: false, file, previous: alive, reaped: [] }
88
+ // 清掉死掉的槽(含其它端口的陈旧记录),保留仍然存活的其它端口
89
+ const reaped = []
90
+ const kept = []
91
+ for (const w of cur.watchdogs) {
92
+ if (Number(w.port) === Number(port)) { reaped.push(w.pid); continue }
93
+ if (isPidAlive(w.pid)) kept.push(w)
94
+ else reaped.push(w.pid)
95
+ }
96
+ const previous = samePort[0] || (reaped.length ? { pid: reaped[0] } : null)
97
+ kept.push({ pid, port: Number(port), vault, startedAt: new Date().toISOString(), ...(pkgVersion ? { pkgVersion } : {}) })
98
+ writeWatchdogLock(env, kept)
99
+ return { acquired: true, file, previous, reaped }
100
+ }
101
+
102
+ /** 释放锁(只清自己那一槽;没有其它槽就删文件)。 */
103
+ export function releaseWatchdogLock({ env = process.env, pid = process.pid } = {}) {
104
+ const cur = readWatchdogLock(env)
105
+ const kept = cur.watchdogs.filter((w) => w.pid !== pid)
106
+ if (kept.length === cur.watchdogs.length) return false
107
+ if (!kept.length) { try { fs.unlinkSync(watchdogLockPath(env)) } catch { /* 已不存在 */ } return true }
108
+ writeWatchdogLock(env, kept)
109
+ return true
110
+ }
111
+
112
+ /**
113
+ * 就地更新某一槽的字段(不存在则忽略)。
114
+ *
115
+ * 用途:watchdog spawn / 回收 web 子进程时,把 webPid / webPort 记进锁里 ——
116
+ * Windows 上 `process.kill(pid,'SIGTERM')` 是**无条件终止**,watchdog 的 stop() 处理器
117
+ * 根本不会执行,所以「停看门狗」时必须靠这份记录才能连带收掉它拉起的 web(issue #19)。
118
+ *
119
+ * @returns {object|null} 更新后的槽;槽不存在时返回 null
120
+ */
121
+ export function updateWatchdogSlot({ env = process.env, pid = process.pid, patch = {} } = {}) {
122
+ const cur = readWatchdogLock(env)
123
+ let hit = null
124
+ const next = cur.watchdogs.map((w) => {
125
+ if (Number(w.pid) !== Number(pid)) return w
126
+ hit = { ...w, ...patch }
127
+ return hit
128
+ })
129
+ if (!hit) return null
130
+ writeWatchdogLock(env, next)
131
+ return hit
132
+ }
133
+
134
+ /**
135
+ * 看门狗运行状态(issue #19 的 `dsh-memory status`):锁文件里登记了什么、谁还活着、
136
+ * 版本是否与本机磁盘上的包一致。
137
+ * @returns {{lockPath:string, pkgVersion:string, watchdogs:Array<object>}}
138
+ */
139
+ export function watchdogStatus({ env = process.env, port } = {}) {
140
+ const pkgVersion = currentPkgVersion()
141
+ const want = port === undefined || port === null || port === '' ? null : Number(port)
142
+ const list = readWatchdogLock(env).watchdogs
143
+ .filter((w) => want === null || Number(w.port) === want)
144
+ .map((w) => ({
145
+ pid: Number(w.pid),
146
+ port: Number(w.port),
147
+ vault: String(w.vault || ''),
148
+ startedAt: String(w.startedAt || ''),
149
+ pkgVersion: String(w.pkgVersion || ''),
150
+ alive: isPidAlive(w.pid),
151
+ versionMismatch: !!(w.pkgVersion && pkgVersion && String(w.pkgVersion) !== pkgVersion),
152
+ webPid: Number(w.webPid) || 0,
153
+ webPort: Number(w.webPort) || 0,
154
+ webAlive: !!(Number(w.webPid) > 0 && isPidAlive(w.webPid)),
155
+ }))
156
+ return { lockPath: watchdogLockPath(env), pkgVersion, watchdogs: list }
157
+ }
158
+
159
+ /**
160
+ * 显式停止常驻看门狗(issue #19:此前只能手工 kill)。
161
+ *
162
+ * 只动**锁文件里登记、且真的活着**的实例;SIGTERM → 等它退出 → 清锁槽。
163
+ * 多会话共用同一个 watchdog,所以这是显式命令,绝不在配置变更时自动调用。
164
+ *
165
+ * @param {object} opts
166
+ * @param {number|string} [opts.port] 只停这个端口;缺省 = 锁里所有端口
167
+ * @param {number} [opts.timeoutMs] 等待退出的上限
168
+ * @param {(pid:number)=>boolean} [opts.kill] 注入用(单测)
169
+ * @param {(pid:number)=>boolean} [opts.isAlive] 注入用(单测)
170
+ * @param {()=>Promise<Array<{pid:number,port:number,command:string}>>} [opts.listProcesses] 注入用(单测)
171
+ * @returns {Promise<{stopped:number[], webStopped:number[], failed:number[], skipped:number[]}>}
172
+ */
173
+ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000, kill, isAlive, sleep, listProcesses } = {}) {
174
+ const want = port === undefined || port === null || port === '' ? null : Number(port)
175
+ const alive = isAlive || isPidAlive
176
+ const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
177
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
178
+ const slots = readWatchdogLock(env).watchdogs.filter((w) => want === null || Number(w.port) === want)
179
+
180
+ // 先确认「锁里登记的 web 子进程」现在确实是 web.js(防 pid 复用误杀)。
181
+ // 枚举不到(极端环境)就只报 skipped,交由用户手工处理 —— 宁可少杀,不可错杀。
182
+ const wantedWeb = slots.map((w) => ({ pid: Number(w.webPid) || 0, port: Number(w.webPort) || Number(w.port) })).filter((x) => x.pid > 0)
183
+ let confirmedWeb = null
184
+ if (wantedWeb.length) {
185
+ try {
186
+ const procs = listProcesses ? await listProcesses() : await listWatchdogProcesses(defaultExec(), process.platform, 'web.js')
187
+ confirmedWeb = new Set(procs.filter((p) => Number(p.pid) > 0).map((p) => Number(p.pid)))
188
+ } catch { confirmedWeb = null }
189
+ }
190
+
191
+ const stopped = []
192
+ const failed = []
193
+ const skipped = []
194
+ for (const w of slots) {
195
+ if (!alive(w.pid)) { skipped.push(Number(w.pid)); releaseWatchdogLock({ env, pid: w.pid }); continue }
196
+ if (!killFn(w.pid)) { failed.push(Number(w.pid)); continue }
197
+ stopped.push(Number(w.pid))
198
+ }
199
+ // 等它们真的退出(最多 timeoutMs),再清锁槽 —— 否则新实例会以为槽里还是活的
200
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
201
+ while (Date.now() < deadline && stopped.some((pid) => alive(pid))) await wait(100)
202
+
203
+ // 收掉 watchdog 拉起的 web:Windows 上 process.kill(pid,'SIGTERM') 是无条件终止,
204
+ // watchdog 的 stop() 处理器不会执行 —— 不在这里收,就会留下一个占着端口的孤儿 web,
205
+ // 而「stop 了却还占端口」正是 issue #19 抱怨的那类不可控状态。
206
+ const webStopped = []
207
+ for (const target of wantedWeb) {
208
+ if (!alive(target.pid)) continue
209
+ if (confirmedWeb && !confirmedWeb.has(target.pid)) { skipped.push(target.pid); continue }
210
+ if (!killFn(target.pid)) { failed.push(target.pid); continue }
211
+ webStopped.push(target.pid)
212
+ }
213
+
214
+ for (const pid of [...stopped, ...failed, ...webStopped]) {
215
+ if (alive(pid)) { if (pid !== 0) failed.push(pid); continue }
216
+ releaseWatchdogLock({ env, pid })
217
+ }
218
+ return {
219
+ stopped: [...new Set(stopped.filter((p) => !failed.includes(p)))],
220
+ webStopped: [...new Set(webStopped.filter((p) => !failed.includes(p)))],
221
+ failed: [...new Set(failed)],
222
+ skipped: [...new Set(skipped)],
223
+ }
224
+ }
225
+
226
+ /**
227
+ * 解析进程列表(纯函数,便于单测)。
228
+ * 支持 `ps -eo pid,args` 与 Windows 的 `ConvertTo-Csv` 两种输入。
229
+ * @param {string} text 进程列表原文
230
+ * @param {string} [platform]
231
+ * @param {string} [marker] 只保留命令行里带该标记的进程(默认 watchdog.js;确认 web 子进程时传 web.js)
232
+ * @returns {Array<{pid:number, port:number, command:string}>}
233
+ */
234
+ export function parseWatchdogProcesses(text, platform = process.platform, marker = 'watchdog.js') {
235
+ const out = []
236
+ for (const raw of String(text || '').split(/\r?\n/)) {
237
+ const line = raw.trim()
238
+ if (!line || !line.includes(marker)) continue
239
+ let pid = 0
240
+ let command = line
241
+ const csv = /^"?(\d+)"?,(.*)$/.exec(line) // Windows: "1234","node ... watchdog.js --port 7999"
242
+ const ps = /^(\d+)\s+(.*)$/.exec(line) // POSIX: 1234 node ... watchdog.js --port 7999
243
+ if (csv) { pid = Number(csv[1]); command = csv[2].replace(/^"|"$/g, '') }
244
+ else if (ps) { pid = Number(ps[1]); command = ps[2] }
245
+ if (!pid) continue
246
+ const m = /--port\s+(\d+)/.exec(command)
247
+ out.push({ pid, port: m ? Number(m[1]) : 7999, command })
248
+ }
249
+ return out
250
+ }
251
+
252
+ /** 列出机器上带 marker 的 node 进程(平台相关,失败返回空数组)。 */
253
+ async function listWatchdogProcesses(exec, platform = process.platform, marker = 'watchdog.js') {
254
+ if (platform === 'win32') {
255
+ const ps = "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"
256
+ const out = await exec('powershell', ['-NoProfile', '-Command', ps])
257
+ return parseWatchdogProcesses(out, 'win32', marker)
258
+ }
259
+ const out = await exec('ps', ['-eo', 'pid,args'])
260
+ return parseWatchdogProcesses(out, 'linux', marker)
261
+ }
262
+
263
+ /** 默认的进程执行器(spawn + 收 stdout;失败返回空串)。 */
264
+ function defaultExec() {
265
+ return async (cmd, args) => {
266
+ const { spawn } = await import('node:child_process')
267
+ return await new Promise((resolve) => {
268
+ const c = spawn(cmd, args, { windowsHide: true })
269
+ let buf = ''
270
+ c.stdout.on('data', (d) => { buf += d })
271
+ c.on('error', () => resolve(''))
272
+ c.on('close', () => resolve(buf))
273
+ })
274
+ }
275
+ }
276
+
277
+ /**
278
+ * 回收历史遗留的孤儿看门狗(新锁只能防新增,管不了升级前已经在跑的)。
279
+ * 只杀「命令行是 watchdog.js + 端口相同 + pid 不是自己 + 不在锁里存活」的进程。
280
+ * @returns {Promise<{scanned:number, killed:number[], skipped:number[]}>}
281
+ */
282
+ export async function reapStaleWatchdogs({ port = 7999, keepPid = process.pid, env = process.env, exec, list, kill } = {}) {
283
+ const runExec = exec || defaultExec()
284
+ const procs = list ? await list() : await listWatchdogProcesses(runExec)
285
+ const lock = readWatchdogLock(env)
286
+ const lockedAlive = new Set(lock.watchdogs.filter((w) => isPidAlive(w.pid)).map((w) => Number(w.pid)))
287
+ // 绝不能杀自己(也不杀父进程):直接跑 lib/watchdog.js --reap 时,调用者自己的命令行
288
+ // 就含 watchdog.js + 同端口、且不在锁里 —— 早期实现在这里把自己 SIGTERM 了,
289
+ // 表现为「没有任何输出 + 退出码 1」。并发 reap 的另一个进程同理:命令行带 --reap 就放过。
290
+ const protectedPids = new Set([Number(keepPid), process.pid, process.ppid].filter((n) => n > 0))
291
+ const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
292
+ const killed = []
293
+ const skipped = []
294
+ for (const p of procs) {
295
+ if (Number(p.port) !== Number(port)) { skipped.push(p.pid); continue }
296
+ if (protectedPids.has(Number(p.pid))) { skipped.push(p.pid); continue }
297
+ if (String(p.command || '').includes('--reap')) { skipped.push(p.pid); continue } // 另一个正在 reap 的进程
298
+ if (lockedAlive.has(Number(p.pid))) { skipped.push(p.pid); continue } // 锁里活着的是当前合法实例
299
+ if (!isPidAlive(p.pid)) { skipped.push(p.pid); continue }
300
+ if (killFn(p.pid)) killed.push(p.pid); else skipped.push(p.pid)
301
+ }
302
+ return { scanned: procs.length, killed, skipped }
303
+ }
304
+
305
+ const ts = () => new Date().toISOString().slice(11, 19) // HH:MM:SS
306
+ const log = (...args) => process.stderr.write(`[${ts()}] [watchdog] ${args.join(' ')}\n`)
307
+
308
+ /** 探测某端口是否在响应 + 是我们的服务(响应含 vaultDir 标记)。 */
309
+ export async function probe(port, timeoutMs = 1000) {
310
+ return new Promise((resolve) => {
311
+ const req = http.request({ host: '127.0.0.1', port, path: `${API_PREFIX}/overview`, method: 'GET', timeout: timeoutMs }, (res) => {
312
+ if (res.statusCode !== 200) return resolve(null)
313
+ const chunks = []
314
+ res.on('data', (c) => chunks.push(c))
315
+ res.on('end', () => {
316
+ try {
317
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
318
+ if (data && data.ok === true && typeof data.vaultDir === 'string') return resolve(data)
319
+ resolve(null)
320
+ } catch { resolve(null) }
321
+ })
322
+ res.on('error', () => resolve(null))
323
+ })
324
+ req.on('error', () => resolve(null))
325
+ req.on('timeout', () => { req.destroy(); resolve(null) })
326
+ req.end()
327
+ })
328
+ }
329
+
330
+ /** 探测端口是否有任何 TCP 监听(即便不是我们的服务也占用)。 */
331
+ export async function probeTcp(port, timeoutMs = 600) {
332
+ return new Promise((resolve) => {
333
+ const s = new net.Socket()
334
+ s.setTimeout(timeoutMs)
335
+ s.once('connect', () => { s.destroy(); resolve(true) })
336
+ s.once('timeout', () => { s.destroy(); resolve(false) })
337
+ s.once('error', () => resolve(false))
338
+ s.connect(port, '127.0.0.1')
339
+ })
340
+ }
341
+
342
+ /**
343
+ * 启动看门狗。返回对象含 .stop() 优雅退出。
344
+ * @param {object} opts
345
+ * @param {number} [opts.port=7999]
346
+ * @param {string} [opts.vaultRoot] // 默认 ~/.dsh/memory-vault
347
+ * @param {number} [opts.interval=5000]
348
+ * @param {number} [opts.maxRestart=10]
349
+ * @param {boolean} [opts.autoStart=true] // 首次探测到死时是否自动拉起
350
+ */
351
+ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRestart = 10, autoStart = true } = {}) {
352
+ let child = null // 我们 spawn 的 web 进程(最近一次)
353
+ const spawned = new Map() // child -> 它监听的端口(用于回收多余实例)
354
+ let ownedByUs = false
355
+ let restartCount = 0
356
+ let stopped = false
357
+ let timer = null
358
+
359
+ // 找空闲端口:port, port+1, ... 最多 +10
360
+ const findFreePort = async (start) => {
361
+ for (let p = start; p < start + 10; p++) {
362
+ if (await probeTcp(p) === false) return p
363
+ }
364
+ return start
365
+ }
366
+
367
+ const spawnWeb = async () => {
368
+ if (child && !child.killed) {
369
+ try { child.kill('SIGTERM') } catch {}
370
+ }
371
+ const targetPort = await findFreePort(port)
372
+ const args = [WEB_JS, '--port', String(targetPort)]
373
+ if (vaultRoot) args.push('--vault', vaultRoot)
374
+ log(`spawn web: ${nodeBinary()} ${args.join(' ')}`)
375
+ const c = spawn(nodeBinary(), args, {
376
+ detached: true,
377
+ stdio: 'ignore',
378
+ env: childEnv({ MEMORY_VAULT_DIR: vaultRoot || process.env.MEMORY_VAULT_DIR || '' }),
379
+ windowsHide: true,
380
+ })
381
+ // spawn 失败默认静默,显式记录(Electron 宿主下曾因此完全无迹可查)
382
+ c.on('error', (error) => {
383
+ log(`web spawn failed (${nodeBinary()}): ${error?.message || error}`)
384
+ })
385
+ c.on('exit', (code, sig) => {
386
+ spawned.delete(c)
387
+ if (child === c) child = null
388
+ if (child === null) updateWatchdogSlot({ patch: { webPid: 0, webPort: 0 } })
389
+ if (!stopped && code !== 0) log(`web exited code=${code} sig=${sig}`)
390
+ })
391
+ c.unref()
392
+ spawned.set(c, targetPort)
393
+ child = c
394
+ ownedByUs = true
395
+ // 记进锁:stop 时即便 watchdog 被硬终止(Windows 无真信号),也能找到并收掉这个 web
396
+ updateWatchdogSlot({ patch: { webPid: c.pid, webPort: targetPort } })
397
+ return targetPort
398
+ }
399
+
400
+ /**
401
+ * 回收「跑到备用端口上的多余实例」。
402
+ *
403
+ * findFreePort 会在目标端口被占时退到 port+1…(曾经因此留下一个 8000 的重复 web
404
+ * 进程常驻 66MB,而 tick 只探目标端口,永远发现不了)。目标端口确认可用后,
405
+ * 把不在目标端口上的自家子进程收掉即可(只杀我们自己 spawn 的,不动用户手起的)。
406
+ */
407
+ const reapExtraChildren = () => {
408
+ for (const [c, p] of spawned) {
409
+ if (p === port) continue
410
+ if (c.exitCode !== null || c.killed) { spawned.delete(c); continue }
411
+ try { c.kill('SIGTERM') } catch {}
412
+ spawned.delete(c)
413
+ log(`目标端口 ${port} 已可用 → 回收多余实例(端口 ${p},pid ${c.pid})`)
414
+ }
415
+ }
416
+
417
+ const tick = async () => {
418
+ if (stopped) return
419
+ const alive = await probe(port)
420
+ if (alive) {
421
+ // 是我们的服务
422
+ if (!ownedByUs) {
423
+ log(`port ${port} 已存活(外部实例)→ 切换为保活模式(不接管)`)
424
+ ownedByUs = true // 至少逻辑上"知道"它活了
425
+ }
426
+ reapExtraChildren()
427
+ return
428
+ }
429
+ // 端口死
430
+ if (restartCount >= maxRestart) {
431
+ log(`restart 次数达上限 ${maxRestart} → 退出看门狗(请人工排查)`)
432
+ stop()
433
+ return
434
+ }
435
+ if (!autoStart) {
436
+ log(`port ${port} 已死,但 --no-restart 设置,跳过`)
437
+ return
438
+ }
439
+ restartCount += 1
440
+ log(`port ${port} 离线 → 第 ${restartCount} 次拉起 web server`)
441
+ try {
442
+ const newPort = await spawnWeb()
443
+ // 等待 ready
444
+ for (let i = 0; i < 30; i++) {
445
+ await new Promise((r) => setTimeout(r, 500))
446
+ const a = await probe(newPort)
447
+ if (a) {
448
+ log(`web 在 ${newPort} 就绪(vault: ${a.vaultDir})`)
449
+ if (newPort !== port) log(`提示:目标端口 ${port} 被占,watchdog 在 ${newPort} 上保活`)
450
+ return
451
+ }
452
+ }
453
+ log(`web 未能在 15s 内就绪`)
454
+ } catch (error) {
455
+ log(`spawn 失败:${error?.message || error}`)
456
+ }
457
+ }
458
+
459
+ const stop = () => {
460
+ if (stopped) return
461
+ stopped = true
462
+ if (timer) { clearInterval(timer); timer = null }
463
+ for (const c of spawned.keys()) {
464
+ if (c.exitCode === null && !c.killed) {
465
+ try { c.kill('SIGTERM') } catch {}
466
+ setTimeout(() => { try { c.kill('SIGKILL') } catch {} }, 1000).unref()
467
+ }
468
+ }
469
+ spawned.clear()
470
+ log('看门狗退出')
471
+ process.exit(0)
472
+ }
473
+
474
+ // 首次探测
475
+ ;(async () => {
476
+ const alive = await probe(port)
477
+ if (alive) {
478
+ log(`port ${port} 已活(外部实例),启动保活监督(间隔 ${interval}ms)`)
479
+ } else if (autoStart) {
480
+ log(`port ${port} 离线 → 首次拉起 web`)
481
+ restartCount += 1
482
+ try {
483
+ const np = await spawnWeb()
484
+ for (let i = 0; i < 30; i++) {
485
+ await new Promise((r) => setTimeout(r, 500))
486
+ if (await probe(np)) { log(`web 在 ${np} 就绪`); break }
487
+ }
488
+ } catch (e) { log(`首次拉起失败:${e?.message || e}`) }
489
+ }
490
+ timer = setInterval(tick, interval)
491
+ })()
492
+
493
+ process.on('SIGINT', stop)
494
+ process.on('SIGTERM', stop)
495
+ return { stop, get port() { return port }, get restartCount() { return restartCount } }
496
+ }
497
+
498
+ // 脚本入口:node lib/watchdog.js [--port N] [--interval MS] [--max-restart N] [--vault DIR]
499
+ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
500
+ const argv = process.argv.slice(2)
501
+ const argOf = (name) => {
502
+ const i = argv.indexOf(name)
503
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null
504
+ }
505
+ const port = Number(argOf('--port')) || 7999
506
+ const interval = Number(argOf('--interval')) || 5000
507
+ const maxRestart = Number(argOf('--max-restart')) || 10
508
+ const vaultRoot = argOf('--vault') || undefined
509
+ const autoStart = !argv.includes('--no-restart')
510
+ if (argv.includes('--reap')) {
511
+ const out = await reapStaleWatchdogs({ port, keepPid: 0 })
512
+ const msg = `回收孤儿看门狗:扫描 ${out.scanned} 个,清理 ${out.killed.length} 个${out.killed.length ? '(pid ' + out.killed.join(', ') + ')' : ''},保留 ${out.skipped.length} 个`
513
+ // 必须同步写:process.exit() 会截断 stderr 的异步写入(重定向/管道时表现为「完全没有输出」)
514
+ try { fs.writeSync(2, `[watchdog] ${msg}\n`) } catch { log(msg) }
515
+ process.exitCode = 0
516
+ } else {
517
+ const lock = acquireWatchdogLock({ port, vault: vaultRoot || '' })
518
+ if (!lock.acquired) {
519
+ log('同端口已有看门狗在跑(pid ' + (lock.previous && lock.previous.pid) + ',启动于 ' + (lock.previous && lock.previous.startedAt) + ')→ 本次启动退出,避免进程堆积')
520
+ process.exitCode = 0
521
+ } else {
522
+ if (lock.previous) log('清理陈旧看门狗记录(pid ' + lock.previous.pid + ' 已不存在)')
523
+ process.on('exit', () => releaseWatchdogLock())
524
+ startWatchdog({ port, interval, maxRestart, vaultRoot, autoStart })
525
+ log('看门狗已启动(pid ' + process.pid + ',端口 ' + port + ')')
526
+ }
527
+ }
393
528
  }