fix(dev): clean up web backend lifecycle

This commit is contained in:
digua committed 2026-06-05 11:12:42 +08:00
1 parent 296573ed5c
commit 953471a514
6 files changed
+124 -106

No files matched your search

+2 -2
View File
@@ -26,8 +26,8 @@
"build:mac": "pnpm --filter @openchatlab/desktop build:mac", "build:mac": "pnpm --filter @openchatlab/desktop build:mac",
"build:win": "pnpm --filter @openchatlab/desktop build:win", "build:win": "pnpm --filter @openchatlab/desktop build:win",
"dev:app": "vite --config vite.web.config.mts", "dev:app": "vite --config vite.web.config.mts",
"dev:serve": "node scripts/dev-serve.mjs", "dev:serve": "bash scripts/dev-serve.sh",
"dev:web": "node scripts/dev-web.mjs", "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", "ensure:server-native": "pnpm --filter chatlab-cli run ensure-native",
"build:web": "vite build --config vite.web.config.mts", "build:web": "vite build --config vite.web.config.mts",
"docs:dev": "pnpm --filter @openchatlab/docs dev", "docs:dev": "pnpm --filter @openchatlab/docs dev",
-49
View File
@@ -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,
])
+30
View File
@@ -1,3 +1,5 @@
import { spawnSync } from 'node:child_process'
export function createChatlabStartCommand({ serverDir, backendPort, nodeExecutable = process.execPath }) { export function createChatlabStartCommand({ serverDir, backendPort, nodeExecutable = process.execPath }) {
return { return {
command: nodeExecutable, command: nodeExecutable,
@@ -16,6 +18,34 @@ export function createChatlabStartCommand({ serverDir, backendPort, nodeExecutab
cwd: serverDir, cwd: serverDir,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env }, 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')
}
}
+25 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' 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', () => { test('web dev backend runs through the current Node executable with the tsx loader', () => {
const command = createChatlabStartCommand({ const command = createChatlabStartCommand({
@@ -25,4 +25,28 @@ test('web dev backend runs through the current Node executable with the tsx load
'--port', '--port',
'3110', '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']])
}) })
-44
View File
@@ -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',
},
})
+67 -10
View File
@@ -18,14 +18,20 @@ import { defineConfig, type Plugin } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite' import ui from '@nuxt/ui/vite'
import { DEFAULT_API_PORT } from './packages/config/src/schema' 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 const BACKEND_PORT = DEFAULT_API_PORT
function isPortInUse(port: number): Promise<boolean> { function isPortInUse(port: number): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
const server = net.createServer() 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.once('listening', () => {
server.close() server.close()
resolve(false) resolve(false)
@@ -34,22 +40,73 @@ function isPortInUse(port: number): Promise<boolean> {
}) })
} }
async function isChatlabBackendResponsive(port: number, timeoutMs = 800): Promise<boolean> {
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 start 后端的插件
* 仅在 CHATLAB_AUTO_SERVE=1 时生效(由 dev:web 脚本设置) * 仅在 CHATLAB_AUTO_SERVE=1 时生效(由 dev:web 脚本设置)
*/ */
function chatlabServePlugin(): Plugin { function chatlabServePlugin(): Plugin {
let serverProcess: ChildProcess | null = null 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 { return {
name: 'chatlab-start', name: 'chatlab-start',
async configureServer() { async configureServer(server) {
if (process.env.CHATLAB_AUTO_SERVE !== '1') return if (process.env.CHATLAB_AUTO_SERVE !== '1') return
const inUse = await isPortInUse(BACKEND_PORT) const inUse = await isPortInUse(BACKEND_PORT)
if (inUse) { if (inUse) {
console.log(`[chatlab start] Port ${BACKEND_PORT} already in use, skipping`) const responsive = await isChatlabBackendResponsive(BACKEND_PORT)
return 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') const serverDir = resolve(__dirname, 'apps/cli')
@@ -72,13 +129,13 @@ function chatlabServePlugin(): Plugin {
console.error(`[chatlab start] exited with code ${code}`) console.error(`[chatlab start] exited with code ${code}`)
} }
serverProcess = null serverProcess = null
unregisterProcessCleanup()
}) })
registerProcessCleanup()
server.httpServer?.once('close', stopServerProcess)
}, },
buildEnd() { buildEnd() {
if (serverProcess) { stopServerProcess()
serverProcess.kill()
serverProcess = null
}
}, },
} }
} }