diff --git a/scripts/devStartupSequence.mts b/scripts/devStartupSequence.mts index e493030c47..fbac638a14 100644 --- a/scripts/devStartupSequence.mts +++ b/scripts/devStartupSequence.mts @@ -1,34 +1,20 @@ -import { type ChildProcess, spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import net from 'node:net'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + import dotenv from 'dotenv'; import dotenvExpand from 'dotenv-expand'; -import net from 'node:net'; -const env = process.env.NODE_ENV || 'development'; -const isWindows = process.platform === 'win32'; - -const shellEnv = Object.entries(process.env).reduce>( - (acc, [key, value]) => { - if (typeof value === 'string') acc[key] = value; - return acc; - }, - {}, -); -const dotenvEnv: Record = {}; -const dotenvResult = dotenv.config({ - override: true, - path: ['.env', `.env.${env}`, `.env.${env}.local`], - processEnv: dotenvEnv, -}); - -if (dotenvResult.parsed) { - const expanded = dotenvExpand.expand({ - parsed: dotenvResult.parsed, - processEnv: { ...dotenvEnv, ...shellEnv }, - }); - - Object.assign(process.env, expanded.parsed, shellEnv); +interface DevProcessHandle { + directPid?: number; + groupPid?: number; + isWindows: boolean; } +const isWindows = process.platform === 'win32'; + const NEXT_HOST = 'localhost'; /** @@ -44,16 +30,19 @@ const resolveNextPort = (): number => { return 3010; }; -const NEXT_PORT = resolveNextPort(); -const NEXT_ROOT_URL = `http://${NEXT_HOST}:${NEXT_PORT}/`; const NEXT_READY_TIMEOUT_MS = 180_000; const NEXT_READY_RETRY_MS = 400; const FORCE_KILL_TIMEOUT_MS = 5_000; const npmCommand = isWindows ? 'npm.cmd' : 'npm'; +let nextPort = 3010; +let nextRootUrl = `http://${NEXT_HOST}:${nextPort}/`; let nextProcess: ChildProcess | undefined; let viteProcess: ChildProcess | undefined; +let nextHandle: DevProcessHandle | undefined; +let viteHandle: DevProcessHandle | undefined; +let forceKillTimer: ReturnType | undefined; let shuttingDown = false; const runNpmScript = (scriptName: string) => @@ -64,6 +53,66 @@ const runNpmScript = (scriptName: string) => shell: isWindows, }); +const loadEnv = () => { + const env = process.env.NODE_ENV || 'development'; + const shellEnv = Object.entries(process.env).reduce>( + (acc, [key, value]) => { + if (typeof value === 'string') acc[key] = value; + return acc; + }, + {}, + ); + const dotenvEnv: Record = {}; + const dotenvResult = dotenv.config({ + override: true, + path: ['.env', `.env.${env}`, `.env.${env}.local`], + processEnv: dotenvEnv, + }); + + if (!dotenvResult.parsed) return; + + const expanded = dotenvExpand.expand({ + parsed: dotenvResult.parsed, + processEnv: { ...dotenvEnv, ...shellEnv }, + }); + + Object.assign(process.env, expanded.parsed, shellEnv); +}; + +const createDevProcessHandle = ({ + isWindows, + pid, +}: { + isWindows: boolean; + pid?: number; +}): DevProcessHandle => ({ + directPid: pid, + groupPid: isWindows ? undefined : pid, + isWindows, +}); + +const sendSignalToDevProcess = (handle: DevProcessHandle | undefined, signal: NodeJS.Signals) => { + if (!handle) return; + + if (!handle.isWindows && handle.groupPid) { + try { + process.kill(-handle.groupPid, signal); + return; + } catch { + // Fall through to the direct child pid below. The wrapper may already be + // gone while its process group has been reaped. + } + } + + if (!handle.directPid) return; + + try { + process.kill(handle.directPid, signal); + } catch { + // The process already exited. + } +}; + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const isPortOpen = (host: string, port: number) => @@ -84,25 +133,27 @@ const waitForNextReady = async () => { const startedAt = Date.now(); while (Date.now() - startedAt < NEXT_READY_TIMEOUT_MS) { - if (await isPortOpen(NEXT_HOST, NEXT_PORT)) return; + if (await isPortOpen(NEXT_HOST, nextPort)) return; await wait(NEXT_READY_RETRY_MS); } throw new Error( - `Next server was not ready within ${NEXT_READY_TIMEOUT_MS / 1000}s on ${NEXT_HOST}:${NEXT_PORT}`, + `Next server was not ready within ${NEXT_READY_TIMEOUT_MS / 1000}s on ${NEXT_HOST}:${nextPort}`, ); }; const prewarmNextRootCompile = async () => { const startedAt = Date.now(); - const response = await fetch(NEXT_ROOT_URL, { signal: AbortSignal.timeout(120_000) }); + const response = await fetch(nextRootUrl, { signal: AbortSignal.timeout(120_000) }); const elapsed = ((Date.now() - startedAt) / 1000).toFixed(2); - console.log(`✅ Next prewarm request finished (${response.status}) in ${elapsed}s ${NEXT_ROOT_URL}`); + console.log( + `✅ Next prewarm request finished (${response.status}) in ${elapsed}s ${nextRootUrl}`, + ); }; const runNextBackgroundTasks = () => { setTimeout(() => { - console.log(`🔁 Next server URL: ${NEXT_ROOT_URL}`); + console.log(`🔁 Next server URL: ${nextRootUrl}`); }, 2_000); void (async () => { @@ -115,67 +166,69 @@ const runNextBackgroundTasks = () => { })(); }; -const isChildAlive = (child: ChildProcess) => - !child.killed && child.exitCode === null && child.signalCode === null; - -const sendKillSignal = (child: ChildProcess, signal: NodeJS.Signals) => { - if (!isChildAlive(child) || !child.pid) return; - try { - if (!isWindows) { - try { - process.kill(-child.pid, signal); - return; - } catch { - // process group kill failed; fall through to direct kill - } - } - child.kill(signal); - } catch { - // child already gone - } +const terminateChildren = () => { + sendSignalToDevProcess(viteHandle, 'SIGTERM'); + sendSignalToDevProcess(nextHandle, 'SIGTERM'); }; -const terminateChild = (child?: ChildProcess) => { - if (!child) return; - sendKillSignal(child, 'SIGTERM'); +const forceKillChildren = () => { + sendSignalToDevProcess(viteHandle, 'SIGKILL'); + sendSignalToDevProcess(nextHandle, 'SIGKILL'); }; -const forceKillChild = (child?: ChildProcess) => { - if (!child) return; - sendKillSignal(child, 'SIGKILL'); +const clearForceKillTimer = () => { + if (!forceKillTimer) return; + clearTimeout(forceKillTimer); + forceKillTimer = undefined; +}; + +const hasChildSettled = (child?: ChildProcess) => + !child || child.exitCode !== null || child.signalCode !== null; + +const clearForceKillTimerWhenChildrenSettle = () => { + if (!shuttingDown) return; + if (hasChildSettled(nextProcess) && hasChildSettled(viteProcess)) clearForceKillTimer(); }; const shutdownAll = (signal: NodeJS.Signals) => { - if (shuttingDown) return; + if (shuttingDown) { + forceKillChildren(); + return; + } shuttingDown = true; - terminateChild(viteProcess); - terminateChild(nextProcess); + terminateChildren(); process.exitCode = signal === 'SIGINT' ? 130 : 143; - const forceKillTimer = setTimeout(() => { - forceKillChild(viteProcess); - forceKillChild(nextProcess); + forceKillTimer = setTimeout(() => { + forceKillTimer = undefined; + forceKillChildren(); }, FORCE_KILL_TIMEOUT_MS); - forceKillTimer.unref(); }; const watchChildExit = (child: ChildProcess, name: 'next' | 'vite') => { child.once('exit', (code, signal) => { - if (!shuttingDown) { - console.error( - `❌ ${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`, - ); - shutdownAll('SIGTERM'); + if (shuttingDown) { + clearForceKillTimerWhenChildrenSettle(); + return; } + + console.error( + `❌ ${name} exited unexpectedly (code: ${code ?? 'null'}, signal: ${signal ?? 'null'})`, + ); + shutdownAll('SIGTERM'); }); }; const main = async () => { + loadEnv(); + nextPort = resolveNextPort(); + nextRootUrl = `http://${NEXT_HOST}:${nextPort}/`; + const forwardedSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']; for (const sig of forwardedSignals) { - process.once(sig, () => shutdownAll(sig)); + process.on(sig, () => shutdownAll(sig)); } process.on('uncaughtException', (error) => { @@ -189,19 +242,20 @@ const main = async () => { }); process.on('exit', () => { - forceKillChild(viteProcess); - forceKillChild(nextProcess); + forceKillChildren(); }); - nextProcess = spawn('npx', ['next', 'dev', '-p', String(NEXT_PORT)], { + nextProcess = spawn('npx', ['next', 'dev', '-p', String(nextPort)], { detached: !isWindows, env: process.env, stdio: 'inherit', shell: isWindows, }); + nextHandle = createDevProcessHandle({ isWindows, pid: nextProcess.pid }); watchChildExit(nextProcess, 'next'); viteProcess = runNpmScript('dev:spa'); + viteHandle = createDevProcessHandle({ isWindows, pid: viteProcess.pid }); watchChildExit(viteProcess, 'vite'); runNextBackgroundTasks(); @@ -211,7 +265,19 @@ const main = async () => { ]); }; -void main().catch((error) => { - console.error('❌ dev startup sequence failed:', error); - shutdownAll('SIGTERM'); -}); +const isMainModule = () => { + const entry = process.argv[1]; + return !!entry && import.meta.url === pathToFileURL(path.resolve(entry)).href; +}; + +export const __testing = { + createDevProcessHandle, + sendSignalToDevProcess, +}; + +if (isMainModule()) { + void main().catch((error) => { + console.error('❌ dev startup sequence failed:', error); + shutdownAll('SIGTERM'); + }); +} diff --git a/src/features/Electron/AuthRequiredModal/index.tsx b/src/features/Electron/AuthRequiredModal/index.tsx index 203a826b08..9e3d4c44c4 100644 --- a/src/features/Electron/AuthRequiredModal/index.tsx +++ b/src/features/Electron/AuthRequiredModal/index.tsx @@ -1,9 +1,8 @@ 'use client'; import { useWatchBroadcast } from '@lobechat/electron-client-ipc'; -import { Flexbox, Icon } from '@lobehub/ui'; +import { Button, Flexbox, Icon } from '@lobehub/ui'; import { - Button, createModal, type ImperativeModalProps, ModalFooter, diff --git a/src/features/RecommendTaskTemplates/TaskTemplateDetailModal.tsx b/src/features/RecommendTaskTemplates/TaskTemplateDetailModal.tsx index 481044086b..2dc35e8f10 100644 --- a/src/features/RecommendTaskTemplates/TaskTemplateDetailModal.tsx +++ b/src/features/RecommendTaskTemplates/TaskTemplateDetailModal.tsx @@ -1,8 +1,8 @@ 'use client'; import type { TaskTemplate } from '@lobechat/const'; -import { ActionIcon, Flexbox, Icon, Markdown, Text } from '@lobehub/ui'; -import { Button, createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui'; +import { ActionIcon, Button, Flexbox, Icon, Markdown, Text } from '@lobehub/ui'; +import { createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui'; import { Divider } from 'antd'; import { cssVar } from 'antd-style'; import { Clock, X } from 'lucide-react'; diff --git a/src/scripts/devProcessCleanup.test.ts b/src/scripts/devProcessCleanup.test.ts new file mode 100644 index 0000000000..8b7781a700 --- /dev/null +++ b/src/scripts/devProcessCleanup.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +interface DevProcessHandle { + directPid?: number; + groupPid?: number; + isWindows: boolean; +} + +interface DevStartupTestingExports { + __testing: { + createDevProcessHandle: (params: { isWindows: boolean; pid?: number }) => DevProcessHandle; + sendSignalToDevProcess: (handle: DevProcessHandle | undefined, signal: NodeJS.Signals) => void; + }; +} + +const loadTestingExports = async () => { + const modulePath = '../../scripts/devStartupSequence' + '.mts'; + return (await import(modulePath)) as unknown as DevStartupTestingExports; +}; + +describe('devProcessCleanup', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should save the detached process group pid on Unix', async () => { + const { createDevProcessHandle } = (await loadTestingExports()).__testing; + + expect(createDevProcessHandle({ isWindows: false, pid: 1234 })).toEqual({ + directPid: 1234, + groupPid: 1234, + isWindows: false, + }); + }); + + it('should signal the saved process group without requiring the direct child to be alive', async () => { + const { sendSignalToDevProcess } = (await loadTestingExports()).__testing; + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + + sendSignalToDevProcess( + { + directPid: 1234, + groupPid: 1234, + isWindows: false, + }, + 'SIGTERM', + ); + + expect(kill).toHaveBeenCalledTimes(1); + expect(kill).toHaveBeenCalledWith(-1234, 'SIGTERM'); + }); + + it('should fall back to the direct child pid when the process group is already gone', async () => { + const { sendSignalToDevProcess } = (await loadTestingExports()).__testing; + const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid < 0) throw new Error('missing process group'); + return true; + }); + + sendSignalToDevProcess( + { + directPid: 1234, + groupPid: 1234, + isWindows: false, + }, + 'SIGKILL', + ); + + expect(kill).toHaveBeenCalledTimes(2); + expect(kill).toHaveBeenNthCalledWith(1, -1234, 'SIGKILL'); + expect(kill).toHaveBeenNthCalledWith(2, 1234, 'SIGKILL'); + }); + + it('should signal only the direct child pid on Windows', async () => { + const { createDevProcessHandle, sendSignalToDevProcess } = (await loadTestingExports()) + .__testing; + const kill = vi.spyOn(process, 'kill').mockImplementation(() => true); + + sendSignalToDevProcess(createDevProcessHandle({ isWindows: true, pid: 1234 }), 'SIGTERM'); + + expect(kill).toHaveBeenCalledTimes(1); + expect(kill).toHaveBeenCalledWith(1234, 'SIGTERM'); + }); +});