fix(dev): prevent duplicate backend launch and forward shutdown signals

This commit is contained in:
2026-10-07 11:01:18 +08:00
parent e50bed9ad8
commit 960af8d40a
3 changed files with 154 additions and 3 deletions
+3
View File
@@ -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
+23 -3
View File
@@ -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]));
});
+128
View File
@@ -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/);
});