dashboardSupervisor.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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 suspendedChecks = 0
  58. private owned: ChildProcess | undefined
  59. private stopped = false
  60. private readonly managed: boolean
  61. private restarting = false
  62. private readonly spawnManaged: NonNullable<DashboardSupervisorOptions['spawnManaged']>
  63. private readonly readyTimeoutMs: number
  64. constructor(options: DashboardSupervisorOptions) {
  65. this.command = options.command ?? 'hermes'
  66. this.run = options.run ?? defaultRun(this.command)
  67. this.launch = options.launch ?? defaultLaunch(this.command)
  68. this.probe = options.probe ?? portIsListening
  69. this.log = options.log ?? console.info
  70. this.intervalMs = options.intervalMs ?? SUPERVISION_INTERVAL_MS
  71. this.managed = options.managed === true
  72. this.spawnManaged = options.spawnManaged ?? spawn
  73. this.readyTimeoutMs = options.readyTimeoutMs ?? RESTART_READY_TIMEOUT_MS
  74. }
  75. /** Only a live child started by this supervisor may be restarted from the UI. */
  76. get canRestart(): boolean {
  77. return this.managed && !this.stopped && !!this.owned?.pid && this.owned.exitCode === null && this.owned.signalCode === null
  78. }
  79. start(): void {
  80. this.stopped = false
  81. void this.checkNow()
  82. this.timer = setInterval(() => { void this.checkNow() }, this.intervalMs)
  83. this.timer.unref()
  84. }
  85. stop(): void {
  86. this.stopped = true
  87. if (this.timer) clearInterval(this.timer)
  88. this.timer = undefined
  89. }
  90. /** Keep legacy availability checks from racing a system service restart. */
  91. suspendChecks(): () => void {
  92. this.suspendedChecks++
  93. let resumed = false
  94. return () => { if (!resumed) { resumed = true; this.suspendedChecks-- } }
  95. }
  96. async restart(): Promise<void> {
  97. if (this.restarting) throw new Error('Hermes Dashboard 正在重启,请等待完成。')
  98. this.restarting = true
  99. try {
  100. while (this.checking) await delay(25)
  101. if (this.stopped) throw new Error('后台服务正在停止,无法重启 Hermes Dashboard。')
  102. if (this.managed) {
  103. if (!this.canRestart) throw new Error('此 Hermes 由外部服务管理,不能由桌面 App 停止。')
  104. await this.stopOwned()
  105. } else this.run(['dashboard', '--stop'])
  106. await this.ensureRunning()
  107. const deadline = Date.now() + this.readyTimeoutMs
  108. while (Date.now() < deadline) {
  109. if (this.stopped || (this.managed && !this.canRestart)) throw new Error('Hermes Dashboard 启动失败。')
  110. if (await this.probe('127.0.0.1', DASHBOARD_PORT)) {
  111. if (this.stopped || (this.managed && !this.canRestart)) throw new Error('Hermes Dashboard 启动失败。')
  112. return
  113. }
  114. await delay(100)
  115. }
  116. throw new Error('Hermes Dashboard 重启后未在规定时间内恢复,请重新检查。')
  117. } finally {
  118. this.restarting = false
  119. }
  120. }
  121. async checkNow(): Promise<void> {
  122. if (this.checking || this.restarting || this.suspendedChecks) return
  123. this.checking = true
  124. try {
  125. await this.ensureRunning()
  126. } catch (error) {
  127. this.log(`Hermes Dashboard supervision failed: ${error instanceof Error ? error.message : String(error)}`)
  128. } finally {
  129. this.checking = false
  130. }
  131. }
  132. private async ensureRunning(): Promise<void> {
  133. // Installation must not rewrite credentials or restart/rebind an existing
  134. // upstream. Only a missing managed service is started on loopback.
  135. const running = await this.probe('127.0.0.1', DASHBOARD_PORT)
  136. if (running || this.stopped || this.owned || this.suspendedChecks) return
  137. this.log(`Hermes Dashboard is unavailable on 9119; starting it on ${this.dashboardHost}.`)
  138. if (this.managed) {
  139. const env: NodeJS.ProcessEnv = { ...process.env, HERMES_PARENT_PID: String(process.pid) }
  140. for (const key of Object.keys(env)) if (key.startsWith('HERMES_YAOYAO_') || key.startsWith('ELECTRON_')) delete env[key]
  141. const child = this.spawnManaged(this.command, ['dashboard', '--host', this.dashboardHost, '--no-open', '--skip-build'], {
  142. env, detached: process.platform !== 'win32', stdio: 'ignore',
  143. })
  144. this.owned = child
  145. child.once('error', error => { if (this.owned === child) this.owned = undefined; this.log(`Hermes 启动失败:${error.message}`) })
  146. child.once('exit', () => { if (this.owned === child) this.owned = undefined })
  147. } else this.launch(['dashboard', '--host', this.dashboardHost])
  148. }
  149. async stopOwned(): Promise<void> {
  150. const child = this.owned
  151. if (!child || !child.pid || child.exitCode !== null || child.signalCode !== null) return
  152. const exited = new Promise<void>(resolve => child.once('exit', () => resolve()))
  153. child.kill('SIGTERM')
  154. await Promise.race([exited, delay(3000)])
  155. if (child.exitCode === null && child.signalCode === null) {
  156. try { process.kill(process.platform === 'win32' ? child.pid : -child.pid, 'SIGKILL') }
  157. catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
  158. await exited
  159. }
  160. }
  161. }