From 960af8d40a8a6efe98e10af9b02c2e778fd9400f Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 7 Oct 2026 11:01:18 +0800 Subject: [PATCH] fix(dev): prevent duplicate backend launch and forward shutdown signals --- README.md | 3 + scripts/dev-backend.mjs | 26 ++++++- scripts/dev-backend.test.mjs | 128 +++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 scripts/dev-backend.test.mjs diff --git a/README.md b/README.md index df54d85..8996532 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ npm run backend # Go control-plane,Air 自动重载 npm run frontend # Umi Max 前端,支持 HMR ``` +`npm run backend` 依赖 Linux 的 `flock`(util-linux),同一工作目录只允许一个后台进程;重复启动会明确报错并退出,重启前请在原终端按 Ctrl+C。不要绕过启动脚本直接运行 Air,以免多个进程同时重编译和启动同一个程序。 + `npm run backend` 不会自动启动数据库或 gateway;先启动 `db` 和 `gateway`。`npm run gateway` 直接运行当前用户的 native gateway,不要求安装 systemd 服务。修改 `browser_gateway` 的 Python 文件后,重启该命令。 服务地址: @@ -96,6 +98,7 @@ npm run frontend # Umi Max 前端,支持 HMR 最小检查: ```bash +node --test scripts/dev-backend.test.mjs go test ./... go vet ./... go build ./cmd/control-plane diff --git a/scripts/dev-backend.mjs b/scripts/dev-backend.mjs index d93dc6a..d7004e9 100755 --- a/scripts/dev-backend.mjs +++ b/scripts/dev-backend.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node // backend: 运行带 Air 热加载的 Go control-plane。 import { spawn } from "node:child_process"; -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync, existsSync, mkdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { constants } from "node:os"; import path from "node:path"; const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); @@ -40,9 +41,28 @@ if (!overrides.LOG_LEVEL) overrides.LOG_LEVEL = "debug"; if (!overrides.NATIVE_GATEWAY_ENDPOINT) overrides.NATIVE_GATEWAY_ENDPOINT = "http://127.0.0.1:28187"; const env = { ...process.env, ...overrides }; -const air = spawn("air", ["-c", ".air.toml", ...process.argv.slice(2)], { +const lockDir = path.join(root, ".data"); +mkdirSync(lockDir, { recursive: true }); +// flock 持有锁直到 Air 及其子进程退出,拒绝重复启动,不争抢同一个程序文件。 +const air = spawn("flock", [ + "--no-fork", "--nonblock", "--conflict-exit-code", "73", + path.join(lockDir, "dev-backend.lock"), + "air", "-c", ".air.toml", ...process.argv.slice(2), +], { cwd: root, env, stdio: "inherit", }); -air.on("exit", (code) => process.exit(code ?? 0)); +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => air.kill(signal)); +} +air.on("error", (error) => { + console.error(`[dev-backend] 启动失败:${error.message}`); + process.exit(1); +}); +air.on("exit", (code, signal) => { + if (code === 73) { + console.error("[dev-backend] 后台已在运行,拒绝重复启动。请停止原进程后再重启。"); + } + process.exit(code ?? (128 + constants.signals[signal])); +}); diff --git a/scripts/dev-backend.test.mjs b/scripts/dev-backend.test.mjs new file mode 100644 index 0000000..dc0f145 --- /dev/null +++ b/scripts/dev-backend.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; + +const script = fileURLToPath(new URL('./dev-backend.mjs', import.meta.url)); + +function fixture(t, source, options = {}) { + const root = mkdtempSync(path.join(tmpdir(), 'creator-backend-test-')); + mkdirSync(path.join(root, 'scripts')); + mkdirSync(path.join(root, 'bin')); + copyFileSync(script, path.join(root, 'scripts/dev-backend.mjs')); + if (options.envFile) writeFileSync(path.join(root, '.env'), options.envFile); + writeFileSync(path.join(root, 'bin/air'), `#!${process.execPath}\n${source}`, { mode: 0o755 }); + const children = []; + t.after(async () => { + for (const child of children) { + if (child.backendPID) { + try { process.kill(child.backendPID, 'SIGTERM'); } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + if (child.exitCode === null && child.signalCode === null) { + const closed = once(child, 'close'); + child.kill('SIGTERM'); + await closed; + } + } + rmSync(root, { recursive: true, force: true }); + }); + return () => { + const child = spawn(process.execPath, [path.join(root, 'scripts/dev-backend.mjs')], { + env: { ...process.env, PATH: options.noFlock ? path.join(root, 'bin') : `${path.join(root, 'bin')}:${process.env.PATH}` }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + children.push(child); + child.errors = ''; + child.output = ''; + child.stdout.on('data', (chunk) => { + child.output += chunk; + const pid = /ready:(\d+)/.exec(chunk.toString()); + if (pid) child.backendPID = Number(pid[1]); + }); + child.stderr.on('data', (chunk) => { child.errors += chunk; }); + return child; + }; +} + +async function ready(child) { + const [chunk] = await once(child.stdout, 'data', { signal: AbortSignal.timeout(1500) }); + assert.match(chunk.toString(), /ready:/); +} + +function exited(child) { + return once(child, 'close', { signal: AbortSignal.timeout(1500) }); +} + +const server = ` +process.on('SIGTERM', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +console.log('ready:' + process.pid); +setInterval(() => {}, 1000); +`; + +test('rejects a second backend without disturbing the first', { timeout: 5000 }, async (t) => { + const start = fixture(t, server); + const first = start(); + await ready(first); + const second = start(); + const [code] = await exited(second); + assert.equal(code, 73); + assert.match(second.errors, /后台已在运行/); + assert.equal(first.exitCode, null); +}); + +test('forwards termination and releases the lock for the next launch', { timeout: 5000 }, async (t) => { + const start = fixture(t, server); + const first = start(); + await ready(first); + const stopped = exited(first); + first.kill('SIGTERM'); + const [code] = await stopped; + assert.equal(code, 0); + const second = start(); + await ready(second); + const closed = exited(second); + second.kill('SIGINT'); + assert.equal((await closed)[0], 0); +}); + +test('reports backend startup failure and allows a retry', { timeout: 5000 }, async (t) => { + const start = fixture(t, "console.error('build failed'); process.exit(17);"); + const first = start(); + assert.equal((await exited(first))[0], 17); + assert.match(first.errors, /build failed/); + const second = start(); + assert.equal((await exited(second))[0], 17); +}); + +test('preserves environment configuration when launching through the lock', { timeout: 5000 }, async (t) => { + const start = fixture(t, "console.log(process.env.LISTEN_ADDR); console.log(process.env.CREATORHUB_CREDENTIAL_MASTER_KEY);", { + envFile: `\n# ignored comment\ninvalid line\nLISTEN_ADDR=:19091\nLISTEN_ADDR=:19092\nCREATORHUB_CREDENTIAL_MASTER_KEY=${Buffer.alloc(32, 1).toString('base64')}\nCONTROL_PLANE_USERNAME=test\nCONTROL_PLANE_PASSWORD=test\nDATABASE_URL=postgres://test\nWEB_DIR=/tmp/web\nCREATOR_COVER_DIR=/tmp/covers\nCREATORHUB_CREDENTIAL_STORE_DIR=/tmp/credentials\nLOG_LEVEL=info\nNATIVE_GATEWAY_ENDPOINT=http://127.0.0.1:28187\n`, + }); + const child = start(); + assert.equal((await exited(child))[0], 0); + assert.match(child.output, /:19091/); + assert.match(child.output, /AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=/); +}); + +test('preserves a signal-caused backend failure instead of reporting success', { timeout: 5000 }, async (t) => { + const start = fixture(t, server); + const child = start(); + await ready(child); + const closed = exited(child); + process.kill(child.backendPID, 'SIGKILL'); + assert.equal((await closed)[0], 137); +}); + +test('reports a missing lock command instead of silently exiting successfully', { timeout: 5000 }, async (t) => { + const start = fixture(t, server, { noFlock: true }); + const child = start(); + assert.equal((await exited(child))[0], 1); + assert.match(child.errors, /启动失败.*ENOENT/); +});