dashboardSupervisor.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. import { execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
  2. import { createConnection } from 'node:net'
  3. const DASHBOARD_PORT = 9119
  4. const SUPERVISION_INTERVAL_MS = 5_000
  5. const RESTART_READY_TIMEOUT_MS = 15_000
  6. type Run = (args: readonly string[]) => string
  7. type Launch = (args: readonly string[]) => void
  8. type Probe = (host: string, port: number) => Promise<boolean>
  9. export interface DashboardSupervisorOptions {
  10. command?: string
  11. intervalMs?: number
  12. run?: Run
  13. launch?: Launch
  14. probe?: Probe
  15. log?: (message: string) => void
  16. managed?: boolean
  17. spawnManaged?: (command: string, args: string[], options: SpawnOptions) => ChildProcess
  18. readyTimeoutMs?: number
  19. }
  20. function defaultRun(command: string): Run {
  21. return (args) => execFileSync(command, args, { encoding: 'utf8', stdio: 'pipe' })
  22. }
  23. function defaultLaunch(command: string): Launch {
  24. return (args) => {
  25. const child = spawn(command, [...args], { detached: true, stdio: 'ignore' })
  26. child.unref()
  27. }
  28. }
  29. function portIsListening(host: string, port: number): Promise<boolean> {
  30. return new Promise((resolve) => {
  31. const socket = createConnection({ host, port })
  32. const finish = (value: boolean) => {
  33. socket.removeAllListeners()
  34. socket.destroy()
  35. resolve(value)
  36. }
  37. socket.setTimeout(1_000)
  38. socket.once('connect', () => finish(true))
  39. socket.once('timeout', () => finish(false))
  40. socket.once('error', () => finish(false))
  41. })
  42. }
  43. function delay(milliseconds: number): Promise<void> {
  44. return new Promise((resolve) => setTimeout(resolve, milliseconds))
  45. }
  46. /** Keeps the local Hermes Dashboard available for the managed 15300 service. */
  47. export class DashboardSupervisor {
  48. private readonly command: string
  49. private readonly dashboardHost = '127.0.0.1'
  50. private readonly run: Run
  51. private readonly launch: Launch
  52. private readonly probe: Probe
  53. private readonly log: (message: string) => void
  54. private readonly intervalMs: number
  55. private timer: NodeJS.Timeout | undefined
  56. private checking = false
  57. private owned: ChildProcess | undefined
  58. private stopped = false
  59. private readonly managed: boolean
  60. private restarting = false
  61. private readonly spawnManaged: NonNullable<DashboardSupervisorOptions['spawnManaged']>
  62. private readonly readyTimeoutMs: number
  63. constructor(options: DashboardSupervisorOptions) {
  64. this.command = options.command ?? 'hermes'
  65. this.run = options.run ?? defaultRun(this.command)
  66. this.launch = options.launch ?? defaultLaunch(this.command)
  67. this.probe = options.probe ?? portIsListening
  68. this.log = options.log ?? console.info
  69. this.intervalMs = options.intervalMs ?? SUPERVISION_INTERVAL_MS
  70. this.managed = options.managed === true
  71. this.spawnManaged = options.spawnManaged ?? spawn
  72. this.readyTimeoutMs = options.readyTimeoutMs ?? RESTART_READY_TIMEOUT_MS
  73. }
  74. /** Only a live child started by this supervisor may be restarted from the UI. */
  75. get canRestart(): boolean {
  76. return this.managed && !this.stopped && !!this.owned?.pid && this.owned.exitCode === null && this.owned.signalCode === null
  77. }
  78. start(): void {
  79. this.stopped = false
  80. void this.checkNow()
  81. this.timer = setInterval(() => { void this.checkNow() }, this.intervalMs)
  82. this.timer.unref()
  83. }
  84. stop(): void {
  85. this.stopped = true
  86. if (this.timer) clearInterval(this.timer)
  87. this.timer = undefined
  88. }
  89. async restart(): Promise<void> {
  90. if (this.restarting) throw new Error('Hermes Dashboard 正在重启,请等待完成。')
  91. this.restarting = true
  92. try {
  93. while (this.checking) await delay(25)
  94. if (this.stopped) throw new Error('后台服务正在停止,无法重启 Hermes Dashboard。')
  95. if (this.managed) {
  96. if (!this.canRestart) throw new Error('此 Hermes 由外部服务管理,不能由桌面 App 停止。')
  97. await this.stopOwned()
  98. } else this.run(['dashboard', '--stop'])
  99. await this.ensureRunning()
  100. const deadline = Date.now() + this.readyTimeoutMs
  101. while (Date.now() < deadline) {
  102. if (this.stopped || (this.managed && !this.canRestart)) throw new Error('Hermes Dashboard 启动失败。')
  103. if (await this.probe('127.0.0.1', DASHBOARD_PORT)) {
  104. if (this.stopped || (this.managed && !this.canRestart)) throw new Error('Hermes Dashboard 启动失败。')
  105. return
  106. }
  107. await delay(100)
  108. }
  109. throw new Error('Hermes Dashboard 重启后未在规定时间内恢复,请重新检查。')
  110. } finally {
  111. this.restarting = false
  112. }
  113. }
  114. async checkNow(): Promise<void> {
  115. if (this.checking || this.restarting) return
  116. this.checking = true
  117. try {
  118. await this.ensureRunning()
  119. } catch (error) {
  120. this.log(`Hermes Dashboard supervision failed: ${error instanceof Error ? error.message : String(error)}`)
  121. } finally {
  122. this.checking = false
  123. }
  124. }
  125. private async ensureRunning(): Promise<void> {
  126. // Installation must not rewrite credentials or restart/rebind an existing
  127. // upstream. Only a missing managed service is started on loopback.
  128. const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
  129. if (running || this.stopped || this.owned) return
  130. this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
  131. if (this.managed) {
  132. const env: NodeJS.ProcessEnv = { ...process.env, HERMES_PARENT_PID: String(process.pid) }
  133. for (const key of Object.keys(env)) if (key.startsWith('HERMES_YAOYAO_') || key.startsWith('ELECTRON_')) delete env[key]
  134. const child = this.spawnManaged(this.command, ['dashboard', '--host', this.dashboardHost, '--no-open', '--skip-build'], {
  135. env, detached: process.platform !== 'win32', stdio: 'ignore',
  136. })
  137. this.owned = child
  138. child.once('error', error => { if (this.owned === child) this.owned = undefined; this.log(`Hermes 启动失败:${error.message}`) })
  139. child.once('exit', () => { if (this.owned === child) this.owned = undefined })
  140. } else this.launch(['dashboard', '--host', this.dashboardHost])
  141. }
  142. async stopOwned(): Promise<void> {
  143. const child = this.owned
  144. if (!child || !child.pid || child.exitCode !== null || child.signalCode !== null) return
  145. const exited = new Promise<void>(resolve => child.once('exit', () => resolve()))
  146. child.kill('SIGTERM')
  147. await Promise.race([exited, delay(3000)])
  148. if (child.exitCode === null && child.signalCode === null) {
  149. try { process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGKILL') }
  150. catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
  151. await exited
  152. }
  153. }
  154. }