diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index d5a01ba1..90379d91 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-30 Linux 本地 dev 端口段按系统级注册表分配 + +- 背景:同一台 Linux 开发机上有多个用户同时跑 `npm run dev` 时,单纯靠各自 `GENARRATIVE_DEV_PORT_RANGE` 容易撞段,且同一用户并发起两个 dev 会话时也会把相同端口段重复拿走。 +- 决策:Linux 上的本地 dev 端口段分配统一收口到系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,锁文件为 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。未手动指定时自动从 `10000-10099` 开始按 100 端口块分配,后续块按 `10100-10199`、`10200-10299` 递增;端口段映射固定为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`;注册表会拒绝不同用户的相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。`GENARRATIVE_DEV_PORT_RANGE` 与 `--port-range` 仍可手动指定端口段,但只在 Linux 生效,Windows 继续沿用原有端口探测与漂移逻辑,不读注册表。 +- 影响范围:`scripts/dev-stack-port-utils.mjs`、`scripts/dev.mjs`、`scripts/dev-stack-port-utils.test.ts`、`scripts/dev.test.ts`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、本条决策记录、`development-workflow.md`。 +- 验证方式:`node --check scripts/dev-stack-port-utils.mjs`、`node --check scripts/dev.mjs`、`node node_modules/vitest/vitest.mjs run scripts/dev-stack-port-utils.test.ts scripts/dev.test.ts` 通过;Linux 下能看到 `[dev] port-range:` 与 `registry.json` 路径日志,自动分配从 `10000-10099` 起步,Windows 不出现注册表分配日志。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-05-30 创作流程统一化门禁扩展为跨玩法矩阵 - 背景:统一创作 / 统一生成门禁已经足够覆盖 Phase 2 的入口与壳层,但当前总计划已经推进到 Phase 3-6,继续只保留单页门禁会让 Phase 4 的特殊工作台、Phase 5 的结果页 / 作品架 / 公开详情和 Phase 6 的冻结验收没有统一入口。 @@ -44,9 +52,9 @@ ## 2026-05-27 生成页总进度圆弧锁定固定 SVG 坐标系 - 背景:多轮圆环角度微调后,`GenerationProgressHero` 的 SVG 圆弧仍会出现底部开口偏斜的问题;后来窄屏验收又发现固定 `400px` 外层宽度会让等待页右侧被裁切。 -- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角固定为 `135deg`,轨道和橘黄色填充都从同一个对称起点 `rotate(135 200 200)` 出发;`270deg` 扫描角配合正下方 `90deg` 留空。SVG 内部坐标系固定为 `400x400`,圆弧使用 `r=166` 和 `strokeWidth=18`;外层显示宽度以 `400px` 为上限,窄屏按 `min(400px, calc(100vw - 2.5rem))` 等比收缩。预计等待 / 已耗时信息卡在窄屏下落到圆环下方两列,`sm` 及以上再回到左右悬浮。 +- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角固定为 `135deg`,轨道和橘黄色填充都从同一个对称起点 `rotate(135 200 200)` 出发;`270deg` 扫描角配合正下方 `90deg` 留空。SVG 内部坐标系固定为 `400x400`,圆弧使用 `r=166` 和 `strokeWidth=18`;外层显示宽度以 `400px` 为上限,窄屏按父容器 `min(400px, calc(100% - 0.75rem))` 等比收缩,避免嵌套页面 padding 或负 margin 下用 `100vw` 误判宽度。预计等待 / 已耗时信息卡在窄屏下落到圆环下方两列,`sm` 及以上再回到左右悬浮。 - 影响范围:`src/components/GenerationProgressHero.tsx`、共用 `CustomWorldGenerationView`、汪汪声浪 `BarkBattleGeneratingView` 以及生成页圆环布局文档。 -- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=135`、`data-ring-fill-start-degrees=135`,且圆环容器包含 `w-[min(400px,calc(100vw-2.5rem))]`、`max-w-full` 与 `aspect-square`,track / fill transform 都是 `rotate(135 200 200)`;竖屏 smoke 至少覆盖 `280px / 320px / 360px / 390px` 宽度。 +- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=135`、`data-ring-fill-start-degrees=135`,且圆环容器包含 `w-[min(400px,calc(100%_-_0.75rem))]`、`max-w-full` 与 `aspect-square`,track / fill transform 都是 `rotate(135 200 200)`;竖屏 smoke 至少覆盖 `280px / 320px / 360px / 390px` 宽度。 - 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。 ## 2026-05-26 平台跨流程错误统一用可复制来源弹窗展示 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 6bafa510..95eebd5f 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -50,6 +50,8 @@ npm install npm run dev ``` +Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start` 到 `start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。 + 该命令会启动: - SpacetimeDB standalone diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index caca24d6..9c55d74f 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -151,6 +151,14 @@ - 验证:检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 文件开头字节不再是 `EF BB BF`,并用 Jenkins `validateDeclarativePipeline` 或重放 `Genarrative-Stdb-Module-Publish`,不应再停在 `No such DSL method 'pipeline'`。 - 关联:`jenkins/Jenkinsfile.production-stdb-module-publish`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## Linux 多用户 dev 端口冲突先查系统级端口段注册表 + +- 现象:同一台 Linux 机器上多个用户同时开发时,`npm run dev` 报端口段已被其他用户占用、同一用户已有活跃端口段,或 SpacetimeDB 复用记录指向当前用户端口段之外的地址;未手动指定时自动分配应从 `10000-10099` 起步。 +- 原因:Linux dev 脚本会通过 `/var/tmp/genarrative-dev-port-ranges/registry.json` 做系统级端口段分配,避免两个用户配置相同或重叠端口段;同一用户后续启动会继续复用自己已经占用的固定端口段。注册表会保留该用户的段记录,不会因为多开而要求重新分配。 +- 处理:先确认当前用户已经占用的端口段,再让后续 `npm run dev` / `dev:*` 继续沿用这段;如确实要切换段,手动释放或清掉对应 registry 记录后再重启。需要临时隔离测试时用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR=` 覆盖注册表目录。不要在 Windows 上按这个注册表排查,Windows 仍走原有端口探测与漂移逻辑。未指定端口段时,系统会从 `10000-10099` 开始顺序分配。 +- 验证:重新启动后终端应打印 `[dev] port-range: ()` 与 `[dev] port-range-registry: .../registry.json`;`node node_modules/vitest/vitest.mjs run scripts/dev-stack-port-utils.test.ts scripts/dev.test.ts` 应通过 Linux registry、自动分配 `10000-10099` 与 Windows bypass 用例。 +- 关联:`scripts/dev-stack-port-utils.mjs`、`scripts/dev.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## SpacetimeDB 入口迁移 helper 合并时不要只保留调用 - 现象:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 或 Jenkins `Genarrative-Stdb-Module-Build` 报 `E0425 cannot find function migrate_rpg_entry_from_old_hidden_default in this scope`,位置在 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 的默认入口配置播种流程。 diff --git a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md index 9e7e0b86..f2262353 100644 --- a/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md +++ b/.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md @@ -34,6 +34,8 @@ metadata: 端口不可用时,脚本会从优先端口开始向后寻找可用端口。后续流程必须以解析后的实际端口为准,不能继续使用默认端口。 +Linux 多用户并发开发时,`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 会先向系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json` 申请一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`adminWeb = start + 3`。注册表锁文件是 `/var/tmp/genarrative-dev-port-ranges/registry.lock`,可通过 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。自动分配从 `10000-10099` 起,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;当前口径是“一个用户固定占用一个段,后续启动继续复用这段并在段内漂移”;该注册表只在 Linux 上生效;Windows 继续沿用原有端口探测、漂移和复用逻辑,不读系统级注册表。 + ## 实现入口 - `package.json` @@ -43,10 +45,12 @@ metadata: - `isPortAvailable(...)`:探测端口是否可监听。 - `findAvailablePort(...)`:从优先端口向后寻找可用端口,`0` 表示申请临时端口。 - `resolveDevStackPorts(...)`:一次性解析 SpacetimeDB、api-server、主站 Vite、后台 Vite 端口,并避免本次解析结果互相冲突。 + - Linux 注册表分配:`reserveLinuxDevPortRange(...)` / `releaseLinuxDevPortRange(...)`,仅在 Linux 上启用系统级端口段登记与用户段复用,自动分配从 `10000-10099` 起。 - CLI 模式:`node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:3101 api:127.0.0.1:8082 web:0.0.0.0:3000 adminWeb:127.0.0.1:3102`。 - `scripts/dev.mjs` - 解析 CLI 参数后统一计算 client host、端口、`SPACETIME_SERVER`、`RUST_SERVER_TARGET`。 - 完整栈按 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 顺序启动。 + - Linux 下会先申请系统级端口段并把它映射成四个 dev 端口;自动分配从 `10000-10099` 起,Windows 则直接沿用原有参数解析与端口漂移逻辑。 - 单模块命令复用同一套参数和 env 解析。 ## 必须保持的传递链路 @@ -60,6 +64,7 @@ metadata: 5. 主站 Vite:`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`、`ADMIN_WEB_TARGET`、`ADMIN_WEB_PORT`、`--port=${WEB_PORT}`、`--host=${WEB_HOST}`。 6. 后台 Vite:`ADMIN_API_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_API_PORT`、`--port=${ADMIN_WEB_PORT}`。 7. 控制台日志:`[dev:ports]` 和 `[dev] web/admin web/api-server/spacetime` 必须显示最终实际地址。 +8. Linux 端口段注册:`[dev] port-range:` 与 `[dev] port-range-registry:` 只在 Linux 输出,Windows 不应依赖系统级注册表。 如果只改了其中一段,通常会出现:浏览器打开的前端可用,但 `/api/*` 代理到旧端口;后台页面可用但后台 API 失败;SpacetimeDB 启动在新端口但 publish 仍发往旧端口。 @@ -76,6 +81,7 @@ metadata: 4. 修改 watch 时保持模块边界:SpacetimeDB 只监听 `spacetime-module` 且改动后重新 publish,不重启 standalone 宿主;api-server 排除 `spacetime-module`;web/admin-web 源码变化交给 Vite 自身 HMR,外层调度器不要再监听前端目录重启 Vite。 5. 修改 `dev:web` 时不要自动改后端目标策略;`dev:web` 只负责主站 Vite 端口可用性与已有后端目标选择。 6. 同步更新技术文档和团队共享记忆。 +7. 如果修改 Linux 端口段注册口径,确认 Windows 分支仍保持旧行为,不要把系统级注册表逻辑扩散到 Windows。 ## 测试与验证 @@ -113,6 +119,7 @@ node scripts/dev-stack-port-utils.mjs resolve-dev-stack spacetime:127.0.0.1:0 ap ## 验收清单 - [ ] 端口工具有测试覆盖端口被占用和多端口互斥解析。 +- [ ] Linux 注册表分配、同用户复用固定段并继续漂移、自动分配从 `10000-10099` 起、Windows bypass 都有测试覆盖。 - [ ] `scripts/dev.mjs` 通过 `node --check`。 - [ ] `npm run dev` 的 SpacetimeDB、publish、api-server、主站 Vite、后台 Vite 都使用实际端口。 - [ ] `npm run dev:web` 在主站端口不可用时能切换到可用端口。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d13fa258..2c214f75 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -45,6 +45,8 @@ npm run dev:web npm run dev:api-server ``` +Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 端口探测与漂移逻辑运行,不读这个系统级注册表。 + 后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 diff --git a/scripts/dev-stack-port-utils.mjs b/scripts/dev-stack-port-utils.mjs index 1a03a19d..bbd8f6e5 100644 --- a/scripts/dev-stack-port-utils.mjs +++ b/scripts/dev-stack-port-utils.mjs @@ -1,5 +1,22 @@ +import {randomBytes} from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {hostname, userInfo} from 'node:os'; +import {dirname, resolve} from 'node:path'; import {createServer} from 'node:net'; +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; + function toListenHosts(host) { if (host === '0.0.0.0') { return ['0.0.0.0']; @@ -21,6 +38,416 @@ export function normalizePort(value, fallback) { return port; } +export function parsePortRangeSpec(value) { + const spec = String(value ?? '').trim(); + if (!spec) { + return null; + } + + const match = /^(\d+)\s*[-:]\s*(\d+)$/u.exec(spec); + if (!match) { + throw new Error(`端口段格式错误: ${spec},应为 start-end 形式,如 10000-10099`); + } + + const start = normalizePort(match[1], -1); + const end = normalizePort(match[2], -1); + if (start < 1024 || end < 1024 || start > end) { + throw new Error(`端口段无效: ${spec},端口必须在 1024-65535 且起始不大于结束`); + } + + if (end - start + 1 < 4) { + throw new Error(`端口段至少需要 4 个端口: ${spec}`); + } + + return {start, end, label: `${start}-${end}`}; +} + +function normalizePortRange(portRange) { + if (!portRange) { + return null; + } + + if (typeof portRange === 'string') { + return parsePortRangeSpec(portRange); + } + + return parsePortRangeSpec(`${portRange.start}-${portRange.end}`); +} + +export function getLinuxDevPortRangeRegistryPaths(env = process.env) { + const registryRoot = + String(env.GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR ?? '').trim() || + LINUX_DEV_PORT_RANGE_REGISTRY_ROOT; + + return { + root: registryRoot, + registryPath: resolve(registryRoot, 'registry.json'), + lockPath: resolve(registryRoot, 'registry.lock'), + }; +} + +export function getLinuxDevPortRangeUsername(env = process.env) { + const candidates = [env.SUDO_USER, env.LOGNAME, env.USER, env.USERNAME]; + + for (const candidate of candidates) { + const value = String(candidate ?? '').trim(); + if (value) { + return value; + } + } + + try { + return userInfo().username; + } catch { + return 'unknown'; + } +} + +export function getLinuxDevPortRangeSpec(env = process.env) { + return parsePortRangeSpec(env.GENARRATIVE_DEV_PORT_RANGE); +} + +export function mapDevPortsToPortRange(portRange) { + const normalizedRange = normalizePortRange(portRange); + if (!normalizedRange) { + return null; + } + + return { + webPort: normalizedRange.start, + apiPort: normalizedRange.start + 1, + spacetimePort: normalizedRange.start + 2, + adminWebPort: normalizedRange.start + 3, + range: normalizedRange, + }; +} + +function ensureWorldWritableDirectory(path) { + mkdirSync(path, {recursive: true}); + try { + chmodSync(path, 0o777); + } catch { + // 目录已存在时若无法改权限,也不影响当前进程继续写入。 + } +} + +function randomToken(byteLength = 8) { + return randomBytes(byteLength).toString('hex'); +} + +function readJsonFile(path, fallbackValue) { + if (!existsSync(path)) { + return fallbackValue; + } + + const rawText = readFileSync(path, 'utf8').trim(); + if (!rawText) { + return fallbackValue; + } + + try { + return JSON.parse(rawText); + } catch (error) { + throw new Error(`系统级端口段记录文件损坏: ${path}; ${error.message}`); + } +} + +function atomicWriteJsonFile(path, value) { + const targetDir = dirname(path); + ensureWorldWritableDirectory(targetDir); + + const tempPath = resolve( + targetDir, + `.tmp.${process.pid}.${Date.now()}.${randomToken()}.json`, + ); + + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o666, + }); + + try { + chmodSync(tempPath, 0o666); + } catch { + // 先写成功再说,后续 rename 之后还能补一次。 + } + + try { + renameSync(tempPath, path); + } catch (error) { + try { + rmSync(tempPath, {force: true}); + } catch { + // ignore cleanup races + } + throw error; + } + + try { + chmodSync(path, 0o666); + } catch { + // 忽略权限补写失败,记录已落盘即可。 + } +} + +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getDefaultPortRangeRegistry() { + return { + version: 4, + updatedAt: '', + allocations: {}, + }; +} + +function readLinuxPortRangeRegistry(registryPath) { + const registry = readJsonFile(registryPath, getDefaultPortRangeRegistry()); + if (!registry || typeof registry !== 'object' || Array.isArray(registry)) { + throw new Error(`系统级端口段记录文件格式错误: ${registryPath}`); + } + + if (!registry.allocations || typeof registry.allocations !== 'object') { + registry.allocations = {}; + } + + const normalizedAllocations = {}; + for (const [key, record] of Object.entries(registry.allocations)) { + if (!record || typeof record !== 'object' || Array.isArray(record)) { + continue; + } + + const username = String(record.username ?? key ?? '').trim(); + const range = safeNormalizePortRange(record.range); + if (!username || !range) { + continue; + } + + normalizedAllocations[username] = { + username, + range, + claimedAt: String(record.claimedAt ?? '').trim(), + updatedAt: String(record.updatedAt ?? record.claimedAt ?? '').trim(), + source: String(record.source ?? 'auto').trim() || 'auto', + }; + } + + registry.version = 4; + registry.allocations = normalizedAllocations; + return registry; +} + +function safeNormalizePortRange(portRange) { + try { + return normalizePortRange(portRange); + } catch { + return null; + } +} + +function rangesOverlap(left, right) { + return left.start <= right.end && right.start <= left.end; +} + +function findRangeConflict(registry, portRange, excludingUsername = '') { + const rangeToCheck = safeNormalizePortRange(portRange); + if (!rangeToCheck) { + return null; + } + + for (const [username, record] of Object.entries(registry.allocations ?? {})) { + if (username === excludingUsername) { + continue; + } + + const recordRange = safeNormalizePortRange(record?.range); + if (!recordRange || !rangesOverlap(recordRange, rangeToCheck)) { + continue; + } + + return {username, record, range: recordRange}; + } + + return null; +} + +function nextLinuxPortRangeCandidate(registry) { + for ( + let start = LINUX_DEV_PORT_RANGE_POOL_START; + start <= LINUX_DEV_PORT_RANGE_POOL_END; + start += LINUX_DEV_PORT_RANGE_BLOCK_SIZE + ) { + const end = start + LINUX_DEV_PORT_RANGE_BLOCK_SIZE - 1; + if (end > LINUX_DEV_PORT_RANGE_POOL_END) { + break; + } + + const label = `${start}-${end}`; + if (!findRangeConflict(registry, {start, end, label})) { + return {start, end, label}; + } + } + + return null; +} + +async function withLinuxPortRangeRegistryLock(lockPath, action) { + ensureWorldWritableDirectory(dirname(lockPath)); + + while (true) { + try { + writeFileSync( + lockPath, + JSON.stringify( + { + pid: process.pid, + hostname: hostname(), + acquiredAt: new Date().toISOString(), + }, + null, + 2, + ), + {encoding: 'utf8', flag: 'wx', mode: 0o666}, + ); + break; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + + let lockRecord = null; + try { + lockRecord = readJsonFile(lockPath, null); + } catch { + lockRecord = null; + } + + if (!lockRecord || !isProcessAlive(lockRecord.pid)) { + try { + rmSync(lockPath, {force: true}); + } catch { + // ignore cleanup races + } + continue; + } + + await sleep(50); + } + } + + try { + return await action(); + } finally { + try { + rmSync(lockPath, {force: true}); + } catch { + // ignore cleanup races + } + } +} + +export async function reserveLinuxDevPortRange({ + env = process.env, + username = getLinuxDevPortRangeUsername(env), + requestedRange = getLinuxDevPortRangeSpec(env), + registryPath = getLinuxDevPortRangeRegistryPaths(env).registryPath, + lockPath = getLinuxDevPortRangeRegistryPaths(env).lockPath, +} = {}) { + if (process.platform !== 'linux') { + return null; + } + + const normalizedRequestedRange = normalizePortRange(requestedRange); + const now = new Date().toISOString(); + + return await withLinuxPortRangeRegistryLock(lockPath, async () => { + const registry = readLinuxPortRangeRegistry(registryPath); + + const current = registry.allocations[username]; + if (current) { + registry.updatedAt = now; + registry.allocations[username] = { + ...current, + username, + range: current.range, + updatedAt: now, + }; + atomicWriteJsonFile(registryPath, registry); + return registry.allocations[username]; + } + + const rangeToUse = normalizedRequestedRange || nextLinuxPortRangeCandidate(registry); + + if (!rangeToUse) { + throw new Error( + `无法为 Linux dev 分配端口段,范围 ${LINUX_DEV_PORT_RANGE_POOL_START}-${LINUX_DEV_PORT_RANGE_POOL_END} 已耗尽`, + ); + } + + const conflict = findRangeConflict(registry, rangeToUse.label, username); + if (conflict) { + if (conflict.range?.label === rangeToUse.label) { + throw new Error( + `Linux 端口段 ${rangeToUse.label} 已被用户 ${conflict.username} 占用,请换一段再启动`, + ); + } + + throw new Error( + `Linux 端口段 ${rangeToUse.label} 已与用户 ${conflict.username} 的端口段 ${conflict.range?.label} 重叠,请换一段再启动`, + ); + } + + registry.allocations[username] = { + username, + range: rangeToUse, + source: normalizedRequestedRange ? 'manual' : 'auto', + claimedAt: now, + updatedAt: now, + }; + registry.updatedAt = now; + atomicWriteJsonFile(registryPath, registry); + + return registry.allocations[username]; + }); +} + +export async function releaseLinuxDevPortRange({ + env = process.env, + username = getLinuxDevPortRangeUsername(env), + registryPath = getLinuxDevPortRangeRegistryPaths(env).registryPath, + lockPath = getLinuxDevPortRangeRegistryPaths(env).lockPath, +} = {}) { + if (process.platform !== 'linux') { + return false; + } + + return await withLinuxPortRangeRegistryLock(lockPath, async () => { + const registry = readLinuxPortRangeRegistry(registryPath); + if (!registry.allocations[username]) { + return false; + } + + delete registry.allocations[username]; + registry.updatedAt = new Date().toISOString(); + atomicWriteJsonFile(registryPath, registry); + return true; + }); +} + export async function isPortAvailable({host, port}) { if (port === 0) { return true; @@ -46,16 +473,45 @@ export async function isPortAvailable({host, port}) { return true; } -export async function findAvailablePort({host, preferredPort, reservedPorts = new Set(), maxAttempts = 200}) { +export async function findAvailablePort({ + host, + preferredPort, + reservedPorts = new Set(), + maxAttempts = null, + portRange = null, +}) { + const range = normalizePortRange(portRange); const startPort = normalizePort(preferredPort, 0); + if (startPort === 0 && range) { + return await findAvailablePort({ + host, + preferredPort: range.start, + reservedPorts, + maxAttempts: range.end - range.start, + portRange: range, + }); + } + if (startPort === 0) { return await reserveEphemeralPort(host, reservedPorts); } - for (let offset = 0; offset <= maxAttempts; offset += 1) { + if (range && (startPort < range.start || startPort > range.end)) { + throw new Error(`端口 ${startPort} 不在允许端口段 ${range.label} 内`); + } + + const boundedAttempts = range + ? Number.isFinite(maxAttempts) + ? Math.min(Math.max(0, maxAttempts), range.end - startPort) + : range.end - startPort + : Number.isFinite(maxAttempts) + ? Math.max(0, maxAttempts) + : 200; + + for (let offset = 0; offset <= boundedAttempts; offset += 1) { const candidate = startPort + offset; - if (candidate > 65535) { + if (candidate > 65535 || (range && candidate > range.end)) { break; } @@ -68,7 +524,8 @@ export async function findAvailablePort({host, preferredPort, reservedPorts = ne } } - throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口`); + const rangeHint = range ? `,允许端口段 ${range.label}` : ''; + throw new Error(`无法从 ${host}:${startPort} 开始找到可用端口${rangeHint}`); } async function reserveEphemeralPort(host, reservedPorts) { @@ -107,6 +564,7 @@ export async function resolveDevStackPorts(config) { host: portConfig.host, preferredPort: portConfig.preferredPort, reservedPorts, + portRange: portConfig.portRange, }); reservedPorts.add(resolvedPort); result[name] = resolvedPort; diff --git a/scripts/dev-stack-port-utils.test.ts b/scripts/dev-stack-port-utils.test.ts index 62240f9f..5521538d 100644 --- a/scripts/dev-stack-port-utils.test.ts +++ b/scripts/dev-stack-port-utils.test.ts @@ -1,7 +1,13 @@ import {createServer} from 'node:net'; +import {mkdtempSync, readFileSync, rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; import {describe, expect, it} from 'vitest'; import { findAvailablePort, + mapDevPortsToPortRange, + parsePortRangeSpec, + reserveLinuxDevPortRange, resolveDevStackPorts, } from './dev-stack-port-utils.mjs'; @@ -18,6 +24,20 @@ function reservePort(port) { } describe('dev stack port utils', () => { + it('解析端口段并映射到四个 dev 端口', () => { + expect(parsePortRangeSpec('10000-10099')).toEqual({ + start: 10000, + end: 10099, + label: '10000-10099', + }); + expect(mapDevPortsToPortRange('10000-10099')).toMatchObject({ + webPort: 10000, + apiPort: 10001, + spacetimePort: 10002, + adminWebPort: 10003, + }); + }); + it('使用端口可用性检查为被占用端口寻找后续可用端口', async () => { const firstServer = await reservePort(0); const firstPort = firstServer.address().port; @@ -38,6 +58,16 @@ describe('dev stack port utils', () => { } }); + it('端口查找不会越过 Linux 用户端口段', async () => { + await expect( + findAvailablePort({ + host: '127.0.0.1', + preferredPort: 9999, + portRange: {start: 10000, end: 10099, label: '10000-10099'}, + }), + ).rejects.toThrow('不在允许端口段'); + }); + it('为 npm run dev 的所有后续流程解析互不冲突的端口', async () => { const resolvedPorts = await resolveDevStackPorts({ spacetime: {host: '127.0.0.1', preferredPort: 0}, @@ -48,4 +78,191 @@ describe('dev stack port utils', () => { expect(new Set(Object.values(resolvedPorts)).size).toBe(4); }); + + it('端口段内会一直漂移到段尾,不会被默认 200 次尝试截断', async () => { + const rangeStart = 10000; + const rangeEnd = 10300; + const reservedPorts = new Set( + Array.from({length: 201}, (_, index) => rangeStart + index), + ); + + const availablePort = await findAvailablePort({ + host: '127.0.0.1', + preferredPort: rangeStart, + reservedPorts, + portRange: {start: rangeStart, end: rangeEnd, label: `${rangeStart}-${rangeEnd}`}, + }); + + expect(availablePort).toBeGreaterThan(rangeStart + 200); + expect(availablePort).toBeLessThanOrEqual(rangeEnd); + }); + + const linuxIt = process.platform === 'linux' ? it : it.skip; + + linuxIt('Linux 未手动指定端口段时从 10000 开始按 100 端口块自动分配', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); + const registryPath = join(tempRoot, 'registry.json'); + const lockPath = join(tempRoot, 'registry.lock'); + + try { + const aliceAllocation = await reserveLinuxDevPortRange({ + env: { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, + }, + username: 'alice', + requestedRange: null, + registryPath, + lockPath, + }); + const bobAllocation = await reserveLinuxDevPortRange({ + env: { + USER: 'bob', + LOGNAME: 'bob', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, + }, + username: 'bob', + requestedRange: null, + registryPath, + lockPath, + }); + + expect(aliceAllocation.range.label).toBe('10000-10099'); + expect(aliceAllocation.source).toBe('auto'); + expect(bobAllocation.range.label).toBe('10100-10199'); + expect(bobAllocation.source).toBe('auto'); + } finally { + rmSync(tempRoot, {recursive: true, force: true}); + } + }); + + linuxIt('Linux 系统级端口段记录会阻止两个用户拿到同一段', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); + const registryPath = join(tempRoot, 'registry.json'); + const lockPath = join(tempRoot, 'registry.lock'); + + try { + const baseEnv = { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, + GENARRATIVE_DEV_PORT_RANGE: '10000-10099', + }; + + const aliceAllocation = await reserveLinuxDevPortRange({ + env: baseEnv, + username: 'alice', + registryPath, + lockPath, + }); + + expect(aliceAllocation.range.label).toBe('10000-10099'); + + const registryAfterAlice = JSON.parse(readFileSync(registryPath, 'utf8')); + expect(registryAfterAlice.allocations.alice.range.label).toBe('10000-10099'); + + await expect( + reserveLinuxDevPortRange({ + env: { + ...baseEnv, + USER: 'bob', + LOGNAME: 'bob', + }, + username: 'bob', + requestedRange: '10000-10099', + registryPath, + lockPath, + }), + ).rejects.toThrow('已被用户 alice 占用'); + + const aliceReuse = await reserveLinuxDevPortRange({ + env: { + ...baseEnv, + USER: 'alice', + LOGNAME: 'alice', + }, + username: 'alice', + registryPath, + lockPath, + }); + + expect(aliceReuse.range.label).toBe('10000-10099'); + expect(aliceReuse.source).toBe('manual'); + expect( + ( + await reserveLinuxDevPortRange({ + env: { + ...baseEnv, + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '10100-10199', + }, + username: 'alice', + requestedRange: '10100-10199', + registryPath, + lockPath, + }) + ).range.label, + ).toBe('10000-10099'); + + const registryAfterReuse = JSON.parse(readFileSync(registryPath, 'utf8')); + expect(registryAfterReuse.allocations.alice.range.label).toBe('10000-10099'); + + const bobAllocation = await reserveLinuxDevPortRange({ + env: { + ...baseEnv, + USER: 'bob', + LOGNAME: 'bob', + GENARRATIVE_DEV_PORT_RANGE: '10100-10199', + }, + username: 'bob', + requestedRange: '10100-10199', + registryPath, + lockPath, + }); + + expect(bobAllocation.range.label).toBe('10100-10199'); + } finally { + rmSync(tempRoot, {recursive: true, force: true}); + } + }); + + linuxIt('Linux 同一用户第二个 dev 会话会复用同一用户段并继续在段内漂移', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'genarrative-port-range-')); + const registryPath = join(tempRoot, 'registry.json'); + const lockPath = join(tempRoot, 'registry.lock'); + + try { + const baseEnv = { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempRoot, + GENARRATIVE_DEV_PORT_RANGE: '10000-10099', + }; + + const first = await reserveLinuxDevPortRange({ + env: baseEnv, + username: 'alice', + registryPath, + lockPath, + }); + + const second = await reserveLinuxDevPortRange({ + env: baseEnv, + username: 'alice', + registryPath, + lockPath, + }); + + expect(first.range.label).toBe('10000-10099'); + expect(second.range.label).toBe('10000-10099'); + expect(second.username).toBe('alice'); + expect(JSON.parse(readFileSync(registryPath, 'utf8')).allocations.alice.range.label).toBe( + '10000-10099', + ); + } finally { + rmSync(tempRoot, {recursive: true, force: true}); + } + }); }); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 6cd9cd13..b45c06f6 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -15,7 +15,11 @@ import {fileURLToPath} from 'node:url'; import { formatPortDecision, + getLinuxDevPortRangeRegistryPaths, + getLinuxDevPortRangeUsername, + mapDevPortsToPortRange, normalizePort, + reserveLinuxDevPortRange, resolveDevStackPorts, } from './dev-stack-port-utils.mjs'; import { @@ -60,6 +64,7 @@ function usage() { --spacetime-port SpacetimeDB 端口 --spacetime-data-dir SpacetimeDB 本地数据目录 --database SpacetimeDB 数据库名 + --port-range Linux 用户端口段,手动指定示例 10000-10099;默认自动从 10000-10099 起分配 --watch 文件改动后刷新/重启对应模块 --no-interactive 关闭交互式手动命令 @@ -110,6 +115,7 @@ function parseArgs(argv, baseEnv) { spacetimePort: normalizePort(env.SPACETIME_PORT, 3101), spacetimeDataDir: resolve(serverRsDir, '.spacetimedb/local/data'), spacetimeServerUrl: String(env.GENARRATIVE_SPACETIME_SERVER_URL ?? '').trim(), + portRangeSpec: String(env.GENARRATIVE_DEV_PORT_RANGE ?? '').trim(), database: readLocalSpacetimeDatabase() || String(env.GENARRATIVE_SPACETIME_DATABASE ?? '').trim() || @@ -185,6 +191,10 @@ function parseArgs(argv, baseEnv) { options.database = readValue(); explicitOptions.add('database'); break; + case '--port-range': + options.portRangeSpec = readValue(); + explicitOptions.add('portRangeSpec'); + break; case '--log': options.apiLog = readValue(); break; @@ -728,6 +738,8 @@ class DevRunner { adminWebTargetHost: resolveClientHost(options.adminWebHost), spacetimeServer: initialSpacetimeServer, apiTarget: `http://${resolveClientHost(options.apiHost)}:${options.apiPort}`, + portRange: null, + portRangeReservation: null, }; this.services = new Map(); this.watchers = []; @@ -751,6 +763,7 @@ class DevRunner { ensureSpacetimeToolVersionMatchesWorkspace(); } + await this.prepareLinuxPortRange(command); await this.tryReuseExistingSpacetime(command); await this.resolvePorts(command); this.registerServices(); @@ -758,6 +771,50 @@ class DevRunner { this.writeDevStackState(); } + async prepareLinuxPortRange(command) { + if (process.platform !== 'linux') { + return; + } + + const requestedRange = String(this.options.portRangeSpec ?? '').trim(); + const allocation = await reserveLinuxDevPortRange({ + env: { + ...this.baseEnv, + GENARRATIVE_DEV_PORT_RANGE: requestedRange, + }, + username: getLinuxDevPortRangeUsername(this.baseEnv), + }); + + if (!allocation) { + return; + } + + this.state.portRangeReservation = allocation; + this.state.portRange = allocation.range; + this.baseEnv.GENARRATIVE_DEV_PORT_RANGE = allocation.range.label; + + const mappedPorts = mapDevPortsToPortRange(allocation.range); + if (!mappedPorts) { + return; + } + + if (!this.explicitOptions.has('webPort')) { + this.options.webPort = mappedPorts.webPort; + } + if (!this.explicitOptions.has('apiPort')) { + this.options.apiPort = mappedPorts.apiPort; + } + if (!this.explicitOptions.has('spacetimePort')) { + this.options.spacetimePort = mappedPorts.spacetimePort; + } + if (!this.explicitOptions.has('adminWebPort')) { + this.options.adminWebPort = mappedPorts.adminWebPort; + } + + this.state.spacetimeServer = `http://${this.options.spacetimeHost}:${this.options.spacetimePort}`; + this.state.apiTarget = `http://${this.state.apiTargetHost}:${this.options.apiPort}`; + } + shouldValidateSpacetimeToolVersion(command) { if (command === 'spacetime') { return true; @@ -806,6 +863,10 @@ class DevRunner { ]); for (const candidate of candidates) { + if (!this.isCandidateSpacetimeWithinAssignedRange(candidate)) { + continue; + } + const pingUrl = buildUrl(candidate, '/v1/ping'); if (!pingUrl || !(await isHttpReady(pingUrl))) { continue; @@ -836,6 +897,21 @@ class DevRunner { ); } + isCandidateSpacetimeWithinAssignedRange(candidateUrl) { + const range = this.state.portRange; + if (!range) { + return true; + } + + try { + const url = new URL(candidateUrl); + const port = Number(url.port); + return Number.isInteger(port) && port >= range.start && port <= range.end; + } catch { + return false; + } + } + async resolvePorts(command) { const {options} = this; const portConfig = {}; @@ -845,6 +921,7 @@ class DevRunner { portConfig.spacetime = { host: options.spacetimeHost, preferredPort: options.spacetimePort, + portRange: this.state.portRange, }; } } @@ -853,6 +930,7 @@ class DevRunner { portConfig.api = { host: options.apiHost, preferredPort: options.apiPort, + portRange: this.state.portRange, }; } @@ -860,6 +938,7 @@ class DevRunner { portConfig.web = { host: options.webHost, preferredPort: options.webPort, + portRange: this.state.portRange, }; } @@ -867,6 +946,7 @@ class DevRunner { portConfig.adminWeb = { host: options.adminWebHost, preferredPort: options.adminWebPort, + portRange: this.state.portRange, }; } @@ -959,6 +1039,15 @@ class DevRunner { console.log(`[dev] repo: ${repoRoot}`); console.log(`[dev] command: ${command}`); console.log(`[dev] watch: ${options.watch ? 'on' : 'off'}`); + if (state.portRange) { + const owner = + state.portRangeReservation?.username || + getLinuxDevPortRangeUsername(this.baseEnv); + console.log(`[dev] port-range: ${state.portRange.label} (${owner})`); + console.log( + `[dev] port-range-registry: ${getLinuxDevPortRangeRegistryPaths(this.baseEnv).registryPath}`, + ); + } console.log(`[dev] web: http://127.0.0.1:${options.webPort}`); console.log(`[dev] admin web: http://${state.adminWebTargetHost}:${options.adminWebPort}/admin/`); console.log(`[dev] api-server: ${state.apiTarget}`); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index cda971b5..b22bad82 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -37,6 +37,8 @@ function workspaceSpacetimeVersionForTest() { } describe('dev scheduler argument routing', () => { + const linuxTest = process.platform === 'linux' ? test : test.skip; + test('完整 dev 栈覆盖前端代理到本次解析出的 api-server 地址', () => { const {command, explicitOptions, options} = parseArgs([], { GENARRATIVE_API_PORT: '8090', @@ -90,6 +92,67 @@ describe('dev scheduler argument routing', () => { 'http://127.0.0.1:3100', ); }); + + linuxTest('Linux 启动时按系统级端口段映射四个 dev 端口', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-dev-port-range-')); + try { + const {command, explicitOptions, options} = parseArgs([], { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '22000-22099', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir, + }); + const runner = new DevRunner(options, { + USER: 'alice', + LOGNAME: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '22000-22099', + GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR: tempDir, + }, explicitOptions); + + await runner.prepareLinuxPortRange(command); + + expect(runner.state.portRange.label).toBe('22000-22099'); + expect(runner.options.webPort).toBe(22000); + expect(runner.options.apiPort).toBe(22001); + expect(runner.options.spacetimePort).toBe(22002); + expect(runner.options.adminWebPort).toBe(22003); + expect(runner.state.apiTarget).toBe('http://127.0.0.1:22001'); + expect(runner.state.spacetimeServer).toBe('http://127.0.0.1:22002'); + } finally { + rmSync(tempDir, {recursive: true, force: true}); + } + }); + + test('Windows 仍沿用原有端口解析,不启用 Linux 端口段登记', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }); + + try { + const {command, explicitOptions, options} = parseArgs([], { + USER: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '22000-22099', + }); + const runner = new DevRunner(options, { + USER: 'alice', + GENARRATIVE_DEV_PORT_RANGE: '22000-22099', + }, explicitOptions); + + await runner.prepareLinuxPortRange(command); + + expect(runner.state.portRange).toBeNull(); + expect(runner.options.webPort).toBe(3000); + expect(runner.options.apiPort).toBe(8082); + expect(runner.options.spacetimePort).toBe(3101); + expect(runner.options.adminWebPort).toBe(3102); + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + } + }); }); describe('dev scheduler api-server env', () => {