diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js index e339cb3e..3d4ca294 100644 --- a/packages/web/server/lib/opencode/shutdown-runtime.js +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -90,20 +90,27 @@ export const createGracefulShutdownRuntime = (dependencies) => { const server = getServer(); if (server) { - await Promise.race([ - new Promise((resolve) => { - server.close(() => { - console.log('HTTP server closed'); - resolve(); - }); - }), - new Promise((resolve) => { - setTimeout(() => { - console.warn('Server close timeout reached, forcing shutdown'); - resolve(); - }, shutdownTimeoutMs); - }), - ]); + let closeTimeout = null; + try { + await Promise.race([ + new Promise((resolve) => { + server.close(() => { + console.log('HTTP server closed'); + resolve(); + }); + }), + new Promise((resolve) => { + closeTimeout = setTimeout(() => { + console.warn('Server close timeout reached, forcing shutdown'); + resolve(); + }, shutdownTimeoutMs); + }), + ]); + } finally { + if (closeTimeout) { + clearTimeout(closeTimeout); + } + } } const uiAuthController = getUiAuthController(); diff --git a/packages/web/server/lib/opencode/shutdown-runtime.test.js b/packages/web/server/lib/opencode/shutdown-runtime.test.js new file mode 100644 index 00000000..b684f4e1 --- /dev/null +++ b/packages/web/server/lib/opencode/shutdown-runtime.test.js @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createGracefulShutdownRuntime } from './shutdown-runtime.js'; + +const createRuntime = (server) => createGracefulShutdownRuntime({ + process: { exit: vi.fn() }, + shutdownTimeoutMs: 1000, + getExitOnShutdown: () => false, + getIsShuttingDown: () => false, + setIsShuttingDown: vi.fn(), + syncToHmrState: vi.fn(), + openCodeWatcherRuntime: { stop: vi.fn() }, + sessionRuntime: { dispose: vi.fn() }, + scheduledTasksRuntime: { stop: vi.fn() }, + getHealthCheckInterval: () => null, + clearHealthCheckInterval: vi.fn(), + getTerminalRuntime: () => null, + setTerminalRuntime: vi.fn(), + getMessageStreamRuntime: () => null, + setMessageStreamRuntime: vi.fn(), + shouldSkipOpenCodeStop: () => true, + getOpenCodePort: () => null, + getOpenCodeProcess: () => null, + setOpenCodeProcess: vi.fn(), + killProcessOnPort: vi.fn(), + waitForPortRelease: vi.fn(async () => true), + getServer: () => server, + getUiAuthController: () => null, + setUiAuthController: vi.fn(), + getActiveTunnelController: () => null, + setActiveTunnelController: vi.fn(), + tunnelAuthController: { clearActiveTunnel: vi.fn() }, +}); + +describe('graceful shutdown runtime', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('clears the server close timeout when the server closes first', async () => { + vi.useFakeTimers(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = { + close: vi.fn((callback) => { + callback(); + }), + }; + + const runtime = createRuntime(server); + await runtime.gracefulShutdown({ exitProcess: false }); + + await vi.advanceTimersByTimeAsync(1000); + + expect(warnSpy).not.toHaveBeenCalledWith('Server close timeout reached, forcing shutdown'); + expect(vi.getTimerCount()).toBe(0); + }); +});