统一 AGC 开发端口分配
将 AGC Vite 纳入 Linux 用户端口段第六槽位 同步 Tauri devUrl、Vite 监听和配套后端端口预留 兼容迁移旧五端口注册记录并阻止重复分配 补齐动态配置顺序、跨平台和进程生命周期回归测试 更新开发运维文档、端口 skill 与项目共享记忆
This commit is contained in:
@@ -16,6 +16,8 @@ const LINUX_DEV_PORT_RANGE_REGISTRY_ROOT = '/var/tmp/genarrative-dev-port-ranges
|
||||
const LINUX_DEV_PORT_RANGE_POOL_START = 10000;
|
||||
const LINUX_DEV_PORT_RANGE_POOL_END = 39999;
|
||||
const LINUX_DEV_PORT_RANGE_BLOCK_SIZE = 100;
|
||||
const LEGACY_DEV_PORT_RANGE_MIN_SIZE = 5;
|
||||
const DEV_PORT_RANGE_MIN_SIZE = 6;
|
||||
|
||||
function toListenHosts(host) {
|
||||
if (host === '0.0.0.0') {
|
||||
@@ -38,7 +40,7 @@ export function normalizePort(value, fallback) {
|
||||
return port;
|
||||
}
|
||||
|
||||
export function parsePortRangeSpec(value) {
|
||||
function parsePortRangeSpecWithMinimum(value, minimumSize) {
|
||||
const spec = String(value ?? '').trim();
|
||||
if (!spec) {
|
||||
return null;
|
||||
@@ -55,23 +57,33 @@ export function parsePortRangeSpec(value) {
|
||||
throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`);
|
||||
}
|
||||
|
||||
if (end - start + 1 < 5) {
|
||||
throw new Error(`端口段至少需要 5 个端口: ${spec}`);
|
||||
if (end - start + 1 < minimumSize) {
|
||||
throw new Error(`端口段至少需要 ${minimumSize} 个端口: ${spec}`);
|
||||
}
|
||||
|
||||
return {start, end, label: `${start}-${end}`};
|
||||
}
|
||||
|
||||
function normalizePortRange(portRange) {
|
||||
export function parsePortRangeSpec(value) {
|
||||
return parsePortRangeSpecWithMinimum(value, DEV_PORT_RANGE_MIN_SIZE);
|
||||
}
|
||||
|
||||
function normalizePortRange(
|
||||
portRange,
|
||||
{minimumSize = DEV_PORT_RANGE_MIN_SIZE} = {},
|
||||
) {
|
||||
if (!portRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof portRange === 'string') {
|
||||
return parsePortRangeSpec(portRange);
|
||||
return parsePortRangeSpecWithMinimum(portRange, minimumSize);
|
||||
}
|
||||
|
||||
return parsePortRangeSpec(`${portRange.start}-${portRange.end}`);
|
||||
return parsePortRangeSpecWithMinimum(
|
||||
`${portRange.start}-${portRange.end}`,
|
||||
minimumSize,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLinuxDevPortRangeRegistryPaths(env = process.env) {
|
||||
@@ -108,7 +120,9 @@ export function getLinuxDevPortRangeSpec(env = process.env) {
|
||||
}
|
||||
|
||||
export function mapDevPortsToPortRange(portRange) {
|
||||
const normalizedRange = normalizePortRange(portRange);
|
||||
const normalizedRange = normalizePortRange(portRange, {
|
||||
minimumSize: LEGACY_DEV_PORT_RANGE_MIN_SIZE,
|
||||
});
|
||||
if (!normalizedRange) {
|
||||
return null;
|
||||
}
|
||||
@@ -119,6 +133,10 @@ export function mapDevPortsToPortRange(portRange) {
|
||||
spacetimePort: normalizedRange.start + 2,
|
||||
adminWebPort: normalizedRange.start + 3,
|
||||
bgfilterWorkerPort: normalizedRange.start + 4,
|
||||
agcVitePort:
|
||||
normalizedRange.start + 5 <= normalizedRange.end
|
||||
? normalizedRange.start + 5
|
||||
: null,
|
||||
range: normalizedRange,
|
||||
};
|
||||
}
|
||||
@@ -252,9 +270,12 @@ function readLinuxPortRangeRegistry(registryPath) {
|
||||
return registry;
|
||||
}
|
||||
|
||||
function safeNormalizePortRange(portRange) {
|
||||
function safeNormalizePortRange(
|
||||
portRange,
|
||||
{minimumSize = LEGACY_DEV_PORT_RANGE_MIN_SIZE} = {},
|
||||
) {
|
||||
try {
|
||||
return normalizePortRange(portRange);
|
||||
return normalizePortRange(portRange, {minimumSize});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -264,6 +285,32 @@ function rangesOverlap(left, right) {
|
||||
return left.start <= right.end && right.start <= left.end;
|
||||
}
|
||||
|
||||
function tryExpandLegacyPortRange(registry, username, portRange) {
|
||||
const normalizedRange = safeNormalizePortRange(portRange);
|
||||
if (!normalizedRange) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
normalizedRange.end - normalizedRange.start + 1 >=
|
||||
DEV_PORT_RANGE_MIN_SIZE
|
||||
) {
|
||||
return normalizedRange;
|
||||
}
|
||||
|
||||
const expandedEnd = normalizedRange.start + DEV_PORT_RANGE_MIN_SIZE - 1;
|
||||
if (expandedEnd > 65535) {
|
||||
return null;
|
||||
}
|
||||
const expandedRange = {
|
||||
start: normalizedRange.start,
|
||||
end: expandedEnd,
|
||||
label: `${normalizedRange.start}-${expandedEnd}`,
|
||||
};
|
||||
return findRangeConflict(registry, expandedRange, username)
|
||||
? null
|
||||
: expandedRange;
|
||||
}
|
||||
|
||||
function findRangeConflict(registry, portRange, excludingUsername = '') {
|
||||
const rangeToCheck = safeNormalizePortRange(portRange);
|
||||
if (!rangeToCheck) {
|
||||
@@ -380,11 +427,16 @@ export async function reserveLinuxDevPortRange({
|
||||
|
||||
const current = registry.allocations[username];
|
||||
if (current) {
|
||||
const expandedRange = tryExpandLegacyPortRange(
|
||||
registry,
|
||||
username,
|
||||
current.range,
|
||||
);
|
||||
registry.updatedAt = now;
|
||||
registry.allocations[username] = {
|
||||
...current,
|
||||
username,
|
||||
range: current.range,
|
||||
range: expandedRange ?? current.range,
|
||||
updatedAt: now,
|
||||
};
|
||||
atomicWriteJsonFile(registryPath, registry);
|
||||
@@ -482,7 +534,9 @@ export async function findAvailablePort({
|
||||
portRange = null,
|
||||
strict = false,
|
||||
}) {
|
||||
const range = normalizePortRange(portRange);
|
||||
const range = normalizePortRange(portRange, {
|
||||
minimumSize: LEGACY_DEV_PORT_RANGE_MIN_SIZE,
|
||||
});
|
||||
const startPort = normalizePort(preferredPort, 0);
|
||||
|
||||
if (startPort === 0 && range) {
|
||||
@@ -563,8 +617,11 @@ async function reserveEphemeralPort(host, reservedPorts) {
|
||||
throw new Error(`无法为 ${host} 分配临时可用端口`);
|
||||
}
|
||||
|
||||
export async function resolveDevStackPorts(config) {
|
||||
const reservedPorts = new Set();
|
||||
export async function resolveDevStackPorts(
|
||||
config,
|
||||
{reservedPorts: initialReservedPorts = new Set()} = {},
|
||||
) {
|
||||
const reservedPorts = new Set(initialReservedPorts);
|
||||
const entries = [
|
||||
['spacetime', config.spacetime],
|
||||
['api', config.api],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {mkdtempSync, readFileSync, rmSync} from 'node:fs';
|
||||
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {createServer} from 'node:net';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
@@ -47,7 +47,7 @@ async function reserveConsecutivePorts() {
|
||||
}
|
||||
|
||||
describe('dev stack port utils', () => {
|
||||
it('解析端口段并映射到五个 dev 端口', () => {
|
||||
it('解析端口段并映射到五个 dev 服务和 AGC 端口', () => {
|
||||
expect(parsePortRangeSpec('10000-10099')).toEqual({
|
||||
start: 10000,
|
||||
end: 10099,
|
||||
@@ -59,9 +59,10 @@ describe('dev stack port utils', () => {
|
||||
spacetimePort: 10002,
|
||||
adminWebPort: 10003,
|
||||
bgfilterWorkerPort: 10004,
|
||||
agcVitePort: 10005,
|
||||
});
|
||||
expect(() => parsePortRangeSpec('10000-10003')).toThrow(
|
||||
'端口段至少需要 5 个端口',
|
||||
expect(() => parsePortRangeSpec('10000-10004')).toThrow(
|
||||
'端口段至少需要 6 个端口',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -122,6 +123,21 @@ describe('dev stack port utils', () => {
|
||||
expect(new Set(Object.values(resolvedPorts)).size).toBe(5);
|
||||
});
|
||||
|
||||
it('解析 dev 服务时跳过父启动器预留的 AGC Vite 端口', async () => {
|
||||
const server = await reservePort(0);
|
||||
const preferredPort = server.address().port;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
|
||||
const resolvedPorts = await resolveDevStackPorts(
|
||||
{
|
||||
api: {host: '127.0.0.1', preferredPort},
|
||||
},
|
||||
{reservedPorts: new Set([preferredPort])},
|
||||
);
|
||||
|
||||
expect(resolvedPorts.api).toBeGreaterThan(preferredPort);
|
||||
});
|
||||
|
||||
it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => {
|
||||
const rangeStart = 10000;
|
||||
const rangeEnd = 10300;
|
||||
@@ -142,6 +158,95 @@ describe('dev stack port utils', () => {
|
||||
|
||||
const linuxIt = process.platform === 'linux' ? it : it.skip;
|
||||
|
||||
linuxIt('Linux 升级时保留旧 v4 五端口记录并阻止重复分配', async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
|
||||
const registryPath = join(tempRoot, 'registry.json');
|
||||
const lockPath = join(tempRoot, 'registry.lock');
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify({
|
||||
version: 4,
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
allocations: {
|
||||
alice: {
|
||||
username: 'alice',
|
||||
range: {start: 10000, end: 10004, label: '10000-10004'},
|
||||
claimedAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
source: 'manual',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const bobAllocation = await reserveLinuxDevPortRange({
|
||||
env: {
|
||||
USER: 'bob',
|
||||
LOGNAME: 'bob',
|
||||
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
|
||||
},
|
||||
username: 'bob',
|
||||
requestedRange: null,
|
||||
registryPath,
|
||||
lockPath,
|
||||
});
|
||||
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
||||
|
||||
expect(bobAllocation.range.label).toBe('10100-10199');
|
||||
expect(registry.allocations.alice.range.label).toBe('10000-10004');
|
||||
expect(registry.allocations.bob.range.label).toBe('10100-10199');
|
||||
} finally {
|
||||
rmSync(tempRoot, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
linuxIt('Linux 当前用户的旧五端口记录会在无冲突时扩出 AGC 槽位', async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
|
||||
const registryPath = join(tempRoot, 'registry.json');
|
||||
const lockPath = join(tempRoot, 'registry.lock');
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify({
|
||||
version: 4,
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
allocations: {
|
||||
alice: {
|
||||
username: 'alice',
|
||||
range: {start: 10000, end: 10004, label: '10000-10004'},
|
||||
claimedAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
source: 'manual',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const allocation = await reserveLinuxDevPortRange({
|
||||
env: {
|
||||
USER: 'alice',
|
||||
LOGNAME: 'alice',
|
||||
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot,
|
||||
},
|
||||
username: 'alice',
|
||||
requestedRange: null,
|
||||
registryPath,
|
||||
lockPath,
|
||||
});
|
||||
|
||||
expect(allocation.range.label).toBe('10000-10005');
|
||||
expect(
|
||||
JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range
|
||||
.label,
|
||||
).toBe('10000-10005');
|
||||
} finally {
|
||||
rmSync(tempRoot, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
linuxIt('Linux 未手动指定端口段时从 10000 开始按 100 端口块自动分配', async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-'));
|
||||
const registryPath = join(tempRoot, 'registry.json');
|
||||
|
||||
+11
-1
@@ -1447,7 +1447,17 @@ class DevRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPorts = await resolveDevStackPorts(portConfig);
|
||||
const reservedPorts = new Set();
|
||||
const agcVitePort = normalizePort(
|
||||
this.baseEnv.GENARRATIVE_AGC_VITE_PORT,
|
||||
0,
|
||||
);
|
||||
if (agcVitePort > 0) {
|
||||
reservedPorts.add(agcVitePort);
|
||||
}
|
||||
const resolvedPorts = await resolveDevStackPorts(portConfig, {
|
||||
reservedPorts,
|
||||
});
|
||||
|
||||
for (const [name, resolvedPort] of Object.entries(resolvedPorts)) {
|
||||
const config = portConfig[name];
|
||||
|
||||
@@ -187,6 +187,38 @@ describe('dev scheduler argument routing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
linuxTest(
|
||||
'Linux 配套后端不会漂移到父启动器预留的 AGC Vite 端口',
|
||||
async () => {
|
||||
const tempDir = mkdtempSync(
|
||||
join(tmpdir(), 'genarrative-dev-port-range-'),
|
||||
);
|
||||
const baseEnv = {
|
||||
USER: 'alice',
|
||||
LOGNAME: 'alice',
|
||||
GENARRATIVE_DEV_PORT_RANGE: '22000-22099',
|
||||
GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir,
|
||||
GENARRATIVE_AGC_VITE_PORT: '22002',
|
||||
};
|
||||
try {
|
||||
const { command, explicitOptions, options } = parseArgs(
|
||||
['backend'],
|
||||
baseEnv,
|
||||
);
|
||||
const runner = new DevRunner(options, baseEnv, explicitOptions);
|
||||
|
||||
await runner.prepareLinuxPortRange(command);
|
||||
await runner.resolvePorts(command);
|
||||
|
||||
expect(runner.options.spacetimePort).not.toBe(22002);
|
||||
expect(runner.options.apiPort).not.toBe(22002);
|
||||
expect(runner.options.bgfilterWorkerPort).not.toBe(22002);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
linuxTest(
|
||||
'Linux 桌面壳显式指定 web-port 时不被系统级端口段改写',
|
||||
async () => {
|
||||
|
||||
Reference in New Issue
Block a user