diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 1c9f8a05..c2e3f02e 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -111,8 +111,8 @@ - 现象:本地 `npm run dev` 因 `3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。 - 原因:`npm run dev` 默认同时启动 SpacetimeDB standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。 -- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。`3101` 已被可复用 standalone 占用时也可显式使用 `npm run dev -- --skip-spacetime`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。 -- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。 +- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。确认需要新启动 SpacetimeDB 时,脚本先检测 `3101`,被占用则输出占用进程并选择最近可用端口,保证 publish 与 `api-server` 都连接同一个实际 SpacetimeDB URL。`api-server` 启动前也会检测 `8082` 并选择最近可用端口。Windows / Git Bash 下不要用 `tr/head/xargs` 管道读取 `spacetime.pid` 或 URL 记录,脚本应使用 Node 读取并短重试,避免 `tr: read error: Device or resource busy`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。 +- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`3101` 或 `8082` 被其他进程占用时,脚本输出占用进程并使用最近可用端口;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。 - 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`scripts/dev-rust-stack.sh`。 ## 本地 SpacetimeDB publish 401 可清本地库重发 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 10104b13..83538794 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -36,10 +36,10 @@ npm run dev:rust 1. 检查 `cargo`、`node` 与 `spacetime` CLI。 2. Windows Git Bash 下如 `server-rs/.spacetimedb/local/bin/current/spacetimedb-cli.exe` 不存在,先把本机 `spacetime` 所在安装目录的 `bin/` 与 `spacetime.exe` 同步到 `server-rs/.spacetimedb/local/`。 3. 启动 SpacetimeDB 前先检查 `server-rs/.spacetimedb/local/data/spacetime.pid`:如果 pid 对应进程仍存在,且同目录 `dev-rust-spacetime-url` 中记录的 URL 可被 `spacetime server ping` 判定在线,则直接复用该宿主;如果 URL 记录缺失,会依次尝试从 `logs/dev-rust-spacetime-start.log` 和 `logs/spacetime-standalone.log` 中解析最近一次监听地址兜底。否则按正常流程重新启动。 -4. 正常启动 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr 127.0.0.1:3101`,确保本地数据库与 SpacetimeDB 内部日志落在项目数据目录中;启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。 +4. 如果确认需要新启动 SpacetimeDB,脚本会先检测 `127.0.0.1:3101` 是否可监听;若已占用,输出占用进程并选择从 `3101` 起向后的最近可用端口,再执行 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr <实际地址>`。启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`,后续 publish 与 `api-server` 都使用同一个实际 URL。 5. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。 -6. 执行 `spacetime publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 -7. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 +6. 执行 `spacetime publish <本地数据库名> --server <实际 SpacetimeDB URL> --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。 +7. 启动 `api-server` 前先检测默认 API 端口 `8082` 是否可监听;若已占用,输出占用进程并选择从 `8082` 起向后的最近可用端口。随后注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*`,启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。 8. 等待 `http://127.0.0.1:/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:`。 9. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。 10. 任一子进程退出时,脚本回收其余子进程。 @@ -61,7 +61,7 @@ Vite 代理覆盖范围: 本地联调跳过策略: -1. 如果 `3101` 已被当前可复用的 SpacetimeDB standalone 占用,可使用 `npm run dev -- --skip-spacetime` 跳过 SpacetimeDB 宿主启动,只复用现有监听实例并继续后续发布、`api-server` 与前端启动。若占用方不是本仓库本地 SpacetimeDB,先停止占用进程或改用 `--spacetime-port`。 +1. 如果 `3101` 已被当前可复用的 SpacetimeDB standalone 占用,脚本会优先按 `spacetime.pid` 与 `dev-rust-spacetime-url` 复用该宿主;如果确认不是可复用宿主,则会先输出占用进程并选择最近可用端口。也可显式使用 `npm run dev -- --skip-spacetime` 跳过 SpacetimeDB 宿主启动,或用 `--spacetime-port` 指定起始探测端口。 2. 如果当前没有修改 `server-rs/crates/spacetime-module`,可使用 `npm run dev -- --skip-publish` 跳过数据库发布,降低本地启动时的 SpacetimeDB wasm 编译耗时。 3. 如果当前阶段只需要检查 `spacetime-module` 语法,不需要重新发布本地数据库,可执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。该命令只做 Rust 编译检查,不生成新数据库,也不刷新 bindings。 @@ -118,6 +118,7 @@ npm run dev:rust:logs -- --follow 4. 如果 Vite 输出 `/api/auth/refresh`、`/api/auth/login-options` 或 `/api/runtime/custom-world-gallery` 的 `ECONNREFUSED`,先确认当前脚本是否已经打印 `等待 api-server 就绪` 并通过;正常情况下 Vite 只会在 `/healthz` 可访问后启动,不应再因为 Rust 监听未完成而代理失败。 5. 如果 `spacetime server ping` 打印 `Server could not be reached (502 Bad Gateway)`,即使命令退出码为 `0` 也不能直接视为已就绪;本地脚本会继续探测 `/v1/ping`。若 `/v1/ping` 返回 `200`,说明 standalone 已经可用,可以继续发布模块;若 `/v1/ping` 也失败,脚本会继续等待新启动实例,或在 root-dir 已被其他实例占用时输出占用进程。 6. 如果本地 `spacetime publish` 显示 `401` 无权限,且确认本地开发数据可以丢弃,可执行 `spacetime --root-dir=server-rs/.spacetimedb/local server clear` 清除本地 SpacetimeDB 数据库后重新发布。重新发布时日志应表现为创建新的数据库,而不是更新旧数据库;如果仍显示更新旧库或继续无权限,说明 root-dir、库名或 CLI 身份仍未对齐。 +7. Windows / Git Bash 下读取 `spacetime.pid` 或 `dev-rust-spacetime-url` 时,如果文件正被 SpacetimeDB 更新,不能用 `tr/head/xargs` 管道直接读;脚本使用 Node 读取并短重试,避免出现 `tr: read error: Device or resource busy` 后直接中断。 编译警告治理: diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 3e7ebd1d..c686a0b9 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -19,7 +19,7 @@ usage() { 1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。 2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。 3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。 - 4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;端口被占用时自动接受 SpacetimeDB 建议的最近可用端口。 + 4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;需要新启动时会先检测端口并选择最近可用端口。 5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。 EOF } @@ -191,6 +191,144 @@ port_from_listen_addr() { echo "${listen_addr##*:}" } +read_digits_from_file() { + local file_path="$1" + + node -e ' +const fs = require("fs"); +const filePath = process.argv[1]; +const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + +for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const digits = fs.readFileSync(filePath, "utf8").replace(/\D/gu, "").slice(0, 20); + if (digits) { + process.stdout.write(digits); + process.exit(0); + } + process.exit(1); + } catch { + sleep(100); + } +} + +process.exit(1); +' "${file_path}" 2>/dev/null +} + +read_trimmed_first_line_from_file() { + local file_path="$1" + + node -e ' +const fs = require("fs"); +const filePath = process.argv[1]; +const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + +for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const firstLine = fs.readFileSync(filePath, "utf8").split(/\r?\n/u)[0]?.trim() ?? ""; + if (firstLine) { + process.stdout.write(firstLine); + process.exit(0); + } + process.exit(1); + } catch { + sleep(100); + } +} + +process.exit(1); +' "${file_path}" 2>/dev/null +} + +validate_tcp_port() { + local port="$1" + local label="$2" + + if ! [[ "${port}" =~ ^[0-9]+$ ]] || ((port < 1 || port > 65535)); then + echo "[dev:rust] ${label} 端口无效: ${port}" >&2 + exit 1 + fi +} + +is_tcp_port_available() { + local host="$1" + local port="$2" + + node -e ' +const net = require("net"); +const host = process.argv[1]; +const port = Number(process.argv[2]); +if (!Number.isInteger(port) || port < 1 || port > 65535) { + process.exit(2); +} +const server = net.createServer(); +server.once("error", () => process.exit(1)); +server.once("listening", () => server.close(() => process.exit(0))); +server.listen({ host, port, exclusive: true }); +' "${host}" "${port}" >/dev/null 2>&1 +} + +describe_tcp_port_owner() { + local port="$1" + + if command -v powershell.exe >/dev/null 2>&1; then + PORT_FOR_POWERSHELL="${port}" powershell.exe -NoProfile -Command ' +$port = [int]$env:PORT_FOR_POWERSHELL +Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 5 | + ForEach-Object { + $process = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue + $name = if ($process) { $process.ProcessName } else { "unknown" } + "pid=$($_.OwningProcess) name=$name local=$($_.LocalAddress):$($_.LocalPort)" + } +' 2>/dev/null || true + return + fi + + if command -v ss >/dev/null 2>&1; then + ss -lntp 2>/dev/null | awk -v port=":${port}" 'NR == 1 || index($0, port) > 0 { print }' || true + return + fi + + if command -v netstat >/dev/null 2>&1; then + netstat -lntp 2>/dev/null | awk -v port=":${port}" 'NR == 1 || index($0, port) > 0 { print }' || true + fi +} + +find_nearest_available_port() { + local host="$1" + local requested_port="$2" + local label="$3" + local max_probe_count="${4:-100}" + + validate_tcp_port "${requested_port}" "${label}" + + local port="${requested_port}" + local probe_count=0 + + while ((port <= 65535 && probe_count < max_probe_count)); do + if is_tcp_port_available "${host}" "${port}"; then + if [[ "${port}" != "${requested_port}" ]]; then + echo "[dev:rust] ${label} 端口 ${host}:${requested_port} 已占用,改用最近可用端口 ${host}:${port}" >&2 + fi + echo "${port}" + return + fi + + if ((probe_count == 0)); then + echo "[dev:rust] ${label} 端口 ${host}:${requested_port} 已占用,开始查找最近可用端口。" >&2 + describe_tcp_port_owner "${requested_port}" >&2 || true + fi + + port=$((port + 1)) + probe_count=$((probe_count + 1)) + done + + echo "[dev:rust] 无法为 ${label} 在 ${host}:${requested_port} 起向后 ${max_probe_count} 个端口内找到可用端口。" >&2 + exit 1 +} + spacetime_url_record_path() { local data_dir="$1" echo "${data_dir}/dev-rust-spacetime-url" @@ -215,8 +353,7 @@ read_spacetime_pid() { fi local pid - pid="$(tr -cd '0-9' <"${pid_file}" | head -c 20)" - if [[ -z "${pid}" ]]; then + if ! pid="$(read_digits_from_file "${pid_file}")"; then return 1 fi @@ -230,16 +367,13 @@ try_reuse_existing_spacetime() { local existing_pid local recorded_url="" - if ! existing_pid="$(read_spacetime_pid "${data_dir}")"; then - return 1 + if [[ -f "${url_file}" ]]; then + if ! recorded_url="$(read_trimmed_first_line_from_file "${url_file}")"; then + recorded_url="" + fi fi - if ! kill -0 "${existing_pid}" 2>/dev/null; then - echo "[dev:rust] 发现过期 spacetime.pid: ${existing_pid},将重新启动 SpacetimeDB。" - return 1 - fi - - if [[ ! -f "${url_file}" ]]; then + if [[ -z "${recorded_url}" ]]; then local start_log start_log="$(spacetime_start_log_path "${data_dir}")" if [[ -f "${start_log}" ]]; then @@ -261,11 +395,9 @@ try_reuse_existing_spacetime() { fi fi if [[ -z "${recorded_url}" ]]; then - echo "[dev:rust] 发现运行中的 SpacetimeDB pid=${existing_pid},但未找到 URL 记录: ${url_file}。" + echo "[dev:rust] 未找到可复用的 SpacetimeDB URL 记录: ${url_file}。" return 1 fi - else - recorded_url="$(head -n 1 "${url_file}" | tr -d '\r' | xargs)" fi if [[ -z "${recorded_url}" ]]; then @@ -277,10 +409,24 @@ try_reuse_existing_spacetime() { SPACETIME_SERVER="${recorded_url}" SPACETIME_PORT="$(port_from_listen_addr "${recorded_url}")" SPACETIME_REUSED_EXISTING=1 - echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid=${existing_pid})" + if existing_pid="$(read_spacetime_pid "${data_dir}")"; then + echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid=${existing_pid})" + else + echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid 文件暂不可读)" + fi return 0 fi + if ! existing_pid="$(read_spacetime_pid "${data_dir}")"; then + echo "[dev:rust] URL 不在线且 spacetime.pid 暂不可读: ${recorded_url},将检查 data-dir 占用。" + return 1 + fi + + if ! kill -0 "${existing_pid}" 2>/dev/null; then + echo "[dev:rust] 发现过期 spacetime.pid: ${existing_pid},将重新启动 SpacetimeDB。" + return 1 + fi + echo "[dev:rust] pid=${existing_pid} 存在,但 URL 不在线: ${recorded_url},将重新启动 SpacetimeDB。" return 1 } @@ -760,15 +906,17 @@ if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then exit 1 fi + SPACETIME_PORT="$(find_nearest_available_port "${SPACETIME_HOST}" "${SPACETIME_PORT}" "SpacetimeDB")" + SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}" + SPACETIME_START_LOG="$(spacetime_start_log_path "${SPACETIME_DATA_DIR}")" mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")" : >"${SPACETIME_START_LOG}" echo "[dev:rust] 启动 spacetimedb" ( cd "${SERVER_RS_DIR}" - # 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口; - # 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。 - printf '\n' | spacetime \ + # 启动前已经由脚本选定端口,避免 api-server 和 SpacetimeDB 对数据库地址认知不一致。 + spacetime \ start \ --data-dir "${SPACETIME_DATA_DIR}" \ --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ @@ -815,8 +963,11 @@ fi echo "[dev:rust] 启动 api-server" load_api_server_env_files +API_PORT="$(find_nearest_available_port "${API_HOST}" "${API_PORT}" "api-server")" +API_TARGET_HOST="$(resolve_client_host "${API_HOST}")" # `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。 RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}" +echo "[dev:rust] api actual: ${RUST_SERVER_TARGET}" ( cd "${REPO_ROOT}" GENARRATIVE_API_HOST="${API_HOST}" \