fix(dev): prevent duplicate backend launch and forward shutdown signals
This commit is contained in:
@@ -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
@@ -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]));
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user