fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件

This commit is contained in:
AskaEth committed 2026-09-09 20:57:58 +08:00
1 parent c121111f51
commit b80c64a115
39 files changed
+4217 -345

No files matched your search

+107 -18
View File
@@ -14,6 +14,17 @@ const STATE_LABEL = {
crashed: '异常退出'
}
// 优雅关闭握手:POST server.shutdownPath,让服务端自己走 signal.NotifyContext
// 的关闭流程;请求失败或接口未实现时回退到进程信号。
const DEFAULT_SHUTDOWN_PATH = '/api/shutdown'
const SHUTDOWN_REQUEST_TIMEOUT_MS = 2000
// 服务端 srv.Shutdown 的超时是 10s,客户端必须留足余量再强杀
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 12000
// 握手不可用时信号已能结束进程,不必再等满超时
const FALLBACK_FORCE_KILL_MS = 1500
// 连续健康这么久才认为崩溃计数可以清零
const HEALTHY_RESET_MS = 60000
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
@@ -33,6 +44,7 @@ class ServiceSupervisor extends EventEmitter {
this.lastError = null
this.stopRequested = false
this.healthySince = null
this.healthTimer = null
this.restartTimer = null
this.forceKillTimer = null
@@ -90,6 +102,7 @@ class ServiceSupervisor extends EventEmitter {
const cwd = path.dirname(this.exePath)
this.lastError = null
this.healthySince = null
this.startedAt = new Date().toISOString()
this.setState('starting')
this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`)
@@ -140,6 +153,7 @@ class ServiceSupervisor extends EventEmitter {
this.child = null
this.pid = null
this.healthy = false
this.healthySince = null
const reason = signal ? `信号 ${signal}` : `退出码 ${code}`
if (this.stopRequested) {
@@ -187,25 +201,80 @@ class ServiceSupervisor extends EventEmitter {
this.log.push('console', '正在停止服务端…')
child.once('exit', () => resolve())
try {
child.kill()
} catch (err) {
this.log.push('console', `发送停止信号失败:${err.message}`)
}
// Go 服务端有 10s 优雅关闭窗口,超过 5s 仍未退出就强杀,避免 Console 退不干净
this.forceKillTimer = setTimeout(() => {
if (this.child && this.pid) {
this.log.push('console', '服务端未在 5 秒内退出,强制结束')
try {
this.child.kill('SIGKILL')
} catch {
// 进程可能已经没了
}
const timeoutMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
// 先按最坏情况挂强杀兜底,握手成功后也不撤:服务端可能卡在关闭流程里
this.armForceKill(timeoutMs)
this.requestShutdown().then((accepted) => {
if (this.child !== child) return
if (accepted) {
this.log.push('console', `服务端已接受关闭请求,最多等待 ${Math.round(timeoutMs / 1000)} 秒`)
return
}
}, 5000)
// 握手不可用:服务端还没实现该接口,或进程已无响应。
// 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底,
// 真正的优雅关闭依赖服务端补上 shutdownPath 接口。
this.log.push('console', '关闭接口不可用,退回进程信号')
this.armForceKill(FALLBACK_FORCE_KILL_MS)
try {
child.kill()
} catch (err) {
this.log.push('console', `发送停止信号失败:${err.message}`)
}
})
})
}
// 优雅关闭握手:让服务端自己走 signal.NotifyContext → srv.Shutdown 的流程,
// 10s 内落盘退出。接口按回环地址访问、不鉴权,只在本机有意义。
// 依赖:服务端实现 POST <server.shutdownPath>(2xx 表示已受理)。
requestShutdown() {
return new Promise((resolve) => {
const body = Buffer.from('{}')
const request = http.request(
{
host: this.healthHost(),
port: this.server.port,
path: this.server.shutdownPath || DEFAULT_SHUTDOWN_PATH,
method: 'POST',
timeout: SHUTDOWN_REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Content-Length': body.length,
Connection: 'close'
}
},
(response) => {
response.resume()
const accepted = response.statusCode >= 200 && response.statusCode < 300
if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}`)
resolve(accepted)
}
)
request.on('timeout', () => request.destroy(new Error('请求超时')))
request.on('error', (err) => {
this.log.push('console', `关闭接口请求失败:${err.message}`)
resolve(false)
})
request.end(body)
})
}
armForceKill(ms) {
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
this.forceKillTimer = setTimeout(() => {
this.forceKillTimer = null
if (!this.child || !this.pid) return
this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`)
try {
this.child.kill('SIGKILL')
} catch {
// 进程可能已经没了
}
}, ms)
}
async restart() {
await this.stop()
this.restarts = 0
@@ -247,10 +316,30 @@ class ServiceSupervisor extends EventEmitter {
}
setHealthy(ok) {
if (this.healthy === ok) return
const changed = this.healthy !== ok
if (ok) {
if (!this.healthySince) this.healthySince = Date.now()
} else {
this.healthySince = null
}
this.healthy = ok
this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)')
this.emitStatus()
if (changed) {
this.log.push('console', ok ? '服务端健康检查通过' : '服务端健康检查失败(等待重试)')
}
// 连续健康一段时间后清零崩溃计数:否则长时间赛事里历史崩溃会耗尽
// maxRestarts 配额,之后真崩了反而不自动拉起
let reset = false
if (ok && this.restarts > 0) {
const resetAfter = Number(this.server.healthyResetMs) || HEALTHY_RESET_MS
if (Date.now() - this.healthySince >= resetAfter) {
this.restarts = 0
reset = true
this.log.push('console', '服务端持续健康,崩溃计数已清零')
}
}
if (changed || reset) this.emitStatus()
}
dispose() {