diff --git a/apps/desktop/e2e/quit.spec.ts b/apps/desktop/e2e/quit.spec.ts index 9ff393d..c143963 100644 --- a/apps/desktop/e2e/quit.spec.ts +++ b/apps/desktop/e2e/quit.spec.ts @@ -48,3 +48,40 @@ test('quitting DevHub stops the whole process tree of running scripts', async ({ await app.close() await expect.poll(() => tree.filter(isAlive), { timeout: 15_000 }).toEqual([]) }) + +// Logout, shutdown and `systemctl --user stop` send SIGTERM first; Windows has no equivalent. +test.describe('SIGTERM', () => { + test.skip(process.platform === 'win32', 'POSIX signals only') + + test('quits DevHub gracefully when nothing is running', async ({ launchDevhub }) => { + const { app } = await launchDevhub() + const child = app.process() + const exited = new Promise((resolve) => child.once('exit', resolve)) + + child.kill('SIGTERM') + await exited + // A graceful quit exits normally; the default handler would report the signal instead. + expect({ code: child.exitCode, signal: child.signalCode }).toEqual({ code: 0, signal: null }) + }) + + test('stops the process tree of running scripts', async ({ launchDevhub, createProject }) => { + const dir = await createProject('server', { + 'package.json': packageJson({ serve: 'node server.js' }), + 'server.js': serverScript + }) + const { app, page } = await launchDevhub() + await addProjectViaApi(page, dir) + await page.getByRole('button', { name: '运行 serve' }).click() + await expect(page.locator('.xterm-rows')).toContainText('serving') + const pids = pidsSchema.parse(JSON.parse(await readFile(join(dir, 'pids.json'), 'utf8'))) + const tree = [pids.parent, pids.child] + expect(tree.every(isAlive)).toBe(true) + + const child = app.process() + const exited = new Promise((resolve) => child.once('exit', resolve)) + child.kill('SIGTERM') + await exited + expect(child.exitCode).toBe(0) + await expect.poll(() => tree.filter(isAlive), { timeout: 15_000 }).toEqual([]) + }) +}) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 012a85c..4fb863a 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -84,7 +84,11 @@ if (!app.requestSingleInstanceLock()) { const timeout = new Promise((resolve) => setTimeout(resolve, disposeTimeoutMs)) void Promise.race([core.dispose(), timeout]) .catch((error: unknown) => console.error('[quit] failed to stop runs', error)) - .finally(() => app.quit()) + // Not directly: with nothing to stop, dispose settles in the microtasks Node drains as + // this handler returns. On SIGTERM Electron calls Quit() with no JS on the stack, so a + // nested quit would run inside it and be overwritten by this prevented one, leaving + // DevHub running with every window closed (#39). + .finally(() => setImmediate(() => app.quit())) }) // With "keep in tray" the window only hides, so this fires only for "quit on close". diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9f6de2f..05ffc56 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -50,7 +50,7 @@ M4-3 Android: 桌面修复: - #22 已完成:启动的子进程不再直接继承 DevHub 的环境:Windows 从注册表修复并补全 PATH(更新后经 Explorer 重启时 `%NVM_HOME%` 未展开、找不到 pnpm),Linux 捕获交互登录 shell 的环境(从桌面启动时缺少 `.bashrc` 中的 nvm)(ADR 0024)。 -- #39 待开始:Linux 上收到 SIGTERM 不退出(#8 中发现),关机或注销时可能留下孤儿进程。 +- #39 已完成,待合并:Linux 上收到 SIGTERM 不退出(#8 中发现)。没有运行中的脚本时,`before-quit` 里的 `app.quit()` 会在 Electron 处理信号的 `Quit()` 内部重入,退出状态被外层覆盖,窗口关闭后进程挂住;改为推迟到下一轮事件循环。E2E 覆盖 SIGTERM 退出与停止进程树(Windows 跳过)。 - #42 已完成,待合并:远程服务启动时端口被占用(更新后旧进程尚未退出)会重试约 15 秒,期间状态显示「正在重试」;修改设置时的冲突仍立即报告。 桌面 MVP 发布: