From cad1af1e69fda9cb300705b30a82ee46bb534d59 Mon Sep 17 00:00:00 2001 From: Julian <1041324235@qq.com> Date: Tue, 1 Sep 2026 09:49:40 +0800 Subject: [PATCH] fix: make serve shutdown reliable --- .all-contributorsrc | 9 ++ CONTRIBUTING.md | 8 +- README.md | 3 + README_zh.md | 3 + packages/cli/src/commands/serve-shutdown.ts | 57 +++++++++++ packages/cli/src/commands/serve.ts | 22 ++--- .../cli/tests/commands/serve-shutdown.test.ts | 96 +++++++++++++++++++ 7 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/commands/serve-shutdown.ts create mode 100644 packages/cli/tests/commands/serve-shutdown.test.ts diff --git a/.all-contributorsrc b/.all-contributorsrc index eec7a9cd..0da604d2 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -76,6 +76,15 @@ "contributions": [ "code" ] + }, + { + "login": "lmingde", + "name": "Mingde", + "avatar_url": "https://avatars.githubusercontent.com/u/38581341?v=4", + "profile": "https://github.com/lmingde", + "contributions": [ + "bug" + ] } ], "contributorsPerLine": 7, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e0f4d47..74f5623e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,9 +28,11 @@ pnpm lint ```text packages/ - core/ - Shared types, database schema, pricing data - cli/ - CLI tool for parsing logs, querying data, sync - web/ - SvelteKit web dashboard (SPA) + core/ - Shared types, database schema, pricing data, utilities + cli/ - Published CLI, parsers, local API server, sync, PM2 helpers + web/ - Local SvelteKit dashboard bundled into the CLI + widget/ - Electron tray/menu-bar widget + site/ - Official website, docs, accounts, uploads, leaderboard ``` ## Submitting Changes diff --git a/README.md b/README.md index 090574e0..73cede1b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,9 @@ Thanks to these wonderful people ([emoji key](https://allcontributors.org/en/ref jlxyfll
jlxyfll

💻 Harlan Hu
Harlan Hu

💻 + + Mingde
Mingde

🐛 + diff --git a/README_zh.md b/README_zh.md index 5c150b3d..323a16c3 100644 --- a/README_zh.md +++ b/README_zh.md @@ -191,6 +191,9 @@ pnpm dev jlxyfll
jlxyfll

💻 Harlan Hu
Harlan Hu

💻 + + Mingde
Mingde

