Просмотр исходного кода

修复服务升级重注册竞态

samien 2 месяцев назад
Родитель
Сommit
38d17d9261
2 измененных файлов с 54 добавлено и 5 удалено
  1. 28 3
      bin/hermes-yaoyao.mjs
  2. 26 2
      tests/serviceCli.test.ts

+ 28 - 3
bin/hermes-yaoyao.mjs

@@ -5,6 +5,7 @@ import { existsSync, realpathSync } from 'node:fs'
 import { chmod, mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises'
 import { homedir } from 'node:os'
 import { dirname, join, resolve } from 'node:path'
+import { setTimeout as delay } from 'node:timers/promises'
 import { fileURLToPath } from 'node:url'
 
 const label = 'com.samien.hermes-yaoyao'
@@ -103,6 +104,30 @@ function loaded() {
   }
 }
 
+function launchctlBootstrapRetryable(error) {
+  const detail = [error?.message, error?.stdout, error?.stderr]
+    .filter(Boolean)
+    .map(String)
+    .join('\n')
+  return /Bootstrap failed:\s*5:\s*Input\/output error/i.test(detail)
+}
+
+export async function bootstrapLaunchAgent(
+  runCommand = run,
+  wait = delay,
+  retryDelays = [250, 500, 1_000, 1_500],
+) {
+  for (let attempt = 0; ; attempt += 1) {
+    try {
+      return runCommand('launchctl', ['bootstrap', domain, plistPath])
+    } catch (error) {
+      const waitMs = retryDelays[attempt]
+      if (waitMs === undefined || !launchctlBootstrapRetryable(error)) throw error
+      await wait(waitMs)
+    }
+  }
+}
+
 async function install() {
   if (!existsSync(serverEntry)) {
     throw new Error('未找到生产构建,请先运行 npm run build')
@@ -114,13 +139,13 @@ async function install() {
   await writeFile(plistPath, launchAgentPlist(), { mode: 0o600 })
   await chmod(plistPath, 0o600)
   run('plutil', ['-lint', plistPath], { inherit: true })
-  run('launchctl', ['bootstrap', domain, plistPath])
+  await bootstrapLaunchAgent()
   process.stdout.write(`已安装并启动 ${label}\n`)
 }
 
-function start() {
+async function start() {
   if (!existsSync(plistPath)) throw new Error('服务尚未安装')
-  if (!loaded()) run('launchctl', ['bootstrap', domain, plistPath])
+  if (!loaded()) await bootstrapLaunchAgent()
   else run('launchctl', ['kickstart', '-k', `${domain}/${label}`])
   process.stdout.write(`已启动 ${label}\n`)
 }

+ 26 - 2
tests/serviceCli.test.ts

@@ -1,8 +1,8 @@
 import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'
 import { homedir, tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
-import { isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
+import { describe, expect, it, vi } from 'vitest'
+import { bootstrapLaunchAgent, isMainModule, launchAgentPlist } from '../bin/hermes-yaoyao.mjs'
 
 describe('hermes-yaoyao LaunchAgent plist', () => {
   it('includes the local Hermes CLI directory in PATH', () => {
@@ -33,4 +33,28 @@ describe('hermes-yaoyao LaunchAgent plist', () => {
       rmSync(root, { recursive: true, force: true })
     }
   })
+
+  it('retries the transient launchctl bootstrap race after bootout', async () => {
+    const error = Object.assign(new Error('launchctl bootstrap failed'), {
+      stderr: 'Bootstrap failed: 5: Input/output error',
+    })
+    const run = vi.fn()
+      .mockImplementationOnce(() => { throw error })
+      .mockReturnValue('')
+    const wait = vi.fn(async () => {})
+
+    await expect(bootstrapLaunchAgent(run, wait)).resolves.toBe('')
+    expect(run).toHaveBeenCalledTimes(2)
+    expect(wait).toHaveBeenCalledWith(250)
+  })
+
+  it('does not retry permanent launchctl bootstrap failures', async () => {
+    const error = new Error('Bootstrap failed: 125: Domain does not support specified action')
+    const run = vi.fn(() => { throw error })
+    const wait = vi.fn(async () => {})
+
+    await expect(bootstrapLaunchAgent(run, wait)).rejects.toThrow(error)
+    expect(run).toHaveBeenCalledTimes(1)
+    expect(wait).not.toHaveBeenCalled()
+  })
 })