Răsfoiți Sursa

修复桌面单次退出并区分后台运行与完整停止

LSamien 1 lună în urmă
părinte
comite
8490ca19cf

+ 6 - 3
bin/desktop-service-sync.mjs

@@ -2,7 +2,7 @@ import { join, resolve } from 'node:path'
 import { homedir } from 'node:os'
 import { createHash } from 'node:crypto'
 import { mkdirSync } from 'node:fs'
-import { LaunchAgentService, synchronizeDesktop } from './lib/service-update.mjs'
+import { LaunchAgentService, synchronizeDesktop, stopDesktopService } from './lib/service-update.mjs'
 
 // Losing the App's progress pipe must not interrupt a durable service transaction.
 process.stdout.on('error', error => { if (!['EPIPE', 'EIO'].includes(error.code)) process.exitCode = 1 })
@@ -22,8 +22,11 @@ try {
     plistPath: fixture ? join(home, 'updates', 'fixture-service.plist') : undefined,
     environment: options.environment,
   })
-  const result = await synchronizeDesktop({ home, runtimeRoot: resolve(options.runtimeRoot), releaseRoot, driver,
-    onProgress: message => process.stdout.write(JSON.stringify({ message }) + '\n') })
+  if(options.action!==undefined&&!['sync','stop'].includes(options.action))throw new Error('本机服务操作无效')
+  const onProgress = message => process.stdout.write(JSON.stringify({ message }) + '\n')
+  const result = options.action==='stop'
+    ? await stopDesktopService({home,driver,onProgress})
+    : await synchronizeDesktop({ home, runtimeRoot: resolve(options.runtimeRoot), releaseRoot, driver, onProgress })
   const current = result.current
   process.stdout.write(JSON.stringify({ result: { action: result.action, current: current && {
     version: current.version, commit: current.commit, buildNumber: current.buildNumber, artifactDigest: current.artifactDigest,

+ 19 - 1
bin/lib/service-update.mjs

@@ -157,7 +157,7 @@ export class LaunchAgentService {
     const deadline = Date.now() + 15000
     while (pid && alive(pid) && Date.now() < deadline) await sleep(100)
     if ((pid && alive(pid)) || this.listeners().size || this.holders(join(this.home, 'workspace.sqlite3')).size)
-      throw new Error('旧服务尚未完全停止,未进行版本切换')
+      throw new Error('后台服务尚未完全停止,操作未完成')
   }
   writePlist(plist) {
     mkdirSync(dirname(this.plistPath), { recursive: true })
@@ -314,3 +314,21 @@ export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, drive
     return { action: decision, current: target }
   } finally { release?.(); unlock() }
 }
+
+/** Explicit native Stop command. It never changes the release or user data,
+ * and unloads the verified LaunchAgent so KeepAlive cannot restart it. */
+export async function stopDesktopService({home,driver,onProgress=()=>{}}) {
+  const unlock=updateMutex(home)
+  let release
+  try {
+    release=reserveUpdate(home,randomUUID())
+    if(existsSync(join(home,'updates','transition.json')))throw new Error('系统更新尚未完成,请完成更新后再停止后台服务')
+    const previous=driver.snapshot()
+    if(!previous)return {action:'stopped'}
+    if(previous.wasRunning)await driver.verify(undefined,5000)
+    onProgress('正在停止后台任务与虚拟机…')
+    await driver.stop()
+    onProgress('后台服务已停止')
+    return {action:'stopped'}
+  } finally {release?.();unlock()}
+}

+ 3 - 2
desktop/boot.js

@@ -1,8 +1,9 @@
 function display(state) {
   document.getElementById('status').textContent = state.message
-  document.getElementById('actions').hidden = state.phase !== 'error'
+  document.getElementById('actions').hidden = !['error','stopped','disconnected'].includes(state.phase)
+  document.getElementById('retry').textContent = state.phase === 'disconnected' ? '重新连接' : state.phase === 'stopped' ? '启动后台服务' : '重试'
   document.getElementById('status').setAttribute('role', state.phase === 'error' ? 'alert' : 'status')
-  document.querySelector('main').setAttribute('aria-busy', String(state.phase === 'starting' || state.phase === 'restarting'))
+  document.querySelector('main').setAttribute('aria-busy', String(['starting','restarting','stopping'].includes(state.phase)))
 }
 async function render() {
   try { display(await window.yaoyaoDesktop.status()) }

+ 46 - 14
desktop/main.mjs

@@ -7,7 +7,7 @@ import { DesktopServiceManager } from './service-manager.mjs'
 import { DesktopRunnerManager } from './runner-manager.mjs'
 import { DesktopPreferences } from './preferences.mjs'
 import { DesktopCredentials } from './credentials.mjs'
-import { synchronizeLocalService } from './service-sync.mjs'
+import { synchronizeLocalService, stopLocalService } from './service-sync.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
@@ -45,8 +45,10 @@ else {
   })
 
   function show() {
+    if (closing || quitting) return
     if (!window || window.isDestroyed()) createWindow()
     window.show(); window.focus()
+    if (manager) void manager.reconnect().then(() => stateChanged(manager.state)).catch(error => log(error.message))
   }
   function safeExternal(url) {
     try { const parsed = new URL(url); if (['https:', 'http:', 'mailto:'].includes(parsed.protocol)) void shell.openExternal(url) }
@@ -90,6 +92,11 @@ else {
   }
   function stateChanged(state) {
     log(`${state.phase}: ${state.message}`)
+    // Quit owns the window from this point on. A detached connection is not a
+    // stopped server and must never navigate the closing window to boot.html.
+    if (closing || quitting) return
+    const serviceStatus=Menu.getApplicationMenu()?.getMenuItemById('desktop-service-status')
+    if(serviceStatus)serviceStatus.label=state.phase==='ready'?'后台服务运行中':state.message
     const localVmMenu=Menu.getApplicationMenu()?.getMenuItemById('desktop-local-vm')
     if(localVmMenu)localVmMenu.enabled=state.phase==='ready'
     const notice = Menu.getApplicationMenu()?.getMenuItemById('desktop-sync-notice')
@@ -106,19 +113,39 @@ else {
   app.on('second-instance', show)
   app.on('activate', show)
   app.on('window-all-closed', () => {})
-  app.on('before-quit', event => {
-    if (quitting) return
-    event.preventDefault()
-    if (closing) return
+  function requestQuit(stopBackground = false) {
+    if (closing || quitting) return
     closing = true; clearInterval(timer)
-    void (async()=>{
-      try{await runnerManager?.stop();await manager?.stop();quitting=true;app.quit()}
-      catch(error){
+    window?.hide()
+    // Native Cmd+Q can enter before-quit from Cocoa's termination callback.
+    // Both cleanup and the final quit must run after that callback unwinds.
+    setImmediate(() => { void (async()=>{
+      try {
+        await runnerManager?.stop()
+        if (stopBackground) await manager?.stopBackground()
+        else await manager?.stop()
+        quitting = true
+        setImmediate(() => app.quit())
+      } catch(error) {
         log(error.message)
-        const choice=await dialog.showMessageBox(window,{type:'warning',message:'服务清理尚未完成',detail:error.message,buttons:['继续运行','仍然退出'],defaultId:0,cancelId:0})
-        if(choice.response===1){quitting=true;app.quit()}else{closing=false;timer=setInterval(()=>{void manager.check()},10000);timer.unref()}
+        window?.show()
+        const choice=await dialog.showMessageBox(window,{type:'warning',message:stopBackground?'后台服务停止未完成':'App 退出清理未完成',detail:error.message,buttons:['返回应用','仅退出 App'],defaultId:0,cancelId:0})
+        if(choice.response===1){quitting=true;setImmediate(()=>app.quit())}
+        else {
+          closing=false
+          await manager?.reconnect().catch(error=>log(error.message))
+          await runnerManager?.start().catch(error=>log(error.message))
+          stateChanged(manager.state)
+          timer=setInterval(()=>{void manager.check()},10000);timer.unref()
+          show()
+        }
       }
-    })()
+    })() })
+  }
+  app.on('before-quit', event => {
+    if (quitting) return
+    event.preventDefault()
+    requestQuit()
   })
   app.whenReady().then(async () => {
     await preferences.load()
@@ -127,6 +154,8 @@ else {
     const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
+      stopBackground: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
+        ? onProgress => stopLocalService({home,port,root,fixture:Boolean(fixtureHome),onProgress}) : undefined,
       synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
         ? onProgress => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress,
           environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1' } })
@@ -159,6 +188,7 @@ else {
     }
     Menu.setApplicationMenu(Menu.buildFromTemplate([
       { label: '夭夭', submenu: [{ role: 'about', label: '关于夭夭' }, { type: 'separator' },
+        { id:'desktop-service-status',label:'正在连接后台服务…',enabled:false },
         { label: '显示窗口', click: show }, { label: '在浏览器中打开', click: () => { if (manager.state.url) safeExternal(manager.state.url) } },
         { id:'desktop-local-vm',label:'本地虚拟机设置…',enabled:false,click:()=>{if(manager.state.phase==='ready'){show();void window.webContents.executeJavaScript("window.dispatchEvent(new CustomEvent('yaoyao:local-vm-settings'))")}} },
         { label: '查看服务日志', click: () => shell.showItemInFolder(logFile) },
@@ -172,7 +202,9 @@ else {
         { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n当前 Web:${manager.state.version||'尚未就绪'}${manager.state.build?.commit?' · '+manager.state.build.commit.slice(0,12):''}${manager.state.external?'(独立后台服务)':'(开发模式)'}\n\n安装或更新 App 后首次启动,会将较旧的本机 Web 同步到配套构建。已有更新的 Web 会保留;同一个 App 构建不会重复同步。\n\nWeb 可以继续自行升级。独立 Web 服务在退出 App 后继续运行;App 管理的执行节点会停止。\n\n降级通过 Web 的回滚入口单独处理。数据库结构不兼容时,需要独立的数据恢复流程。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
         { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
-        { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] },
+        { type: 'separator' },
+        { id:'desktop-quit',label:'退出夭夭(后台继续运行)',accelerator:'CommandOrControl+Q',click:()=>requestQuit() },
+        { id:'desktop-stop-and-quit',label:'停止后台服务并退出',click:()=>requestQuit(true) }] },
       {label:'执行节点',submenu:[{id:'runner-status',label:'未配置执行节点',enabled:false},{type:'separator'},
         {id:'runner-import',label:'导入节点配置…',click:importRunner},
         {id:'runner-reconnect',label:'重新连接',click:()=>runnerAction(()=>runnerManager.start())},
@@ -183,10 +215,10 @@ else {
     icon.setTemplateImage(true)
     tray = new Tray(icon)
     tray.setToolTip('夭夭')
-    tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭', click: () => app.quit() }]))
+    tray.setContextMenu(Menu.buildFromTemplate([{ label: '打开夭夭', click: show }, { label: '查看日志', click: () => shell.showItemInFolder(logFile) }, { type: 'separator' }, { label: '退出夭夭(后台继续运行)', click: () => requestQuit() }, { label: '停止后台服务并退出', click: () => requestQuit(true) }]))
     tray.on('click', show)
     createWindow()
-    powerMonitor.on('resume', () => { void manager.check() })
+    powerMonitor.on('resume', () => { if(!closing&&!quitting)void manager.check() })
     timer = setInterval(() => { void manager.check() }, 10_000); timer.unref()
     await manager.start().catch(error => log(error.message))
     if(!closing)await runnerManager.start().catch(error=>{runnerManager.publish(error.message)})

+ 30 - 2
desktop/service-manager.mjs

@@ -57,6 +57,16 @@ export class DesktopServiceManager {
     this.state = { phase: 'stopped', message: '正在准备同步 Web…' }
     return this.start()
   }
+  async reconnect() {
+    const generation=this.generation
+    if (this.state.phase === 'ready' && !this.stopping) {
+      await this.check()
+      if(this.stopping||generation!==this.generation)return this.state
+    }
+    if (this.state.phase === 'ready' && !this.stopping) return this.state
+    clearTimeout(this.restartTimer)
+    return this.start()
+  }
   async readRecord() {
     try {
       const r = JSON.parse(await readFile(join(this.root, 'service-instance.json'), 'utf8'))
@@ -94,7 +104,7 @@ export class DesktopServiceManager {
   }
   async start() {
     if (this.starting) return this.starting
-    if (this.state.phase === 'ready') return this.state
+    if (this.state.phase === 'ready' && !this.stopping) return this.state
     this.stopping = false
     this.starting = this.boot().finally(() => { this.starting = null })
     return this.starting
@@ -221,11 +231,29 @@ export class DesktopServiceManager {
     if (this.child === child) this.child = null
   }
   async stop() {
+    const owned = Boolean(this.child)
     this.stopping = true
     clearTimeout(this.restartTimer)
     ++this.generation
     await this.starting?.catch(() => {})
     await this.stopChild()
-    this.publish({ phase: 'stopped', message: '本地服务已停止' })
+    this.publish(owned ? { phase: 'stopped', message: '后台服务已停止' }
+      : { phase: 'disconnected', message: 'App 已断开后台连接' })
+  }
+  async stopBackground() {
+    this.stopping = true
+    clearTimeout(this.restartTimer)
+    ++this.generation
+    await this.starting?.catch(() => {})
+    this.publish({phase:'stopping',message:'正在停止后台服务…'})
+    try {
+      if(this.options.stopBackground)await this.options.stopBackground(message=>this.publish({phase:'stopping',message}))
+      else if(this.child)await this.stopChild()
+      else throw new Error('此后台服务不由本 App 管理,请在它的启动位置停止')
+      this.publish({phase:'stopped',message:'后台服务已停止'})
+    } catch(error) {
+      this.publish({phase:'error',message:'后台服务停止未完成,请重新连接检查'})
+      throw error
+    }
   }
 }

+ 29 - 0
desktop/service-manager.test.mjs

@@ -35,8 +35,37 @@ test('busy synchronization connects the verified existing Web and remains retrya
     assert.equal(state.phase,'ready');assert.equal(state.external,true);assert.match(state.updateNotice,/同步待完成/)
     assert.equal((await manager.retrySynchronization()).updateNotice,undefined);assert.equal(attempts,2)
     await manager.stop()
+    assert.equal(manager.state.phase,'disconnected')
+    assert.doesNotMatch(manager.state.message,/服务已停止/)
+    assert.equal((await manager.reconnect()).phase,'ready')
+    assert.equal(manager.state.pid,process.pid)
+    await assert.rejects(manager.stopBackground(),/不由本 App 管理/)
+    assert.equal(manager.state.phase,'error')
+    assert.equal((await manager.reconnect()).phase,'ready')
     const invalid=new DesktopServiceManager({...options,synchronize:async()=>{throw new Error('invalid package')}})
     await assert.rejects(invalid.start(),/invalid package/)
     assert.equal(invalid.state.phase,'error')
   }finally{await new Promise(done=>server.close(done));await rm(home,{recursive:true,force:true})}
 })
+test('publishes stopped only after the native service manager confirms shutdown',async()=>{
+ let finish,fail=false
+ const manager=new DesktopServiceManager({stopBackground:()=>fail?Promise.reject(new Error('still alive')):new Promise(resolve=>{finish=resolve})})
+ const stopping=manager.stopBackground()
+ await new Promise(resolve=>setImmediate(resolve))
+ assert.equal(manager.state.phase,'stopping')
+ finish();await stopping
+ assert.equal(manager.state.phase,'stopped')
+ fail=true;await assert.rejects(manager.stopBackground(),/still alive/)
+ assert.equal(manager.state.phase,'error')
+ assert.doesNotMatch(manager.state.message,/服务已停止/)
+})
+test('a late activate health check cannot reconnect after quit has begun',async()=>{
+ const manager=new DesktopServiceManager({})
+ manager.state={phase:'ready'}
+ let finish,starts=0
+ manager.check=()=>new Promise(resolve=>{finish=resolve})
+ manager.start=async()=>{starts++}
+ const reconnect=manager.reconnect()
+ await manager.stop();finish();await reconnect
+ assert.equal(starts,0);assert.equal(manager.state.phase,'disconnected')
+})

+ 5 - 3
desktop/service-sync.mjs

@@ -3,7 +3,9 @@ import { join } from 'node:path'
 
 /** A native helper owns the update until completion, even if the window closes.
  * The renderer never receives its configuration or a process-control bridge. */
-export function synchronizeLocalService({ home, port, root, fixture, environment, onProgress }) {
+export const synchronizeLocalService = options => serviceCommand({...options,action:'sync'})
+export const stopLocalService = options => serviceCommand({...options,action:'stop'})
+function serviceCommand({ home, port, root, fixture, environment, onProgress = () => {}, action }) {
   return new Promise((done, reject) => {
     const runtimeRoot = join(root, 'web-service')
     const helper = spawn(join(runtimeRoot, 'node'), [join(runtimeRoot, 'bin', 'desktop-service-sync.mjs')], {
@@ -22,8 +24,8 @@ export function synchronizeLocalService({ home, port, root, fixture, environment
       }
     })
     helper.once('error', reject)
-    helper.once('exit', code => code === 0 && result ? done(result) : reject(Object.assign(new Error(failure || '本机 Web 同步未完成,请重试'), { code: failureCode })))
+    helper.once('exit', code => code === 0 && result ? done(result) : reject(Object.assign(new Error(failure || (action==='stop'?'后台服务停止未完成':'本机 Web 同步未完成,请重试')), { code: failureCode })))
     helper.stdin.on('error', () => {})
-    helper.stdin.end(JSON.stringify({ home, port, runtimeRoot, fixture, environment }))
+    helper.stdin.end(JSON.stringify({ home, port, runtimeRoot, fixture, environment, action }))
   })
 }

+ 37 - 5
desktop/service-sync.test.mjs

@@ -26,6 +26,21 @@ async function until(read, accept, timeout = 60000) {
   }
   throw new Error('等待同步服务状态超时')
 }
+async function nativeQuit(app, menu = false) {
+  let exited=false;app.process().once('exit',()=>{exited=true})
+  let output='';app.process().stdout.on('data',bytes=>{output+=bytes})
+  await app.evaluate(({app,BrowserWindow})=>{
+    const navigations=[]
+    BrowserWindow.getAllWindows()[0].webContents.on('did-start-navigation',(_event,url)=>navigations.push(url))
+    app.once('will-quit',()=>console.log('QUIT_TEST_NAVIGATIONS='+JSON.stringify(navigations)))
+  })
+  await app.evaluate(({app,Menu},menu)=>{if(menu)Menu.getApplicationMenu().getMenuItemById('desktop-quit').click();else app.quit()},menu)
+  await until(()=>exited,Boolean,10000)
+  const proof=output.match(/QUIT_TEST_NAVIGATIONS=(\[[^\n]*\])/)
+  assert.ok(proof,'native will-quit must complete')
+  assert.equal(JSON.parse(proof[1]).some(url=>url.includes('boot.html')),false,'native quit must not navigate to a stopped boot page')
+  await app.close().catch(()=>{})
+}
 
 test('App first launch installs an independent Web; Web remains up after quit and a newer Web is retained', { timeout: 150000 }, async () => {
   const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-sync-native-')))
@@ -62,7 +77,7 @@ test('App first launch installs an independent Web; Web remains up after quit an
       return (await fetch('/api/app/system/update/status')).json()
     })
     assert.equal(support.installationMode, 'release'); assert.equal(support.supported, true)
-    await app.close(); app = undefined
+    await nativeQuit(app); app = undefined
     assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok, true)
     assert.equal(driver.pid(), record.pid, 'quitting the App must leave the independent Web running')
     const saved = driver.snapshot().plist
@@ -86,15 +101,32 @@ test('App first launch installs an independent Web; Web remains up after quit an
     const marker = JSON.parse(await readFile(join(home, 'updates', 'desktop-sync.json'), 'utf8'))
     assert.equal(marker.action, 'newer')
     assert.equal(marker.artifactDigest, original.artifactDigest)
-    await app.close(); app = undefined
-    assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok, true)
-    await driver.stop()
+    // Keep an authenticated event stream open. Stop-and-quit must close it,
+    // unload launchd (not just kill its PID), and exit with one menu action.
+    await reopened.evaluate(async()=>{
+      const capabilities=await (await fetch('/api/realtime/capabilities')).json()
+      const created=await fetch('/api/realtime/channels',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':capabilities.csrfToken},body:JSON.stringify({channel:'chat'})})
+      if(!created.ok)throw new Error('fixture realtime channel unavailable')
+      const channel=await created.json()
+      const response=await fetch(`/api/realtime/channels/${channel.id}/events`)
+      if(!response.ok)throw new Error('fixture event stream unavailable')
+      if(!response.headers.get('content-type')?.includes('text/event-stream'))throw new Error('expected a real SSE stream')
+      window.stopTestStream=response.body.getReader()
+      void window.stopTestStream.read()
+    })
+    let exited=false;app.process().once('exit',()=>{exited=true})
+    await app.evaluate(({Menu})=>Menu.getApplicationMenu().getMenuItemById('desktop-stop-and-quit').click())
+    await until(()=>exited,Boolean,30000);app=undefined
+    assert.equal(driver.pid(),undefined)
+    assert.equal(driver.listeners().size,0)
+    await new Promise(done=>setTimeout(done,1000))
+    assert.equal(driver.pid(),undefined,'launchd must not relaunch a deliberately stopped service')
     app = await launch()
     const restarted = await app.firstWindow(); await restarted.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
     const resumed = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
     assert.equal(resumed.version, '0.3.33'); assert.equal(resumed.desktopOwned, false)
     assert.notEqual(resumed.pid, updated.pid)
-    await app.close(); app = undefined
+    await nativeQuit(app,true); app = undefined
   } catch (error) {
     // Only this fixture's server log, which contains startup diagnostics.
     const log = await readFile(join(home, 'service.log'), 'utf8').catch(() => '')

+ 15 - 0
docs/desktop-lifecycle.md

@@ -0,0 +1,15 @@
+# macOS App 与后台服务
+
+| 操作 | 结果 |
+| --- | --- |
+| 关闭窗口 | 隐藏窗口,App 与后台继续运行 |
+| ⌘Q / 退出夭夭(后台继续运行) | 一次退出 App,独立 Web、本机托管 Runner 与虚拟机继续按原生命周期运行 |
+| 停止后台服务并退出 | 停止本应用管理的后台服务、任务与虚拟机,再退出 App;保留工作文件和配置 |
+
+重新打开 App 会核对已有后台的身份和健康状态;后台运行时连接现有实例,已停止时重新启动。退出期间不切回启动页,也不接受重复打开/退出请求。应用菜单可查看“后台服务运行中”。只有确认服务停止才显示“后台服务已停止”;断开连接单独表示为“App 已断开后台连接”。
+
+普通退出仍会停止由 App 自身启动的高级外部 Runner;“本地虚拟机”使用的托管 Runner 在独立 Web 内运行,不受普通退出影响。服务停止只针对已核对数据目录、端口、进程和 LaunchAgent 的本机实例,不停止用户另外启动的 Hermes、Docker 或其他服务。
+
+停止操作使用与升级相同的互斥保护,更新进行中会拒绝停止。通过 `launchctl bootout` 卸载服务,避免 KeepAlive 重启;启动配置保留,下次打开 App 可重新启动。后台停止期间拒绝新应用请求,并在清理虚拟机后关闭 HTTP 长连接。
+
+验收应分别验证原生 ⌘Q/菜单退出、窗口关闭、连续重新打开、真实后台停止及重新启动。仅调用测试框架的关闭方法不能证明一次原生退出完成。

+ 4 - 1
src/server/index.ts

@@ -93,10 +93,13 @@ let closing = false
 async function shutdown(): Promise<void> {
   if (closing) return
   closing = true
+  instance.beginShutdown()
   try {
     dashboardSupervisor?.stop()
     await closeFrontend()
-    await nodeRuntime.close(instance.quiesced)
+    // Existing browser/SSE connections must not keep an explicitly stopped
+    // background process alive after its workers and VMs have been cleaned up.
+    await nodeRuntime.close(true)
     await dashboardSupervisor?.stopOwned()
   } finally {
     instance.release()

+ 4 - 1
src/server/serviceInstance.ts

@@ -40,12 +40,14 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
   }
   try { write() } catch (error) { lock.close(); throw error }
   let released = false
+  let stopping = false
   let quiescedUntil = 0, pendingMutations = 0
   const transition = join(root, 'updates', 'transition.json')
   const quiesced = () => Date.now() < quiescedUntil || existsSync(transition)
   return {
     record,
     get quiesced() { return quiesced() },
+    beginShutdown() { stopping = true },
     publish(url: string) { record.url = url; write() },
     release() {
       if (released) return
@@ -62,6 +64,7 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
       return async (ctx, next) => {
         const control = ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce'
         if (!control) {
+          if(stopping){ctx.status=503;ctx.body={error:'后台服务正在停止',code:'service_stopping'};return}
           if (quiesced() && !['/healthz', '/readyz', '/api/status'].includes(ctx.path)) {
             ctx.status = 503; ctx.set('Retry-After', '3')
             ctx.body = { error: 'Web 服务正在更新,请稍后重试', code: 'service_updating' }; return
@@ -85,7 +88,7 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
           } else { ctx.status = 405; ctx.body = { error: '更新事务尚未结束' } }
         } else if (ctx.method === 'GET') {
           const { token: _token, ...publicRecord } = record
-          ctx.body = { ...publicRecord, quiesced: quiesced() }
+          ctx.body = { ...publicRecord, quiesced: quiesced(), stopping }
         } else if (ctx.method === 'DELETE' && desktopOwned) {
           ctx.body = { stopping: true }
           setImmediate(() => { void shutdown() })

+ 8 - 1
tests/live/localVm.live.test.ts

@@ -5,6 +5,8 @@ import {readFileSync} from 'node:fs'
 import {tmpdir} from 'node:os'
 import {join,resolve} from 'node:path'
 import {createServer} from 'node:http'
+import {execFile} from 'node:child_process'
+import {promisify} from 'node:util'
 import {chromium} from '@playwright/test'
 import serve from 'koa-static'
 import request from 'supertest'
@@ -112,7 +114,12 @@ it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)
   await page.screenshot({path:join(evidence,'two-desktops.png')})
   await desktops.getByRole('button',{name:'交还并关闭',exact:true}).click()
  }finally{
-  await browser?.close();await node.close(true)
+  const runnerId=app.runners.records()[0]?.id
+  // Shut down while the browser is still attached. All owned desktops must
+  // actually stop before the server reports completion.
+  await node.close(true)
+  if(runnerId){const result=await promisify(execFile)('docker',['ps','-q','--filter',`label=cn.samien.yaoyao.runner=${runnerId}`]);expect(result.stdout.trim()).toBe('')}
+  await browser?.close()
   await new Promise<void>(done=>{upstream.close(()=>done());upstream.closeAllConnections()})
   await rm(root,{recursive:true,force:true})
  }

+ 16 - 1
tests/server/desktopSync.test.ts

@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { syncDecision, sealRuntime, verifyRuntimePackage } from '../../bin/lib/runtime-release.mjs'
 import { assertRollbackCompatible, backupData, databaseSchema } from '../../bin/lib/service-data.mjs'
-import { currentRelease, reserveUpdate, switchRelease, synchronizeDesktop, transitionService, updateMutex, writeJSON, recoverTransition } from '../../bin/lib/service-update.mjs'
+import { currentRelease, reserveUpdate, switchRelease, synchronizeDesktop, stopDesktopService, transitionService, updateMutex, writeJSON, recoverTransition } from '../../bin/lib/service-update.mjs'
 import { RunnerHub } from '../../src/server/runnerHub'
 
 const homes: string[] = []
@@ -21,6 +21,21 @@ function runtime(root: string, commit = 'b'.repeat(40), version = '0.3.32', ance
 }
 const clean = (commit: string, ancestors: string[] = [], version = '0.3.32') => ({ commit, ancestors, version, dirty: false })
 
+it('stops only a verified managed service and refuses to race an update',async()=>{
+ const home=temporary(),calls:string[]=[]
+ const driver={snapshot:()=>({wasRunning:true}),verify:async()=>{calls.push('verify')},stop:async()=>{calls.push('stop')}}
+ const unlock=updateMutex(home)
+ try{await expect(stopDesktopService({home,driver})).rejects.toThrow('更新');expect(calls).toEqual([])}finally{unlock()}
+ writeJSON(join(home,'updates','transition.json'),{})
+ await expect(stopDesktopService({home,driver})).rejects.toThrow('更新尚未完成');expect(calls).toEqual([])
+ rmSync(join(home,'updates','transition.json'))
+ await expect(stopDesktopService({home,driver:{...driver,snapshot:()=>{throw new Error('wrong owner')}}})).rejects.toThrow('wrong owner')
+ expect(calls).toEqual([])
+ expect(await stopDesktopService({home,driver})).toEqual({action:'stopped'})
+ expect(calls).toEqual(['verify','stop'])
+ await expect(stopDesktopService({home,driver:{...driver,stop:async()=>{throw new Error('still running')}}})).rejects.toThrow('still running')
+})
+
 describe('desktop release selection and integrity', () => {
   it('orders same-version builds by ancestry, preserves newer Web, and refuses unrelated builds', () => {
     expect(syncDecision(clean('b', ['a']), clean('a'))).toBe('upgrade')

+ 11 - 0
tests/server/serviceInstance.test.ts

@@ -14,6 +14,17 @@ afterEach(() => { for (const instance of instances) instance.release(); rmSync(h
 const acquire = (desktop = true) => { const instance = acquireServiceInstance(home, '0.3.32', desktop); instances.push(instance); return instance }
 
 describe('service ownership', () => {
+  it('rejects new application requests during shutdown without calling it an update',async()=>{
+    const instance=acquire(false)
+    const app=new Koa().use(instance.middleware(async()=>{})).use(ctx=>{ctx.body={ok:true}})
+    await request(app.callback()).post('/api/app/conversations').expect(200)
+    instance.beginShutdown()
+    const blocked=await request(app.callback()).post('/api/app/conversations').expect(503)
+    expect(blocked.body.code).toBe('service_stopping')
+    const state=await request(app.callback()).get('/desktop/service').set('x-yaoyao-desktop-token',instance.record.token).expect(200)
+    expect(state.body.stopping).toBe(true)
+    expect(state.body.quiesced).toBe(false)
+  })
   it('excludes a second process connection and releases ownership explicitly', () => {
     const first = acquire()
     expect(() => acquire()).toThrow('另一个')