🐛 + diff --git a/packages/cli/src/commands/serve-shutdown.ts b/packages/cli/src/commands/serve-shutdown.ts new file mode 100644 index 00000000..00ff96b6 --- /dev/null +++ b/packages/cli/src/commands/serve-shutdown.ts @@ -0,0 +1,57 @@ +import type http from 'node:http' + +export const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000 + +interface GracefulShutdownOptions { + server: http.Server + cleanup: () => void + stopRuntime: () => void + exit?: (code: number) => void + log?: (message: string) => void + timeoutMs?: number +} + +export function createGracefulShutdownHandler(options: GracefulShutdownOptions): () => void { + const exit = options.exit ?? ((code: number) => process.exit(code)) + const log = options.log ?? ((message: string) => console.log(message)) + const timeoutMs = options.timeoutMs ?? FORCE_SHUTDOWN_TIMEOUT_MS + let shuttingDown = false + let exited = false + let forceTimer: ReturnType | undefined + + const exitOnce = () => { + if (exited) return + exited = true + if (forceTimer) clearTimeout(forceTimer) + exit(0) + } + + return () => { + if (shuttingDown) { + // A second signal means the caller no longer wants to wait for graceful + // shutdown. Do not call server.close() again: each call adds another + // close listener while active connections are still draining. + options.server.closeAllConnections() + exitOnce() + return + } + shuttingDown = true + + log('\nShutting down...') + options.cleanup() + options.stopRuntime() + + forceTimer = setTimeout(() => { + options.server.closeAllConnections() + exitOnce() + }, timeoutMs) + forceTimer.unref() + + try { + options.server.close(exitOnce) + options.server.closeIdleConnections() + } catch { + exitOnce() + } + } +} diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 96b8cd0e..b3059c14 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -15,6 +15,7 @@ import { getSyncTarget } from '../sync/target.js' import { RuntimeSettingsController } from '../runtime/settings-controller.js' import { AsyncTaskQueue } from '../db/write-queue.js' import { fetchExchangeRate, CACHE_TTL_MS } from '@aiusage/core' +import { createGracefulShutdownHandler } from './serve-shutdown.js' import type Database from 'better-sqlite3' export interface ServeOptions { @@ -202,20 +203,11 @@ export function serve(options: ServeOptions): void { try { unlinkSync(PORT_FILE) } catch {} } - process.on('SIGINT', () => { - console.log('\nShutting down...') - cleanup() - runtimeSettings.stop() - server.close(() => { - process.exit(0) - }) - }) - - process.on('SIGTERM', () => { - cleanup() - runtimeSettings.stop() - server.close(() => { - process.exit(0) - }) + const shutdown = createGracefulShutdownHandler({ + server, + cleanup, + stopRuntime: () => runtimeSettings.stop(), }) + process.once('SIGINT', shutdown) + process.once('SIGTERM', shutdown) } diff --git a/packages/cli/tests/commands/serve-shutdown.test.ts b/packages/cli/tests/commands/serve-shutdown.test.ts new file mode 100644 index 00000000..67b72de0 --- /dev/null +++ b/packages/cli/tests/commands/serve-shutdown.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createGracefulShutdownHandler } from '../../src/commands/serve-shutdown.js' + +function createServer() { + return { + close: vi.fn(), + closeIdleConnections: vi.fn(), + closeAllConnections: vi.fn(), + } +} + +describe('createGracefulShutdownHandler', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('cleans up and exits after the server closes', () => { + const server = createServer() + server.close.mockImplementation((callback: () => void) => { + callback() + return server + }) + const cleanup = vi.fn() + const stopRuntime = vi.fn() + const exit = vi.fn() + const log = vi.fn() + const shutdown = createGracefulShutdownHandler({ + server: server as any, + cleanup, + stopRuntime, + exit, + log, + }) + + shutdown() + + expect(log).toHaveBeenCalledWith('\nShutting down...') + expect(cleanup).toHaveBeenCalledOnce() + expect(stopRuntime).toHaveBeenCalledOnce() + expect(server.close).toHaveBeenCalledOnce() + expect(server.closeIdleConnections).toHaveBeenCalledOnce() + expect(server.closeAllConnections).not.toHaveBeenCalled() + expect(exit).toHaveBeenCalledWith(0) + }) + + it('force-closes active connections when graceful shutdown times out', () => { + const server = createServer() + server.close.mockReturnValue(server) + const exit = vi.fn() + const shutdown = createGracefulShutdownHandler({ + server: server as any, + cleanup: vi.fn(), + stopRuntime: vi.fn(), + exit, + log: vi.fn(), + timeoutMs: 100, + }) + + shutdown() + vi.advanceTimersByTime(99) + expect(exit).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(server.closeAllConnections).toHaveBeenCalledOnce() + expect(exit).toHaveBeenCalledWith(0) + }) + + it('force-exits on a second signal without calling server.close again', () => { + const server = createServer() + server.close.mockReturnValue(server) + const cleanup = vi.fn() + const stopRuntime = vi.fn() + const exit = vi.fn() + const shutdown = createGracefulShutdownHandler({ + server: server as any, + cleanup, + stopRuntime, + exit, + log: vi.fn(), + }) + + shutdown() + shutdown() + + expect(cleanup).toHaveBeenCalledOnce() + expect(stopRuntime).toHaveBeenCalledOnce() + expect(server.close).toHaveBeenCalledOnce() + expect(server.closeAllConnections).toHaveBeenCalledOnce() + expect(exit).toHaveBeenCalledOnce() + }) +})