'use strict' const assert = require('node:assert/strict') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const test = require('node:test') const { EventEmitter } = require('node:events') const { ServiceSupervisor, healthHost } = require('./service') // service.js 的外部依赖只有三条边界:spawn / 时钟 / HTTP。构造函数支持注入后, // 「等 3 秒退避」「优雅关闭超时强杀」这些真实要挂几秒的分支可以在毫秒内跑完, // 而且能精确断言「第几次重启、等了多少毫秒、进程被怎么结束的」。 // 这里不碰真实进程与网络,全部用假实现;真机链路(.exe 拉起、端口探测)只能在 Windows 上跑。 const flush = () => new Promise((resolve) => setImmediate(resolve)) // 假时钟:定时器只在 advance() 里执行,测试因此完全不受真实时间影响 function makeClock(start = Date.parse('2026-09-11T00:00:00.000Z')) { let now = start let seq = 0 const tasks = new Map() const schedule = (fn, ms, every) => { const id = ++seq tasks.set(id, { fn, at: now + Math.max(0, Number(ms) || 0), every }) return id } return { now: () => now, timers: { setTimeout: (fn, ms) => schedule(fn, ms, 0), clearTimeout: (id) => tasks.delete(id), setInterval: (fn, ms) => schedule(fn, ms, Math.max(1, Number(ms) || 1)), clearInterval: (id) => tasks.delete(id) }, scheduled: () => tasks.size, advance(ms) { const target = now + ms for (;;) { let due = null for (const [id, task] of tasks) { if (task.at <= target && (!due || task.at < due.task.at)) due = { id, task } } if (!due) break now = due.task.at if (due.task.every) due.task.at = now + due.task.every else tasks.delete(due.id) due.task.fn() } now = target } } } // 假子进程:只实现 service.js 用到的那几样(stdout/stderr 流、exit/error 事件、kill) class FakeChild extends EventEmitter { constructor(pid) { super() this.pid = pid this.stdout = new FakeStream() this.stderr = new FakeStream() this.kills = [] } kill(signal) { this.kills.push(signal || 'SIGTERM') return true } // code 为 null、signal 非空 = 被信号杀(Node 在 Windows 上强杀时 code 非 0) exit(code, signal = null) { this.emit('exit', code, signal) } } class FakeStream extends EventEmitter { setEncoding() {} } // 假 HTTP:健康探测与关闭握手分开控制,测试自己决定服务端「答不答、答什么」 function makeHttpStub() { const state = { probeStatus: 200, probeError: null, shutdown: 'accept', probes: [], shutdowns: [] } return { state, get(options, callback) { state.probes.push(options) const request = new EventEmitter() request.destroy = () => {} process.nextTick(() => { if (state.probeError) request.emit('error', new Error(state.probeError)) else callback({ statusCode: state.probeStatus, resume() {} }) }) return request }, request(options, callback) { const request = new EventEmitter() request.destroy = (err) => { if (err) request.emit('error', err) } request.end = () => { state.shutdowns.push(options) process.nextTick(() => { if (state.shutdown === 'accept') callback({ statusCode: 200, resume() {} }) else if (state.shutdown === 'http500') callback({ statusCode: 500, resume() {} }) else request.emit('error', new Error('connect ECONNREFUSED 127.0.0.1:19540')) }) } return request } } } function makeLog() { const lines = [] return { lines, push: (source, message) => lines.push(`[${source}] ${message}`), append: (source, chunk) => lines.push(`[${source}] ${String(chunk)}`), text: () => lines.join('\n') } } function setup(t, serverOverrides = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scoreboard-service-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) const exePath = path.join(root, 'scoreboard.exe') fs.writeFileSync(exePath, 'stub') const clock = makeClock() const http = makeHttpStub() const log = makeLog() const children = [] const spawnCalls = [] const service = new ServiceSupervisor({ values: { server: { exe: 'scoreboard.exe', args: ['-config', 'config.yaml'], host: '127.0.0.1', port: 19540, healthIntervalMs: 5000, restartDelayMs: 3000, maxRestarts: 0, shutdownPath: '/api/shutdown', shutdownTimeoutMs: 5000, healthyResetMs: 60000, ...serverOverrides } }, exePath, log, spawn: (file, args, options) => { spawnCalls.push({ file, args, options }) const child = new FakeChild(1000 + children.length) children.push(child) return child }, http, timers: clock.timers, now: clock.now }) return { service, clock, http, log, children, spawnCalls } } // 让健康探测通过一次:等 800ms 的首次探测 + 一次 IPC 往返 async function markAlive(clock) { clock.advance(800) await flush() } test('healthHost:监听 0.0.0.0/:: 时本机探测改走回环地址', () => { assert.equal(healthHost({ host: '0.0.0.0' }), '127.0.0.1') assert.equal(healthHost({ host: '::' }), '127.0.0.1') assert.equal(healthHost({ host: '192.168.1.10' }), '192.168.1.10') assert.equal(healthHost({}), '127.0.0.1') }) test('崩溃:退出码非 0 → 红灯 + 崩溃计数 +1,退避是线性的 ×1..×5', async (t) => { const { service, clock, children, spawnCalls, log } = setup(t) service.start() assert.equal(service.state, 'running') assert.equal(spawnCalls.length, 1) // 参数里带上了 -config 的绝对路径与目录,spawn 的工作目录是 exe 所在目录 assert.equal(spawnCalls[0].options.cwd, path.dirname(spawnCalls[0].file)) assert.ok(spawnCalls[0].options.env.SCOREBOARD_SHUTDOWN_TOKEN) children[0].exit(1) assert.equal(service.state, 'crashed') assert.equal(service.restarts, 1) assert.match(service.lastError, /退出码 1/) assert.match(log.text(), /3 秒后自动重启服务端(第 1 次)/) // 第 1 次退避 = 3000ms(×1),到点自动拉起 clock.advance(2999) assert.equal(spawnCalls.length, 1) clock.advance(1) assert.equal(spawnCalls.length, 2) // 第 2 次退避 = 6000ms(×2):线性递增,不是翻倍 children[1].exit(1) assert.equal(service.restarts, 2) clock.advance(5999) assert.equal(spawnCalls.length, 2) clock.advance(1) assert.equal(spawnCalls.length, 3) assert.match(log.text(), /6 秒后自动重启服务端(第 2 次)/) // 连崩到第 6 次,退避封顶在 5 倍(×5),不再继续放大 for (let i = 0; i < 4; i += 1) { children[children.length - 1].exit(1) clock.advance(15000) } assert.equal(service.restarts, 6) children[children.length - 1].exit(1) assert.match(log.text(), /15 秒后自动重启服务端(第 7 次)/) }) test('崩溃:达到 maxRestarts 后不再自动拉起', async (t) => { const { service, clock, children, spawnCalls, log } = setup(t, { maxRestarts: 2 }) service.start() children[0].exit(1) clock.advance(3000) children[1].exit(1) clock.advance(6000) // 配额内的两次自动重启已经用掉 assert.equal(service.restarts, 2) assert.equal(spawnCalls.length, 3) children[2].exit(1) assert.equal(service.state, 'crashed') assert.match(log.text(), /已达到最大重启次数 2,不再自动拉起/) clock.advance(60000) assert.equal(spawnCalls.length, 3) }) test('崩溃:spawn 只发 error 不发 exit 时也要调度重启(否则守护器卡死)', async (t) => { const { service, clock, children, spawnCalls } = setup(t) service.start() children[0].emit('error', new Error('spawn EACCES')) assert.equal(service.state, 'crashed') assert.equal(service.child, null) assert.match(service.lastError, /EACCES/) clock.advance(3000) assert.equal(spawnCalls.length, 2) }) test('优雅停止:握手受理 → 等它自己退出,状态是已停止且不重启', async (t) => { const { service, clock, children, http, spawnCalls, log } = setup(t) service.start() const stopping = service.stop() await flush() assert.equal(service.state, 'stopping') assert.equal(http.state.shutdowns.length, 1) // 握手带上了启动时下发的 token 与关闭路径 assert.equal(http.state.shutdowns[0].path, '/api/shutdown') assert.match(http.state.shutdowns[0].headers['X-Shutdown-Token'], /^[0-9a-f]{64}$/) assert.equal(http.state.shutdowns[0].method, 'POST') assert.match(log.text(), /已接受优雅关闭请求/) // 受理后不会先去发信号,等服务端自己退出 assert.deepEqual(children[0].kills, []) children[0].exit(0) await stopping assert.equal(service.state, 'stopped') assert.equal(service.lastError, null) assert.match(log.text(), /服务端已优雅退出(退出码 0)/) clock.advance(60000) assert.equal(spawnCalls.length, 1) }) test('优雅停止:握手不可用 → 退回强杀,退出后仍算已停止而不是崩溃', async (t) => { const { service, clock, children, http, log } = setup(t) http.state.shutdown = 'unreachable' service.start() const stopping = service.stop() await flush() assert.match(log.text(), /优雅关闭接口不可用,退回强制结束/) assert.deepEqual(children[0].kills, ['SIGTERM']) // 强杀兜底到期前进程自己退了 → 记「已停止」(不是「已被强制结束」) children[0].exit(1, 'SIGTERM') await stopping assert.equal(service.state, 'stopped') assert.match(log.text(), /服务端已停止(信号 SIGTERM)/) }) test('优雅停止:服务端拖着不退 → 到点强杀并放行,流程不会永远挂住', async (t) => { const { service, clock, children, log } = setup(t) let settled = false service.start() const stopping = service.stop().then(() => { settled = true }) await flush() // 受理后等 shutdownTimeoutMs(5000) 再补一刀强杀,不能先发信号 clock.advance(4999) assert.deepEqual(children[0].kills, []) clock.advance(1) assert.deepEqual(children[0].kills, ['SIGKILL']) assert.match(log.text(), /服务端未在 5 秒内退出,强制结束/) children[0].exit(null, 'SIGKILL') await stopping assert.equal(settled, true) assert.equal(service.state, 'stopped') assert.match(log.text(), /服务端已被强制结束(信号 SIGKILL)/) }) test('停止:没有子进程时直接回到已停止,并取消已排期的重启', async (t) => { const { service, clock, children, spawnCalls } = setup(t) service.start() children[0].exit(1) assert.equal(service.state, 'crashed') // 崩溃等待期内点「停止」:不能等到点又把服务端拉起来 await service.stop() assert.equal(service.state, 'stopped') clock.advance(60000) assert.equal(spawnCalls.length, 1) }) test('按设计退出:健康过的服务端以退出码 0 退出 → 重启中(不是红灯),不计崩溃', async (t) => { const { service, clock, children, spawnCalls, log } = setup(t) service.start() await markAlive(clock) assert.equal(service.healthy, true) // 切换赛事 / 一键恢复:服务端回执 200 后自行优雅退出 children[0].exit(0) assert.equal(service.state, 'restarting') assert.equal(service.status.stateLabel, '重启中') assert.equal(service.restarts, 0) assert.equal(service.lastError, null) assert.match(log.text(), /服务端按设计退出(退出码 0,切换赛事 \/ 一键恢复)/) assert.match(log.text(), /不计入崩溃次数/) // 仍然要自动拉起(延迟与首次崩溃退避一致,留端口回收时间) clock.advance(3000) assert.equal(spawnCalls.length, 2) }) test('按设计退出:全新进程刚起来就退出码 0,不算按设计退出(当崩溃处理)', async (t) => { const { service, children, log } = setup(t) service.start() children[0].exit(0) // 没健康过就没有「预期退出窗口」:这不是切赛事,按崩溃退避重试 assert.equal(service.state, 'crashed') assert.equal(service.restarts, 1) assert.match(service.lastError, /服务端意外退出(退出码 0)/) assert.doesNotMatch(log.text(), /按设计退出/) }) test('按设计退出:健康记录超出预期退出窗口后退出码 0 → 仍按崩溃处理', async (t) => { const { service, clock, http, children } = setup(t) service.start() await markAlive(clock) assert.equal(service.healthy, true) // 服务端卡死:探测开始失败,最后一次确认「还活着」的时间停留在窗口之前 http.state.probeError = 'connect ECONNREFUSED 127.0.0.1:19540' clock.advance(20000) await flush() assert.equal(service.healthy, false) children[0].exit(0) assert.equal(service.state, 'crashed') assert.equal(service.restarts, 1) }) test('健康恢复:持续健康超过 healthyResetMs 后崩溃计数清零', async (t) => { const { service, clock, children, log } = setup(t) service.start() children[0].exit(1) clock.advance(3000) assert.equal(service.restarts, 1) await markAlive(clock) clock.advance(60000) await flush() assert.equal(service.restarts, 0) assert.match(log.text(), /服务端持续健康,崩溃计数已清零/) }) test('健康检查失败:探测报错时状态为不健康但不改变进程状态', async (t) => { const { service, clock, http } = setup(t) http.state.probeError = 'connect ECONNREFUSED 127.0.0.1:19540' service.start() await markAlive(clock) assert.equal(service.healthy, false) assert.equal(service.state, 'running') assert.match(service.status.url, /^http:\/\/127\.0\.0\.1:19540$/) }) test('手动重启:先优雅停再拉起,崩溃计数清零', async (t) => { const { service, clock, children, spawnCalls } = setup(t) service.start() children[0].exit(1) clock.advance(3000) assert.equal(service.restarts, 1) const restarting = service.restart() await flush() children[children.length - 1].exit(0) await restarting assert.equal(service.restarts, 0) assert.equal(service.state, 'running') assert.equal(spawnCalls.length, 3) }) test('迟到的 exit:换过进程之后旧进程的 exit 不能改当前状态', async (t) => { const { service, clock, children } = setup(t) service.start() children[0].exit(1) clock.advance(3000) const current = children[1] // 第二次崩溃前,第一个进程迟到的 exit 事件又冒出来一次 service.handleExit(children[0], 1, null) assert.equal(service.state, 'running') assert.equal(service.child, current) }) test('dispose:清掉所有排期,之后不再拉起新进程', async (t) => { const { service, clock, children, spawnCalls } = setup(t) service.start() children[0].exit(1) assert.equal(service.state, 'crashed') service.dispose() clock.advance(60000) assert.equal(spawnCalls.length, 1) }) test('dispose:子进程随后才退出也不能再排一次重启', async (t) => { const { service, clock, children, spawnCalls } = setup(t) service.start() const child = children[0] service.dispose() // 应用已经在退出,这个迟到的 exit 不该再被当成崩溃去安排守护 child.exit(1) assert.equal(service.state, 'running') clock.advance(60000) assert.equal(spawnCalls.length, 1) })