From 1aec9a3795e11f9ba14d22405ed6961f8693fa37 Mon Sep 17 00:00:00 2001 From: delta <528491526@qq.com> Date: Wed, 7 Oct 2026 16:00:46 +0800 Subject: [PATCH] fix(desktop): quit on SIGTERM when no scripts are running With nothing to stop, core.dispose() settles within the microtasks Node drains as the before-quit handler returns. On SIGTERM Electron calls Browser::Quit() from a task with no JS on the stack, so the follow-up app.quit() re-entered Quit() before the outer call stored its result; the outer, prevented result then reset is_quitting_. The windows still closed, Electron saw "all windows closed" without quitting, and DevHub stayed alive with its tray destroyed, so logout and shutdown had to SIGKILL it. Defer the follow-up quit to the next event loop turn. Add Linux/macOS E2E tests for SIGTERM, idle and with a running process tree; the idle one times out without the fix. Closes #39 --- apps/desktop/e2e/quit.spec.ts | 37 ++++++++++++++++++++++++++++++++++ apps/desktop/src/main/index.ts | 6 +++++- docs/PROGRESS.md | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) 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 发布: