Files
Genarrative/scripts/dev-rust-stack.sh
2026-05-08 11:44:42 +08:00

645 lines
20 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
用法:
npm run dev:rust
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110
./scripts/dev-rust-stack.sh --admin-web-port 3102
./scripts/dev-rust-stack.sh --api-timeout-seconds 600
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
./scripts/dev-rust-stack.sh --preserve-database
./scripts/dev-rust-stack.sh --no-migration-bootstrap-secret
npm run dev:rust:logs -- --follow
说明:
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 作为本地数据与日志目录。
5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
EOF
}
require_command() {
local command_name="$1"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[dev:rust] 缺少命令: ${command_name}" >&2
exit 1
fi
}
resolve_client_host() {
local host_name="$1"
if [[ "${host_name}" == "0.0.0.0" || "${host_name}" == "::" ]]; then
echo "127.0.0.1"
return
fi
echo "${host_name}"
}
load_api_server_env_files() {
local env_files=()
local key
local value
[[ -f "${REPO_ROOT}/.env" ]] && env_files+=("${REPO_ROOT}/.env")
[[ -f "${REPO_ROOT}/.env.local" ]] && env_files+=("${REPO_ROOT}/.env.local")
[[ -f "${REPO_ROOT}/.env.secrets.local" ]] && env_files+=("${REPO_ROOT}/.env.secrets.local")
if [[ "${#env_files[@]}" -eq 0 ]]; then
return
fi
# Node 只负责按 dotenv 子集解析文本;通过 NUL 分隔返回,避免让 env 文件内容参与 shell 求值。
while IFS= read -r -d '' key && IFS= read -r -d '' value; do
export "${key}=${value}"
done < <(
node - "${env_files[@]}" <<'NODE'
const fs = require('fs');
const shellEnvKeys = new Set(Object.keys(process.env));
const values = new Map();
for (const filePath of process.argv.slice(2)) {
if (!fs.existsSync(filePath)) {
continue;
}
const rawText = fs.readFileSync(filePath, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
if (shellEnvKeys.has(key)) {
continue;
}
values.set(key, rawValue.replace(/^['"]|['"]$/gu, ''));
}
}
for (const [key, value] of values.entries()) {
process.stdout.write(`${key}\0${value}\0`);
}
NODE
)
}
cleanup() {
local index
for ((index = ${#PIDS[@]} - 1; index >= 0; index--)); do
local pid="${PIDS[index]}"
local name="${NAMES[index]}"
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
echo "[dev:rust] 停止 ${name} (pid=${pid})"
if command -v pgrep >/dev/null 2>&1; then
while read -r child_pid; do
if [[ -n "${child_pid}" ]]; then
kill "${child_pid}" 2>/dev/null || true
fi
done < <(pgrep -P "${pid}" 2>/dev/null || true)
fi
kill "${pid}" 2>/dev/null || true
sleep 0.2
if kill -0 "${pid}" 2>/dev/null; then
kill -9 "${pid}" 2>/dev/null || true
fi
fi
done
}
wait_for_spacetime() {
local server="$1"
local timeout_seconds="$2"
local root_dir="$3"
local process_pid="${4:-}"
local deadline=$((SECONDS + timeout_seconds))
while ((SECONDS < deadline)); do
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2
print_spacetime_start_failure_diagnostics "${root_dir}"
exit 1
fi
if is_spacetime_ready "${server}" "${root_dir}"; then
return
fi
sleep 0.5
done
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
print_spacetime_start_failure_diagnostics "${root_dir}"
exit 1
}
is_spacetime_ready() {
local server="$1"
local root_dir="$2"
local output
if output="$(spacetime --root-dir="${root_dir}" server ping "${server}" 2>&1)" &&
[[ "${output}" == *"Server is online:"* ]]; then
return 0
fi
# SpacetimeDB CLI 2.1.0 在 Windows 下可能对已监听的 standalone 返回 502
# 直接探测 HTTP 健康端点,避免 npm run dev:rust 卡在“等待 SpacetimeDB 就绪”。
node -e '
const target = new URL("/v1/ping", process.argv[1]);
const client = target.protocol === "https:" ? require("https") : require("http");
const request = client.get(target, { timeout: 1000 }, (response) => {
response.resume();
process.exit(response.statusCode >= 200 && response.statusCode < 300 ? 0 : 1);
});
request.on("timeout", () => request.destroy(new Error("timeout")));
request.on("error", () => process.exit(1));
' "${server}" >/dev/null 2>&1
}
print_spacetime_start_failure_diagnostics() {
local root_dir="$1"
local log_file="${root_dir}/data/logs/spacetime-standalone.log"
echo "[dev:rust] SpacetimeDB root-dir: ${root_dir}" >&2
if [[ ! -f "${log_file}" ]]; then
echo "[dev:rust] 未找到 SpacetimeDB standalone 日志: ${log_file}" >&2
return
fi
echo "[dev:rust] 最近 SpacetimeDB standalone 日志: ${log_file}" >&2
tail -n 80 "${log_file}" >&2 || true
if grep -q "mismatched database identity" "${log_file}" 2>/dev/null; then
echo "[dev:rust] 检测到本地 replica 与当前数据库 identity 不一致。" >&2
echo "[dev:rust] 常见原因是同一个 root-dir 保留了旧库 data/replicas/1但 control-db 已指向新库。" >&2
echo "[dev:rust] 若这是可丢弃的本地开发库,请先停止 SpacetimeDB再备份或移走 ${root_dir}/data 后重新启动。" >&2
echo "[dev:rust] 若需要保留数据,不要清理目录;请改回创建旧库的 database/root-dir或先走迁移导出。" >&2
fi
}
describe_spacetime_root_owner() {
local root_dir="$1"
local windows_root_dir="${root_dir}"
if [[ "${windows_root_dir}" =~ ^/([a-zA-Z])/(.*)$ ]]; then
windows_root_dir="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}"
fi
# Windows 本地开发最常见的失败是同一个 root-dir 下已有 standalone 持有 spacetime.pid
# 启动前先打印占用进程,避免用户只看到底层 os error 33 而不知道该停哪个实例。
if command -v powershell.exe >/dev/null 2>&1; then
ROOT_DIR_FOR_POWERSHELL="${windows_root_dir}" powershell.exe -NoProfile -Command '
$rootDir = $env:ROOT_DIR_FOR_POWERSHELL
$normalized = $rootDir.Replace("/", "\")
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match "spacetime" -and $_.CommandLine -and $_.CommandLine.Replace("/", "\") -like "*$normalized*" } |
ForEach-Object { "pid=$($_.ProcessId) name=$($_.Name) command=$($_.CommandLine)" }
' 2>/dev/null || true
return
fi
if command -v ps >/dev/null 2>&1; then
ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v root_dir="${root_dir}" '
{
user = $1
pid = $2
ppid = $3
stat = $4
command = $5
args = $0
sub(/^[[:space:]]*[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]*/, "", args)
name = command
sub(/^.*\//, "", name)
# 只认真实的 SpacetimeDB 启动进程,避免 .spacetimedb 路径让 grep/awk 自身误命中。
if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, root_dir) > 0) {
print user " " pid " " ppid " " stat " " name " " args
}
}
' || true
fi
}
wait_for_api_server() {
local health_url="$1"
local timeout_seconds="$2"
local process_pid="${3:-}"
local deadline=$((SECONDS + timeout_seconds))
while ((SECONDS < deadline)); do
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[dev:rust] api-server 进程在就绪前退出。" >&2
exit 1
fi
# 使用 Node 发起健康检查,避免要求 Windows 本地额外安装 curl/wget。
if node -e '
const target = process.argv[1];
const client = target.startsWith("https:") ? require("https") : require("http");
const request = client.get(target, { timeout: 1000 }, (response) => {
response.resume();
process.exit(response.statusCode >= 200 && response.statusCode < 500 ? 0 : 1);
});
request.on("timeout", () => request.destroy(new Error("timeout")));
request.on("error", () => process.exit(1));
' "${health_url}" >/dev/null 2>&1; then
return
fi
sleep 0.5
done
echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2
exit 1
}
sync_local_spacetime_install() {
local root_dir="$1"
# SpacetimeDB standalone 会在 --root-dir 下回调 bin/current/spacetimedb-cli.exe
# Windows 本地开发使用工程内 root-dir 时,需要把用户级安装目录同步进来。
if [[ "${OSTYPE:-}" != msys* && "${OSTYPE:-}" != cygwin* ]]; then
return
fi
local target_cli="${root_dir}/bin/current/spacetimedb-cli.exe"
if [[ -f "${target_cli}" ]]; then
return
fi
local spacetime_command
spacetime_command="$(command -v spacetime || true)"
if [[ -z "${spacetime_command}" ]]; then
return
fi
local install_dir
install_dir="$(cd -- "$(dirname -- "${spacetime_command}")" && pwd)"
if [[ ! -d "${install_dir}/bin" ]]; then
return
fi
echo "[dev:rust] 同步本机 SpacetimeDB 安装到 ${root_dir}"
mkdir -p "${root_dir}"
cp -a "${install_dir}/bin" "${root_dir}/"
if [[ -f "${install_dir}/spacetime.exe" ]]; then
cp -f "${install_dir}/spacetime.exe" "${root_dir}/spacetime.exe"
fi
# Git Bash 复制 Windows junction 时可能不会生成可执行的 current 目录;
# 若 current 缺失,则用最新版本目录复制出一个真实目录,满足 standalone 回调路径。
if [[ ! -f "${target_cli}" ]]; then
local version_dir
version_dir="$(find "${root_dir}/bin" -mindepth 1 -maxdepth 1 -type d ! -name current | sort -V | tail -n 1)"
if [[ -n "${version_dir}" && -f "${version_dir}/spacetimedb-cli.exe" ]]; then
rm -rf "${root_dir}/bin/current"
cp -a "${version_dir}" "${root_dir}/bin/current"
fi
fi
}
generate_migration_bootstrap_secret() {
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
}
prepare_migration_bootstrap_secret() {
case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in
auto)
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
;;
manual)
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
echo "[dev:rust] 迁移引导密钥至少需要 16 个字符。" >&2
exit 1
fi
;;
disabled)
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
echo "[dev:rust] 未启用迁移引导密钥。"
return
;;
*)
echo "[dev:rust] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2
exit 1
;;
esac
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
echo "[dev:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml"
MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module"
VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs"
ADMIN_WEB_DIR="${REPO_ROOT}/apps/admin-web"
API_HOST="127.0.0.1"
API_PORT="8082"
WEB_HOST="0.0.0.0"
WEB_PORT="3000"
ADMIN_WEB_HOST="127.0.0.1"
ADMIN_WEB_PORT="3102"
SPACETIME_HOST="127.0.0.1"
SPACETIME_PORT="3101"
SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local"
DATABASE=""
API_LOG="info,tower_http=info"
SPACETIME_TIMEOUT_SECONDS="60"
API_SERVER_TIMEOUT_SECONDS="300"
SKIP_SPACETIME=0
SKIP_PUBLISH=0
PRESERVE_DATABASE=0
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
PIDS=()
NAMES=()
read_local_spacetime_database() {
local config_path="${REPO_ROOT}/spacetime.local.json"
if [[ ! -f "${config_path}" ]]; then
return
fi
node -e '
const fs = require("fs");
const path = process.argv[1];
try {
const value = JSON.parse(fs.readFileSync(path, "utf8")).database;
if (typeof value === "string" && value.trim()) {
process.stdout.write(value.trim());
}
} catch (error) {
process.stderr.write(`[dev:rust] ignore invalid spacetime.local.json: ${error.message}\n`);
}
' "${config_path}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--api-host)
API_HOST="${2:?缺少 --api-host 的值}"
shift 2
;;
--api-port)
API_PORT="${2:?缺少 --api-port 的值}"
shift 2
;;
--web-host)
WEB_HOST="${2:?缺少 --web-host 的值}"
shift 2
;;
--web-port)
WEB_PORT="${2:?缺少 --web-port 的值}"
shift 2
;;
--admin-web-host)
ADMIN_WEB_HOST="${2:?缺少 --admin-web-host 的值}"
shift 2
;;
--admin-web-port)
ADMIN_WEB_PORT="${2:?缺少 --admin-web-port 的值}"
shift 2
;;
--spacetime-host)
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
shift 2
;;
--spacetime-port)
SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}"
shift 2
;;
--spacetime-root-dir)
SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}"
shift 2
;;
--database)
DATABASE="${2:?缺少 --database 的值}"
shift 2
;;
--log)
API_LOG="${2:?缺少 --log 的值}"
shift 2
;;
--spacetime-timeout-seconds)
SPACETIME_TIMEOUT_SECONDS="${2:?缺少 --spacetime-timeout-seconds 的值}"
shift 2
;;
--api-timeout-seconds)
API_SERVER_TIMEOUT_SECONDS="${2:?缺少 --api-timeout-seconds 的值}"
shift 2
;;
--skip-spacetime)
SKIP_SPACETIME=1
shift
;;
--skip-publish)
SKIP_PUBLISH=1
shift
;;
--clear-database)
PRESERVE_DATABASE=0
shift
;;
--preserve-database)
PRESERVE_DATABASE=1
shift
;;
--migration-bootstrap-secret)
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
shift 2
;;
--no-migration-bootstrap-secret)
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_MODE="disabled"
shift
;;
*)
echo "[dev:rust] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "${DATABASE//[[:space:]]/}" ]]; then
DATABASE="$(read_local_spacetime_database)"
fi
if [[ -z "${DATABASE//[[:space:]]/}" ]]; then
DATABASE="genarrative-dev"
fi
if [[ ! -f "${MANIFEST_PATH}" ]]; then
echo "[dev:rust] 未找到 ${MANIFEST_PATH},无法启动 Rust 本地栈。" >&2
exit 1
fi
if [[ ! -f "${MODULE_PATH}/Cargo.toml" ]]; then
echo "[dev:rust] 未找到 ${MODULE_PATH}/Cargo.toml无法发布 SpacetimeDB 模块。" >&2
exit 1
fi
if [[ ! -f "${VITE_CLI_PATH}" ]]; then
echo "[dev:rust] 未找到 ${VITE_CLI_PATH},无法启动 Web 前端。" >&2
exit 1
fi
if [[ ! -f "${ADMIN_WEB_DIR}/package.json" ]]; then
echo "[dev:rust] 未找到 ${ADMIN_WEB_DIR}/package.json无法启动后台前端。" >&2
exit 1
fi
require_command cargo
require_command node
if [[ "${SKIP_SPACETIME}" -ne 1 || "${SKIP_PUBLISH}" -ne 1 ]]; then
require_command spacetime
fi
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
ADMIN_WEB_TARGET_HOST="$(resolve_client_host "${ADMIN_WEB_HOST}")"
trap cleanup EXIT INT TERM
echo "[dev:rust] repo: ${REPO_ROOT}"
echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}"
echo "[dev:rust] admin web: http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}"
echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}"
echo "[dev:rust] spacetime: ${SPACETIME_SERVER}"
echo "[dev:rust] database: ${DATABASE}"
echo "[dev:rust] spacetime root: ${SPACETIME_ROOT_DIR}"
echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s"
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
mkdir -p "${SPACETIME_ROOT_DIR}"
sync_local_spacetime_install "${SPACETIME_ROOT_DIR}"
if is_spacetime_ready "${SPACETIME_SERVER}" "${SPACETIME_ROOT_DIR}"; then
echo "[dev:rust] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER}"
else
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_ROOT_DIR}")"
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
echo "[dev:rust] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
echo "[dev:rust] 目标地址未就绪: ${SPACETIME_SERVER}" >&2
echo "[dev:rust] 如需复用,请传入占用实例实际端口,例如 --spacetime-port 3199如需重启请先停止下列进程。" >&2
echo "${SPACETIME_ROOT_OWNER}" >&2
exit 1
fi
echo "[dev:rust] 启动 spacetimedb"
(
cd "${SERVER_RS_DIR}"
exec spacetime \
--root-dir="${SPACETIME_ROOT_DIR}" \
start \
--edition standalone \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
) &
PIDS+=("$!")
NAMES+=("spacetimedb")
fi
fi
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
echo "[dev:rust] 等待 SpacetimeDB 就绪"
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${SPACETIME_ROOT_DIR}" "${PIDS[0]:-}"
prepare_migration_bootstrap_secret
PUBLISH_ARGS=(
publish
"${DATABASE}"
--server "${SPACETIME_SERVER}"
--module-path "${MODULE_PATH}"
)
if [[ "${PRESERVE_DATABASE}" -ne 1 ]]; then
PUBLISH_ARGS+=(-c=on-conflict)
fi
PUBLISH_ARGS+=(--yes)
echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}"
spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}"
fi
echo "[dev:rust] 启动 api-server"
load_api_server_env_files
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
(
cd "${REPO_ROOT}"
GENARRATIVE_API_HOST="${API_HOST}" \
GENARRATIVE_API_PORT="${API_PORT}" \
GENARRATIVE_API_LOG="${API_LOG}" \
GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
) &
API_PID="$!"
PIDS+=("${API_PID}")
NAMES+=("api-server")
echo "[dev:rust] 等待 api-server 就绪"
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${API_SERVER_TIMEOUT_SECONDS}" "${API_PID}"
echo "[dev:rust] 启动 vite"
(
cd "${REPO_ROOT}"
RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \
ADMIN_WEB_TARGET="http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" \
ADMIN_WEB_PORT="${ADMIN_WEB_PORT}" \
VITE_DEV_HOST="${WEB_HOST}" \
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}"
) &
PIDS+=("$!")
NAMES+=("vite")
echo "[dev:rust] 启动 admin vite"
(
cd "${ADMIN_WEB_DIR}"
ADMIN_API_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_API_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_API_PORT="${API_PORT}" \
exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}"
) &
PIDS+=("$!")
NAMES+=("admin-vite")
echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。"
set +e
wait -n "${PIDS[@]}"
EXIT_CODE="$?"
set -e
echo "[dev:rust] 子进程已退出,开始回收本地 Rust 栈,退出码: ${EXIT_CODE}"
exit "${EXIT_CODE}"