diff --git a/package.json b/package.json index 3b391473..6b5292d0 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "build:mac": "pnpm --filter @openchatlab/desktop build:mac", "build:win": "pnpm --filter @openchatlab/desktop build:win", "dev:app": "vite --config vite.web.config.mts", - "dev:serve": "node scripts/dev-serve.mjs", - "dev:web": "node scripts/dev-web.mjs", + "dev:serve": "bash scripts/dev-serve.sh", + "dev:web": "pnpm run ensure:server-native && CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts", "ensure:server-native": "pnpm --filter chatlab-cli run ensure-native", "build:web": "vite build --config vite.web.config.mts", "docs:dev": "pnpm --filter @openchatlab/docs dev", diff --git a/scripts/dev-serve.mjs b/scripts/dev-serve.mjs deleted file mode 100644 index 6f95ad85..00000000 --- a/scripts/dev-serve.mjs +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env node - -/** - * Cross-platform API server dev launcher. - */ - -import { spawnSync } from 'node:child_process' - -const pnpm = 'pnpm' -const extraArgs = process.argv.slice(2) - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - stdio: 'inherit', - shell: false, - ...options, - }) - if (result.error) { - console.error(result.error.message) - process.exit(1) - } - if (result.status !== 0) { - process.exit(result.status ?? 1) - } -} - -function runPnpm(args, options = {}) { - if (process.env.npm_execpath) { - run(process.execPath, [process.env.npm_execpath, ...args], options) - return - } - run(pnpm, args, { shell: process.platform === 'win32', ...options }) -} - -runPnpm(['run', 'ensure:server-native']) -runPnpm([ - 'exec', - 'tsx', - 'watch', - '--include', - 'packages/core/src/**', - '--include', - 'packages/node-runtime/src/**', - 'apps/cli/src/cli.ts', - 'start', - '--headless', - '--no-open', - ...extraArgs, -]) diff --git a/scripts/dev-server-command.mjs b/scripts/dev-server-command.mjs index 4d03730c..f1974bf7 100644 --- a/scripts/dev-server-command.mjs +++ b/scripts/dev-server-command.mjs @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process' + export function createChatlabStartCommand({ serverDir, backendPort, nodeExecutable = process.execPath }) { return { command: nodeExecutable, @@ -16,6 +18,34 @@ export function createChatlabStartCommand({ serverDir, backendPort, nodeExecutab cwd: serverDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, + detached: true, }, } } + +export function terminateChatlabStartProcess( + childProcess, + { platform = process.platform, killProcess = process.kill, spawnSyncFn = spawnSync } = {} +) { + if (!childProcess) return + if (childProcess.exitCode !== null || childProcess.signalCode !== null) return + + const pid = childProcess.pid + if (!pid) { + childProcess.kill('SIGTERM') + return + } + + if (platform === 'win32') { + const result = spawnSyncFn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + if (result.status === 0) return + childProcess.kill('SIGTERM') + return + } + + try { + killProcess(-pid, 'SIGTERM') + } catch { + childProcess.kill('SIGTERM') + } +} diff --git a/scripts/dev-server-command.test.mjs b/scripts/dev-server-command.test.mjs index c7fd3f44..61810b89 100644 --- a/scripts/dev-server-command.test.mjs +++ b/scripts/dev-server-command.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { createChatlabStartCommand } from './dev-server-command.mjs' +import { createChatlabStartCommand, terminateChatlabStartProcess } from './dev-server-command.mjs' test('web dev backend runs through the current Node executable with the tsx loader', () => { const command = createChatlabStartCommand({ @@ -25,4 +25,28 @@ test('web dev backend runs through the current Node executable with the tsx load '--port', '3110', ]) + assert.equal(command.options.detached, true) +}) + +test('web dev backend cleanup terminates the whole POSIX process group', () => { + const killed = [] + const proc = { + pid: 12345, + exitCode: null, + signalCode: null, + kill(signal) { + killed.push(['child', signal]) + return true + }, + } + + terminateChatlabStartProcess(proc, { + platform: 'darwin', + killProcess: (pid, signal) => { + killed.push(['process', pid, signal]) + return true + }, + }) + + assert.deepEqual(killed, [['process', -12345, 'SIGTERM']]) }) diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs deleted file mode 100644 index 3970f346..00000000 --- a/scripts/dev-web.mjs +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env node - -/** - * Cross-platform Web dev launcher. - * - * Windows shells do not support inline env syntax such as - * `CHATLAB_AUTO_SERVE=1 vite ...`, so set the env in Node before spawning Vite. - */ - -import { spawnSync } from 'node:child_process' - -const pnpm = 'pnpm' -const extraArgs = process.argv.slice(2) - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - stdio: 'inherit', - shell: false, - ...options, - }) - if (result.error) { - console.error(result.error.message) - process.exit(1) - } - if (result.status !== 0) { - process.exit(result.status ?? 1) - } -} - -function runPnpm(args, options = {}) { - if (process.env.npm_execpath) { - run(process.execPath, [process.env.npm_execpath, ...args], options) - return - } - run(pnpm, args, { shell: process.platform === 'win32', ...options }) -} - -runPnpm(['run', 'ensure:server-native']) -runPnpm(['exec', 'vite', '--config', 'vite.web.config.mts', ...extraArgs], { - env: { - ...process.env, - CHATLAB_AUTO_SERVE: '1', - }, -}) diff --git a/vite.web.config.mts b/vite.web.config.mts index c9fdcd9f..3a212c72 100644 --- a/vite.web.config.mts +++ b/vite.web.config.mts @@ -18,14 +18,20 @@ import { defineConfig, type Plugin } from 'vite' import vue from '@vitejs/plugin-vue' import ui from '@nuxt/ui/vite' import { DEFAULT_API_PORT } from './packages/config/src/schema' -import { createChatlabStartCommand } from './scripts/dev-server-command.mjs' +import { createChatlabStartCommand, terminateChatlabStartProcess } from './scripts/dev-server-command.mjs' const BACKEND_PORT = DEFAULT_API_PORT function isPortInUse(port: number): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const server = net.createServer() - server.once('error', () => resolve(true)) + server.once('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + resolve(true) + return + } + reject(error) + }) server.once('listening', () => { server.close() resolve(false) @@ -34,22 +40,73 @@ function isPortInUse(port: number): Promise { }) } +async function isChatlabBackendResponsive(port: number, timeoutMs = 800): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}/_web/sessions`, { + signal: AbortSignal.timeout(timeoutMs), + }) + return response.status === 200 || response.status === 401 || response.status === 403 + } catch { + return false + } +} + /** * 自动启动 chatlab start 后端的插件 * 仅在 CHATLAB_AUTO_SERVE=1 时生效(由 dev:web 脚本设置) */ function chatlabServePlugin(): Plugin { let serverProcess: ChildProcess | null = null + let processCleanupRegistered = false + + function unregisterProcessCleanup() { + if (!processCleanupRegistered) return + process.off('exit', stopServerProcess) + process.off('SIGINT', handleSigint) + process.off('SIGTERM', handleSigterm) + processCleanupRegistered = false + } + + function stopServerProcess() { + if (!serverProcess) return + terminateChatlabStartProcess(serverProcess) + serverProcess = null + unregisterProcessCleanup() + } + + function handleSigint() { + stopServerProcess() + process.exit(130) + } + + function handleSigterm() { + stopServerProcess() + process.exit(143) + } + + function registerProcessCleanup() { + if (processCleanupRegistered) return + process.once('exit', stopServerProcess) + process.once('SIGINT', handleSigint) + process.once('SIGTERM', handleSigterm) + processCleanupRegistered = true + } return { name: 'chatlab-start', - async configureServer() { + async configureServer(server) { if (process.env.CHATLAB_AUTO_SERVE !== '1') return const inUse = await isPortInUse(BACKEND_PORT) if (inUse) { - console.log(`[chatlab start] Port ${BACKEND_PORT} already in use, skipping`) - return + const responsive = await isChatlabBackendResponsive(BACKEND_PORT) + if (responsive) { + console.log(`[chatlab start] Port ${BACKEND_PORT} already has a responsive ChatLab API, skipping`) + return + } + throw new Error( + `[chatlab start] Port ${BACKEND_PORT} is in use, but ChatLab API did not respond. Stop the stale process and restart dev:web.` + ) } const serverDir = resolve(__dirname, 'apps/cli') @@ -72,13 +129,13 @@ function chatlabServePlugin(): Plugin { console.error(`[chatlab start] exited with code ${code}`) } serverProcess = null + unregisterProcessCleanup() }) + registerProcessCleanup() + server.httpServer?.once('close', stopServerProcess) }, buildEnd() { - if (serverProcess) { - serverProcess.kill() - serverProcess = null - } + stopServerProcess() }, } }