mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(dev): clean up web backend lifecycle
This commit is contained in:
1 parent
296573ed5c
commit
953471a514
6 files changed
+124
-106
No files matched your search
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
])
|
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']])
|
||||||
})
|
})
|
||||||
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user