Merge remote-tracking branch 'origin/fix/ci-master-red' into fix/home-project-naming-async
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m35s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m51s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / Backend tests (pull_request) Successful in 6m24s
Project CI / Native shell tests (pull_request) Successful in 7m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m56s
Project CI / Repository checks (pull_request) Successful in 6m15s

This commit is contained in:
2026-10-04 03:39:42 +08:00
19 changed files with 540 additions and 51 deletions
@@ -28,7 +28,22 @@ pub(crate) const DIRECT_ACTIVE_TURNS_CHANGED_EVENT: &str =
"game-creator-direct-active-turns-changed";
static DIRECT_ACTIVE_TURNS_EVENT_REVISION: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: AtomicU64 = AtomicU64::new(0);
thread_local! {
/// 只统计**当前线程**发出的通知。`--test-threads=1` 只串行测试线程,宿主
/// `tauri::async_runtime` 的后台回合仍在自己的工作线程上跑(放行任务随
/// `TurnReservation::drop` 起整轮),并会在任意时刻广播「运行中的项目」变了。断言要观测的
/// 是本测试自己触发的通知,不该被别的后台广播串台。
///
/// 这里必须留在测试线程作用域内:退役 `runtime_driver` 时这条口径曾被降级回进程级
/// `AtomicU64`,于是 `agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change`
/// 又回到偶发(同一片内前一个用例留下的后台回合广播进采样窗口)。
///
/// 代价:计数改成线程作用域后,**别的线程**上的重复 / 丢失通知不再被这条用例覆盖(那是
/// 宿主后台回合的行为,本身就不该由单线程断言口径表达);要覆盖跨线程序,应另加用例
/// 观察回合身份/序列号,而不是把计数器退回进程级。
static DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT: std::cell::Cell<u64> =
const { std::cell::Cell::new(0) };
}
const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024;
const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16;
@@ -54,7 +69,7 @@ pub(crate) fn set_game_creator_agent_runtime_update_app_handle(app: tauri::AppHa
pub(crate) fn emit_direct_active_turns_changed() {
let revision = DIRECT_ACTIVE_TURNS_EVENT_REVISION.fetch_add(1, Ordering::AcqRel) + 1;
#[cfg(test)]
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.fetch_add(1, Ordering::AcqRel);
let _ = DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.try_with(|count| count.set(count.get() + 1));
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
@@ -66,7 +81,7 @@ pub(crate) fn emit_direct_active_turns_changed() {
#[cfg(test)]
pub(crate) fn direct_active_turns_event_test_count() -> u64 {
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.load(Ordering::Acquire)
DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT.with(std::cell::Cell::get)
}
pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, message: &str) {
@@ -630,12 +630,20 @@ fn process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup() {
let root = directory.path();
init_local_game_project_at(root, "graceful-process-project", "Graceful Process Project")
.expect("initialize project");
// leader 输出 READY 后 **等** 同组后台子进程,不自己先退出。terminate 必须先送到 trampoline
// 的 target group、再让组内后代把延迟清理跑完,这条断言才有确定性:leader 一旦先自然退出,
// trampoline 的 800ms 宽限就开始计时,客户端只要在那之后才发出 terminate(CI 高负载下调度
// 完全可能 >800ms),会话就会先以 `exited` 收口,客户端再 terminate 只能读到既成事实。
// 后台子进程仍留在同一进程组里、仍靠 TERM 触发延迟 400ms 的 marker 清理,语义不变。
// 注意:这条用例的确定性靠的是「trap 里 400ms 清理 < 800ms 宽限」的时间余量(实测 0.41s),
// 不是真正的同步原语;若以后清理耗时逼近或超过宽限,应把 marker 拆成「收到 TERM 即时落盘 +
// 延迟内容」两步,或让宽限可注入,而不是放宽断言。
let spec = resolve_project_command_spec_at(
root,
"bash",
&[
"-lc".to_string(),
"(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; exit 0".to_string(),
"(trap 'sleep 0.4; printf done > graceful-marker.txt; exit 0' TERM; while :; do sleep 1; done) & printf 'READY\\n'; wait".to_string(),
],
".",
30,
+6 -1
View File
@@ -157,7 +157,12 @@ http {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
try_files $uri /index.html =404;
}
# 收银台深链 `/pay/<checkoutToken>`:只放行裸前缀会让真实收银台链接落到默认 location 变 404。
location ~* "^/pay/[^/]+/?$" {
try_files $uri /index.html =404;
}
# END GENARRATIVE MAIN SPA ROUTES
+1 -1
View File
@@ -107,4 +107,4 @@ curl -sSI -H 'Accept-Encoding: br' \
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)。历史上的独立来源模板与专属门禁已随同源方案上线删除。
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
+13 -1
View File
@@ -206,7 +206,19 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
return 503;
}
try_files $uri /index.html =404;
}
# 收银台深链 `/pay/<checkoutToken>`:token 由前端从最后一个路径段读取(payment.rs 生成该链接),
# 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。
location ~* "^/pay/[^/]+/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
+13 -1
View File
@@ -234,7 +234,19 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:creation|editor/canvas|profile|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
return 503;
}
try_files $uri /index.html =404;
}
# 收银台深链 `/pay/<checkoutToken>`:token 由前端从最后一个路径段读取(payment.rs 生成该链接),
# 只放行裸前缀会让真实收银台链接落到默认 location 变 404;这里只放行「/pay/ + 恰好一个路径段」。
location ~* "^/pay/[^/]+/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
@@ -311,6 +311,20 @@
},
"docs": ["主站 SPA allowlist", "失败回退 `/index.html`"]
},
{
"id": "pay_checkout_spa_fallback",
"samplePath": "/pay/checkout-token",
"expect": {
"kind": "static",
"root": "web",
"mode": "spa_fallback"
},
"nginx": {
"production": ["location ~* \"^/pay/[^/]+/?$\""],
"development": ["location ~* \"^/pay/[^/]+/?$\""]
},
"docs": ["收银台深链 `/pay/<checkoutToken>`", "前缀 + 恰好一个路径段"]
},
{
"id": "games_spa_fallback",
"samplePath": "/games/detail",
+27 -1
View File
@@ -4163,7 +4163,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 现象:target 注册了 SIGTERM 清理逻辑,但 `command.terminate` 只偶尔出现 stopped marker;耗时 300-500ms 的清理经常被提前截断。
- 原因:如果先向 wrapper/bwrap/trampoline/target 共用的外层进程组发送 SIGTERM,wrapper 会先退出,bwrap 的 die-with-parent 随即收走 namespace;名义上的 800ms 宽限并没有真正留给 target。
- 处理:process-session target 在 child pre-exec 内暂时屏蔽 SIGTTOU,完成 setpgid + PTY slave tcsetpgrp 并恢复信号掩码后才 exec;不能先 spawn 到后台组再由 parent 设前台,否则 target 可能已经因 immediate read 收到 SIGTTIN。Runtime 通过两级私有控制通道请求 trampoline 只向 target group 发 SIGTERM。direct leader 退出后 trampoline 继续检查同组后代,外层 wrapper/bwrap 在最多 800ms 宽限期保持存活,超时才强杀 containment group。reader 发现未换行输出超过上限时必须先原子投影 `output-limit-exceeded` 并唤醒 poll,再异步发送终止控制,不能让高负载下的 supervisor 调度延迟把已越界进程继续暴露为 `running`。
- 验证:使用直接 bash target 启动同组后台子进程;leader 在输出 READY 后自然退出,仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。
- 验证:使用直接 bash target 启动同组后台子进程;leader 打印 READY 后用 `wait` 保持存活直到 terminate 真正到达(leader 若自己先退出,客户端调度就被拖进 800ms 宽限窗口,见 2026-10-04「graceful terminate 断言」条),仍存活的子进程收到 TERM 后由 trap 延迟 400ms 写 marker 并退出,terminate 返回前 marker 必须存在。正式 `command.exec` 测试夹具仍必须走允许的 `npm run` 等程序,不能为了构造 stdin race 绕过白名单直接解析 `bash -lc`。另跑 immediate stdin/EOF、Runner owner SIGKILL 和后代隔离用例,确认前台切组没有破坏交互或 fail-closed 回收;测试互斥锁在前序 panic 后应恢复 guard 继续报告后续独立结果,不能用 `PoisonError` 掩盖真实失败范围。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/process_session.rs`、`process_session_bridge.rs`、`command_sandbox_trampoline.rs`。
## 启动记录必须封闭状态组合,child 不能自行猜 durable commit 超时
@@ -6316,3 +6316,29 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现行口径**:见 `development-workflow.md` 的「AGC 测试类型门禁」;tests 必须 0 error,不引入基线或豁免,只改类型层。
- **环境提示**:Node 24+ 默认启用实验性 Web Storage,全局 `localStorage` 未配置即 `undefined`,会顶掉 vitest 0.34 jsdom 环境里的 Storage,`recentProjectsHook.test.tsx`、`gameDistributionPublish.test.ts` 在 Node 26 上失败(HEAD 即如此)。项目按 `@types/node ^22.14` 面向 Node 22,本机用 fnm 装 v22.23.3 并设为 default(`~/.configure/profile.d/fnm.sh` 在 shell 启动时 `eval "$(fnm env)"`),Node 22 不暴露该全局、jsdom 的 localStorage 正常,仓库无需任何改动。不要用 `--localstorage-file=…` 绕:那只是把 Node 自己的文件型 Storage 顶上来,多个用例文件共享同一份状态。
- **关联**:`apps/ai-game-creator-shell/tsconfig.tests.json`、`apps/ai-game-creator-shell/package.json`、`apps/ai-game-creator-shell/tests/`。
## 2026-10-04 tracing 的 callsite interest 是进程级缓存:并发测试会把 span 调用点缓存成 never,span 看起来"根本没产生"
- **现象**:`app::tests::http_tracing::unavailable_router_rejection_keeps_generated_context_and_headers` 偶发 `each rejected request should have one HTTP span left: 0 / right: 1`(`server-rs/crates/api-server/src/app.rs`),同一族断言在 `server-rs/crates/platform-llm/src/observability_tests.rs` 偶发 `provider_spans.len() == 1` 失败。同一批代码时而绿时而红,且失败用例都是最早跑的一批。
- **原因**:`span!`/`info_span!` 宏在调用点缓存 interest 为 `never` 时**静默返回空 span**,连 `new_span` 都不会调用(tracing 0.1.44 `macros.rs` 的 `span!` 分支)。而 `DefaultCallsite` 的 interest **只在调用点首次被命中时算一次**,且计算时用 `DISPATCHERS.rebuilder()`——进程里只注册过一个 dispatcher 时它会退化成 `dispatcher::get_default()`,即**命中线程自己的 dispatcher**(tracing-core 0.1.36 `callsite.rs` 的 `Rebuilder::JustOne`)。libtest 默认并发跑同一二进制里的上千个用例,没有 subscriber 的测试线程一旦抢到 `http.request` / `llm.request` 调用点的首次注册,就会把它永久缓存成 `never`。`with_subscriber` 只在**每次 poll** 设线程本地 dispatcher,纠正不了这个进程级缓存,于是"span 没产生"。
- **处理(现行口径)**:测试采集不要依赖 `with_subscriber`。改为在整个被测流程期间持有 scoped default(`tracing::subscriber::set_default`,其内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并用同一调用点预热探测到连续两轮采集成功为止;测试 subscriber 显式实现 `register_callsite`(目标 span 恒 `always`、其余 `sometimes`),避免自己的重建把其它调用点永久标记成 `never`。**不要**把断言改成"允许 0 个 span",**不要** sleep 赌时序。
- **验证**:`cargo test --locked -p api-server --bin api-server app::tests::http_tracing`(默认并发与 `--test-threads=1` 各连跑 20 次)、`cargo test -p platform-llm observability_tests`;更接近 CI 并发的是整段 `app::tests::`(91 用例同进程)与 `--skip bgfilter_worker --skip wallet_refund_outbox` 的全量 bin(1133 用例)连跑。
- **关联**:`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/platform-llm/src/observability_tests.rs`。
## 2026-10-04 AGC 通知计数与 graceful terminate 的断言偶发都来自"跨线程 / 跨用例串台"
- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 偶发 `left: 8 / right: 7`(进度内容变化必须通知一次);`process_session::tests::process_session_graceful_terminate_keeps_wrapper_alive_for_target_cleanup` 偶发 `left: "exited" / right: "terminated"`;两者都在 `AI game creator shell Rust lane 2/2` 分片里红。
- **原因 1(通知计数串台)**:测试计数器 `DIRECT_ACTIVE_TURNS_EVENT_TEST_COUNT` 在 *2026-10-01 已按线程作用域隔离*(`thread_local! Cell`),但 2026-10-02 退役 `runtime_driver` 把这段接缝搬进 `agent/direct_events.rs` 时**降级回进程级 `static AtomicU64`**。`--test-threads=1` 只串行测试线程,宿主 `tauri::async_runtime` 的后台回合仍在自己的工作线程上广播「运行中的项目」变了,于是断言取到别的回合的广播。
- **原因 2(terminate 竞速)**:测试命令里 leader 打印 READY 后立刻 `exit 0`,同组后代仍存活,trampoline 从 leader 被回收那一刻开始 `PROCESS_SESSION_TARGET_TERMINATE_GRACE_MS=800ms` 宽限;客户端只要在 leader 退出后 >800ms 才发出 terminate(CI 高负载下要跨 durable record 写盘、registry 注册、线程 spawn),会话已按 `exited` 收口,terminate 只能读到既成事实——不是产品缺陷,是测试赌了客户端调度。
- **处理(现行口径)**:①测试专用的通知计数必须留在测试线程作用域(`thread_local! Cell`),不要用进程级 Atomic;②graceful terminate 用例的 leader 打印 READY 后要用 `wait` 等后台子进程,让 terminate 必然落在会话仍 running 时(断言、trap、`sleep 0.4`、marker 名字都不改)。
- **验证**:①修复前把计数器临时改回 Atomic 时同一并行口径 42/50 红;修复后并行 50 次 0 红、`--test-threads=1` 200 次 0 红、CI 现场等价块(145 用例)3 次 0 红;②该用例是 `#[cfg(target_os = "linux")]`,Windows 本机跑不到,用真实 Linux 内核(WSL Alpine)验证命令形状:leader 活到 TERM、同组后代完成 400ms 延迟清理(marker=done,real 0.41s)、清理后组内零残留;CI 侧仍应跑 `node apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=4` 复核。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs`、`apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs`、`command_sandbox_trampoline.rs`。
## 2026-10-04 SPA 深链的前缀路由(`/pay/<checkoutToken>`)必须进 allowlist,裸前缀不够
- **现象**:只把 `/pay`、`/profile/payment` 加进 Nginx SPA allowlist 让门禁变绿,并不代表真实收银台链接能打开。`payment.rs` 生成的 `checkoutUrl` 是 `/pay/<checkoutToken>`,三份模板原先只有 `location ~* "^/(?:…|pay|profile|profile/payment|…)/?$"` 这条精确 location,深链落回默认 `location /` 的 `try_files $uri $uri/ =404` → **404**。
- **原因/代价**:前端 `resolveSelectionStageFromPath` 用 `startsWith('/pay/')` 判定并取最后一个路径段当 token,Nginx / Pingora 侧却只放行裸前缀(2026-10-03 的支付接入 commit 只改了前端路由源)。同一批漂移里还有一条被掩盖的失败:`check:nginx-spa-routes` 在 `npm run lint` 链里先跑,它红的时候看不到后面的 `check:pingora-route-parity` 也红(Pingora `MAIN_SPA_PATHS` 缺 `/pay`、`/profile/payment`)——修一条门禁时要把整条链跑到底,不要只看第一个红。
- **处理(现行口径)**:前缀路由的真相源是 `src/routing/activeAppPageRoutes.ts` 的 `APP_PREFIX_ROUTE_ENTRIES`。`scripts/check-nginx-spa-routes.mjs` 据此要求三份模板都写锚定前缀 location(`location ~* "^/pay/[^/]+/?$"`,只放行「前缀 + 恰好一个路径段」,裸前缀仍由精确 location 负责,并要求镜像精确 location 的维护闸);`check:pingora-route-parity` 要求 Rust 的 `MAIN_SPA_PREFIX_PATHS` 与 `is_main_spa_prefix_path` 同口径(大小写不敏感、多段与 `/payment/x` 这类同名邻居不收)。
- **别踩**:不要写成裸前缀正则(`^/pay`)——它会吞掉 `/payment/x`、`/paycheckout/x` 这类同名邻居;也不要把深链塞进精确 allowlist 的 alternatives 里(`pay` 的 alternatives 只匹配 `/pay`)。
- **判据/取证**:`node --test scripts/check-nginx-spa-routes.test.mjs`(正/反用例,含「写回精确匹配即红」)、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway -- pay_checkout_deep_link matches_nginx_route_parity_matrix`;线上复验 `curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` → 200 且正文与 `/` 同一份 `index.html`。
- **关联**:`scripts/check-nginx-spa-routes.mjs`、`deploy/pingora/nginx-route-parity.matrix.json`、`server-rs/crates/pingora-gateway/src/main.rs`、`server-rs/crates/api-server/src/payment.rs`、`deploy/nginx/genarrative.conf`。
@@ -63,7 +63,7 @@ npm run check:pingora-release-readiness
`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。
`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` 与 `APP_PREFIX_ROUTE_ENTRIES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠、前缀路由的「前缀 + 恰好一个路径段」锚定形状(收银台深链 `/pay/<checkoutToken>` 必须整体回退 `index.html`,只放行裸前缀会让真实链接落到默认 location 变 404)和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。该脚本自带正/反用例(`node --test scripts/check-nginx-spa-routes.test.mjs`,由 `npm run check:nginx-spa-routes` 一起执行),防止有人把前缀路由改回精确匹配。
`check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由,并做**反向覆盖**(模板里的每条 `location` 都必须被矩阵声明)。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。`check:nginx-spa-routes` 与 `check:pingora-route-parity` 已串进 `npm run lint`(因此 `check:repository-ci`、CI 与 pre-push 都会执行),接线本身由 `check:production-ops` 的 guardrail 锁定。
@@ -535,7 +535,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
| `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 |
| `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 |
| `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 |
| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/profile`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 |
| 主站 SPA allowlist | 只对 `/`、`/components`、`/creation`、`/design-system`、`/editor/canvas`、`/games`、`/games/detail`、`/games/mine`、`/games/play`、`/games/publish`、`/pay`、`/profile`、`/profile/payment`、`/project` 失败回退 `/index.html`(集合与前端路由源、Nginx 三份模板逐条一致,由 `npm run check:pingora-route-parity` 与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 比对);匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。`/games/game_<32 位十六进制 id>/…` 是发行网关路由,不在 SPA allowlist 内。 |
| 主站 SPA 前缀路由 | 收银台深链 `/pay/<checkoutToken>` 走 `MAIN_SPA_PREFIX_PATHS`:只放行「前缀 + 恰好一个路径段」(大小写不敏感),裸前缀由上面的精确集合负责,多段路径与 `/payment/x` 这类前缀同名邻居都不进 SPA fallback;Nginx 三份模板同口径写成 `location ~* "^/pay/[^/]+/?$"`,由矩阵的 `pay_checkout_spa_fallback` 用例固定。 |
| 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 |
SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游玩 / 我的 / 发布深链:`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`)与 Nginx 三份模板同口径;Pingora 侧由路由对照矩阵的 `games_spa_fallback` 用例与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 逐条断言。根路径 `/` 精确回退 `/index.html`(Nginx 在 `location = /` 里用 `try_files /index.html =404;`,不带 `$uri`),由矩阵的 `web_root_spa` 用例固定。发行网关路径 `/games/game_<32 位十六进制 id>/…` 不走 SPA,见下一节的对照说明。
@@ -748,10 +748,12 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- 门禁:
```bash
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由)
# SPA 白名单 + 三份 nginx 模板一致性(含 games 系列路由与收银台深链前缀路由)
npm run check:nginx-spa-routes
```
线上/预发复验收银台深链时,除了 `npm run check:nginx-spa-routes`,还要用真实请求确认 `/pay/<checkoutToken>` 返回 SPA 外壳而不是 404(`curl -s -o /dev/null -w '%{http_code}' https://<平台域名>/pay/<checkoutToken>` 应为 200,正文与 `/` 同一份 `index.html`)。`payment.rs` 生成的 `checkoutUrl` 就是这个路径,只把 `/pay` 加进 allowlist 会让真实链接落到默认 location 变 404。
本地想在真实边缘语义下复验时,把 `deploy/nginx/genarrative.conf` 的证书路径与 `/var/log/nginx` 换成临时目录,用 `nginx -c <临时 wrapper>` 起一个临时实例,再用 `curl --resolve <平台域名>:443:127.0.0.1 https://<平台域名>/games/<gameId>/` 验证:入口文档 200 `text/html`、`/games/<gameId>/assets/*` 200、未知 gameId 404,平台 API 与 SPA 路由不受影响。
#### 游戏分发可观测事件
+1 -1
View File
@@ -99,7 +99,7 @@
"check:production-api-deploy": "node scripts/check-production-api-deploy.mjs",
"check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs",
"check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs",
"check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs",
"check:nginx-spa-routes": "node --test scripts/check-nginx-spa-routes.test.mjs && node scripts/check-nginx-spa-routes.mjs",
"check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs",
"check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs",
"check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs",
+148 -14
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const APP_PAGE_ROUTES_PATH = 'src/routing/activeAppPageRoutes.ts';
const APP_ROUTES_PATH = 'src/routing/activeAppRoutes.tsx';
const APP_PREFIX_ROUTE_ENTRIES_PATTERN =
/const APP_PREFIX_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u;
const COMPATIBILITY_ROUTES = [];
const NGINX_PATHS = [
'deploy/nginx/genarrative.conf',
@@ -86,7 +90,92 @@ function compareRouteSets(actualRoutes, expectedRoutes, label) {
}
}
function validateNginxRoutes(nginxPath, expectedRoutes) {
/**
* 前缀路由在 Nginx 里的锚定形状:前缀 + 恰好一个路径段 + 一个可省略的尾部斜杠。
* 裸前缀自身由 SPA allowlist 的精确 location 负责,这里不重复放行,也不放宽到多段路径。
*/
export function buildPrefixRouteNginxPattern(prefix) {
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
return `^${escapedPrefix}/[^/]+/?$`;
}
/** 校验前缀路由的放行形状;返回失败原因列表(空数组代表通过)。 */
export function collectPrefixRoutePatternFailures(pattern, prefix) {
const failures = [];
const expected = buildPrefixRouteNginxPattern(prefix);
if (pattern !== expected) {
failures.push(
`前缀路由 ${prefix} 的 Nginx 放行形状必须锚定为 ${expected}(前缀 + 恰好一个路径段 + 可省略的尾部斜杠),实际为 ${pattern}。`,
);
return failures;
}
const matcher = new RegExp(pattern, 'iu');
for (const sample of [
`${prefix}/checkout-token`,
`${prefix.toUpperCase()}/CheckOutToken/`,
]) {
if (!matcher.test(sample)) {
failures.push(`前缀路由形状 ${pattern} 未匹配深链: ${sample}`);
}
}
for (const sample of [
prefix,
`${prefix}/`,
`${prefix}/two/segments`,
`${prefix}suffix/segment`,
]) {
if (matcher.test(sample)) {
failures.push(`前缀路由形状 ${pattern} 错误接收非深链路径: ${sample}`);
}
}
return failures;
}
export function collectExpectedPrefixRoutes() {
const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8');
const entries = extractSourceBlock(
appPageRoutes,
APP_PREFIX_ROUTE_ENTRIES_PATTERN,
`${APP_PAGE_ROUTES_PATH} APP_PREFIX_ROUTE_ENTRIES`,
);
const routes = Array.from(
entries.matchAll(/\[\s*'([^']+)'\s*,\s*'([^']+)'\s*\]/gu),
(match) => ({ path: match[1], stage: match[2] }),
);
const uniqueRoutes = new Map(routes.map((route) => [route.path, route]));
for (const route of uniqueRoutes.values()) {
if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route.path)) {
fail(`前端前缀路由源包含门禁暂不支持的路径格式: ${route.path}`);
}
}
return [...uniqueRoutes.values()].sort((left, right) =>
left.path.localeCompare(right.path),
);
}
function findRegexLocationBody(block, pattern) {
for (const match of block.matchAll(/location\s+~\*\s+"([^"]+)"\s*\{/gu)) {
if (match[1] !== pattern) {
continue;
}
const openBrace = match.index + match[0].length - 1;
let depth = 0;
for (let index = openBrace; index < block.length; index += 1) {
if (block[index] === '{') {
depth += 1;
} else if (block[index] === '}') {
depth -= 1;
if (depth === 0) {
return block.slice(openBrace + 1, index);
}
}
}
return null;
}
return null;
}
function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) {
const source = readFileSync(nginxPath, 'utf8');
const blockStart = source.indexOf(SPA_BLOCK_START);
const blockEnd = source.indexOf(SPA_BLOCK_END);
@@ -140,6 +229,44 @@ function validateNginxRoutes(nginxPath, expectedRoutes) {
}
}
// 前缀路由(带动态段)必须有独立的锚定 location:只放行裸前缀会让真实深链落到默认
// location 变成 404(例如收银台 `/pay/<checkoutToken>`)。
const exactLocationBody = findRegexLocationBody(block, nginxPattern);
const maintenanceGuard = 'if ($genarrative_maintenance) { return 503; }';
for (const { path: prefix } of prefixRoutes) {
if (!expectedRoutes.includes(prefix)) {
fail(
`${nginxPath} 前缀路由 ${prefix} 必须同时是精确路由:裸前缀自身也要能直达。`,
);
continue;
}
const expectedPattern = buildPrefixRouteNginxPattern(prefix);
const prefixLocationBody = findRegexLocationBody(block, expectedPattern);
if (prefixLocationBody === null) {
fail(
`${nginxPath} 缺少前缀路由 ${prefix} 的锚定 location(期望 location ~* "${expectedPattern}")。`,
);
continue;
}
for (const failure of collectPrefixRoutePatternFailures(
expectedPattern,
prefix,
)) {
fail(`${nginxPath} ${failure}`);
}
if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) {
fail(`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`);
}
if (
exactLocationBody?.includes(maintenanceGuard) &&
!prefixLocationBody.includes(maintenanceGuard)
) {
fail(
`${nginxPath} 前缀路由 ${prefix} 的 location 必须与精确 SPA location 一样先判维护状态。`,
);
}
}
const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length);
if (!defaultLocation.includes('try_files $uri $uri/ =404;')) {
fail(
@@ -218,20 +345,27 @@ function validateMaintenanceInternalBypass() {
}
export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes();
export const expectedPrefixRoutes = collectExpectedPrefixRoutes();
for (const nginxPath of NGINX_PATHS) {
validateNginxRoutes(nginxPath, expectedMainSpaRoutes);
}
validateMaintenanceInternalBypass();
const isMainModule =
process.argv[1] &&
pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
if (failures.length > 0) {
console.error('[check:nginx-spa-routes] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
if (isMainModule) {
for (const nginxPath of NGINX_PATHS) {
validateNginxRoutes(nginxPath, expectedMainSpaRoutes, expectedPrefixRoutes);
}
process.exit(1);
}
validateMaintenanceInternalBypass();
console.log(
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`,
);
if (failures.length > 0) {
console.error('[check:nginx-spa-routes] FAILED');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log(
`[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${expectedPrefixRoutes.length} prefix routes, ${NGINX_PATHS.length} Nginx templates)`,
);
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
buildPrefixRouteNginxPattern,
collectExpectedPrefixRoutes,
collectPrefixRoutePatternFailures,
} from './check-nginx-spa-routes.mjs';
test('前缀路由按「前缀 + 恰好一个路径段 + 可省略尾部斜杠」锚定,裸前缀交给精确 location', () => {
const pattern = buildPrefixRouteNginxPattern('/pay');
assert.equal(pattern, '^/pay/[^/]+/?$');
assert.deepEqual(collectPrefixRoutePatternFailures(pattern, '/pay'), []);
const matcher = new RegExp(pattern, 'iu');
assert.ok(matcher.test('/pay/checkout-token'));
assert.ok(matcher.test('/PAY/CheckOutToken/'));
assert.ok(!matcher.test('/pay'));
assert.ok(!matcher.test('/pay/'));
assert.ok(!matcher.test('/pay/two/segments'));
assert.ok(!matcher.test('/paycheckout/token'));
assert.ok(!matcher.test('/payment/token'));
});
test('把前缀路由写回精确匹配(只放行裸前缀)会被门禁拒绝', () => {
const failures = collectPrefixRoutePatternFailures('^/pay/?$', '/pay');
assert.equal(failures.length, 1);
assert.match(failures[0], /必须锚定为 \^\/pay\/\[\^\/\]\+\/\?\$/u);
});
test('过宽的裸前缀形状(会吞掉同名邻居路径)也会被门禁拒绝', () => {
const failures = collectPrefixRoutePatternFailures('^/pay', '/pay');
assert.equal(failures.length, 1);
assert.match(failures[0], /必须锚定为/u);
});
test('前缀路由真相源来自前端路由表且当前包含 /pay', () => {
const prefixRoutes = collectExpectedPrefixRoutes();
assert.ok(
prefixRoutes.some((route) => route.path === '/pay'),
'APP_PREFIX_ROUTE_ENTRIES 必须声明 /pay',
);
for (const route of prefixRoutes) {
assert.match(route.path, /^\//u);
assert.notEqual(route.stage.trim(), '');
}
});
+28 -1
View File
@@ -2,7 +2,10 @@
import { readFileSync } from 'node:fs';
import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs';
import {
expectedMainSpaRoutes,
expectedPrefixRoutes,
} from './check-nginx-spa-routes.mjs';
const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json';
const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf';
@@ -289,10 +292,34 @@ function validateRustMainSpaRoutes() {
}
}
function validateRustMainSpaPrefixPaths() {
const prefixBlock = pingoraGatewaySource.match(
/const MAIN_SPA_PREFIX_PATHS: &\[&str\] = &\[([\s\S]*?)\];/u,
);
if (!prefixBlock) {
fail('Pingora Rust 缺少 MAIN_SPA_PREFIX_PATHS allowlist。');
return;
}
const rustPrefixes = Array.from(
prefixBlock[1].matchAll(/"([^"]+)"/gu),
(match) => match[1],
);
const expected = expectedPrefixRoutes.map((route) => route.path);
const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix));
const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix));
if (missing.length > 0) {
fail(`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`);
}
if (extra.length > 0) {
fail(`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`);
}
}
validateMatrixShape();
validateRustTestUsesMatrix();
validateNginxLocationsAreCovered();
validateRustMainSpaRoutes();
validateRustMainSpaPrefixPaths();
if (failures.length > 0) {
console.error('[check:pingora-route-parity] FAILED');
+61 -6
View File
@@ -510,11 +510,12 @@ mod tests {
use tracing::{
Metadata, Subscriber,
field::{Field, Visit},
instrument::WithSubscriber,
span::{Attributes, Id, Record},
subscriber::Interest,
};
use super::*;
use crate::app::make_http_request_span;
use crate::state::{BackpressureState, HttpRequestPermitPoolKind};
#[derive(Clone, Default)]
@@ -534,6 +535,18 @@ mod tests {
}
impl Subscriber for HttpSpanCapture {
/// tracing 的 callsite interest 是**进程级**缓存,只在调用点首次命中时计算一次。
/// 这里只为 HTTP span 声明 always:任何以本 subscriber 参与的重建都会让
/// `http.request` 调用点保持可用;其余调用点返回 sometimes,交给 `enabled`
/// 在真正创建 span 时判断,避免把其它调用点永久标记成 never。
fn register_callsite(&self, metadata: &Metadata<'_>) -> Interest {
if metadata.is_span() && metadata.name() == "http.request" {
Interest::always()
} else {
Interest::sometimes()
}
}
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.is_span() && metadata.name() == "http.request"
}
@@ -557,6 +570,41 @@ mod tests {
fn exit(&self, _: &Id) {}
}
fn http_span_warmup_request() -> Request<Body> {
Request::builder()
.uri("/__genarrative_http_span_interest_warmup__")
.body(Body::empty())
.expect("warmup request should build")
}
/// 让 `http.request` 调用点在本进程内稳定可用,而不是依赖调用点首次注册时命中线程的 dispatcher。
///
/// `DefaultCallsite::register` 只在调用点首次被命中时计算一次 interest,计算时会用
/// `DISPATCHERS.rebuilder()`;当进程里只有一个 dispatcher 时它会退化成
/// `dispatcher::get_default()`,也就是**命中线程自己的** dispatcher。并发跑测试时,
/// 没有 subscriber 的线程一旦抢到这次注册,`http.request` 调用点就会被永久缓存成
/// `never`,之后 `span!` 宏会静默跳过 span 创建,测试便会观察到 0 个 span。
///
/// 这里不 sleep 赌时序:每轮都用 `set_default` 在我们的 subscriber 下安装 scoped default
/// (内部 `Dispatch::new` 会触发 tracing 重建 interest 缓存),并直接命中同一个
/// `http.request` 调用点做探测;连续两轮都探测到 span 才认为缓存已经稳定。
fn ensure_http_request_span_interest() {
let mut captured_rounds = 0;
for _ in 0..8 {
let probe = HttpSpanCapture::default();
{
let _guard = tracing::subscriber::set_default(probe.clone());
let _ = make_http_request_span(&http_span_warmup_request());
}
let captured = !probe.0.lock().expect("span capture should lock").is_empty();
captured_rounds = if captured { captured_rounds + 1 } else { 0 };
if captured_rounds >= 2 {
return;
}
}
panic!("无法在测试 subscriber 下恢复 http.request 调用点的 interest 缓存");
}
async fn assert_rejection_observed(
app: Router,
request: Request<Body>,
@@ -567,11 +615,18 @@ mod tests {
let expected_request_id = request.headers().get("x-request-id").cloned();
let path = request.uri().path().to_string();
let capture = HttpSpanCapture::default();
let response = app
.oneshot(request)
.with_subscriber(capture.clone())
.await
.expect("rejected request should complete");
// 先把 http.request 调用点的进程级 interest 缓存校正到当前 subscriber(见上方注释),
// 再在整个请求期间持有 scoped default。注意 `set_default` 是**线程绑定**的:这里成立
// 是因为本模块用的是默认 `#[tokio::test]`(current-thread 运行时,请求与断言线程一致);
// 一旦改成 multi_thread / spawn 到别的线程,必须换成 `with_current_subscriber()` 或
// `with_subscriber`,否则请求会在没有 dispatcher 的线程上跑。
ensure_http_request_span_interest();
let response = {
let _guard = tracing::subscriber::set_default(capture.clone());
app.oneshot(request)
.await
.expect("rejected request should complete")
};
assert_eq!(response.status(), expected_status);
let request_id = response.headers()["x-request-id"]
@@ -30,7 +30,10 @@ fn prepare(
#[test]
fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() {
assert_eq!(TOOLS.len(), 15);
assert_eq!(MCP_OPERATIONS.len(), 30);
// legacy 工具集合直接来自内置 OpenAPI(排除 5 条 x-mcp-excluded 元数据/入口操作)。
// 支付收银台新增 createExternalPaymentOrder / getExternalPaymentOrder 后,
// 可调用操作由 30 增至 32:语义工具只做增量,不得替换 legacy 工具。
assert_eq!(MCP_OPERATIONS.len(), 32);
let legacy = MCP_OPERATIONS
.iter()
.map(mcp_operation_tool)
@@ -52,7 +55,9 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() {
.is_some_and(|text| !text.is_empty())
);
}
assert_eq!(names.len(), 45);
// 37 条 OpenAPI 操作里 5 条元数据/入口操作被 x-mcp-excluded 排除,剩下 32 条 legacy 工具
// 加 15 条语义工具共 47 条;上面的插入断言已保证两组名字互不覆盖,这里固定总数防止漏注册。
assert_eq!(names.len(), 47);
}
#[test]
@@ -72,10 +72,17 @@ const MAIN_SPA_PATHS: &[&str] = &[
"/games/mine",
"/games/play",
"/games/publish",
"/pay",
"/profile",
"/profile/payment",
"/project",
];
// 带动态段的前缀路由,必须与前端 `APP_PREFIX_ROUTE_ENTRIES` 以及三份 nginx 模板的
// `location ~* "^/pay/[^/]+/?$"` 同口径:只放行「前缀 + 恰好一个路径段」,裸前缀由
// `MAIN_SPA_PATHS` 负责;`npm run check:pingora-route-parity` 会逐条比对这份 allowlist。
const MAIN_SPA_PREFIX_PATHS: &[&str] = &["/pay"];
#[derive(Clone, Debug)]
struct GatewayConfig {
listen_addr: String,
@@ -1955,6 +1962,21 @@ fn is_main_spa_path(path: &str) -> bool {
MAIN_SPA_PATHS
.iter()
.any(|candidate| normalized.eq_ignore_ascii_case(candidate))
|| is_main_spa_prefix_path(normalized)
}
/// 前缀路由(带动态段):`<前缀>/<恰好一个路径段>`,大小写不敏感(与 nginx `location ~*` 同口径)。
/// 裸前缀、多段路径和前缀同名邻居(如 `/payment/x`)都不算命中。
fn is_main_spa_prefix_path(normalized: &str) -> bool {
let mut segments = normalized.trim_start_matches('/').split('/');
let (Some(first), Some(second), None) = (segments.next(), segments.next(), segments.next())
else {
return false;
};
!second.is_empty()
&& MAIN_SPA_PREFIX_PATHS
.iter()
.any(|prefix| prefix.trim_start_matches('/').eq_ignore_ascii_case(first))
}
fn is_maintenance_page_asset(path: &str) -> bool {
@@ -3310,6 +3332,36 @@ mod tests {
}
}
#[test]
fn pay_checkout_deep_link_uses_main_spa_prefix_route() {
let spa_fallback = RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
mode: StaticMode::SpaFallback,
});
// 裸前缀由 MAIN_SPA_PATHS 精确命中,收银台深链 `/pay/<checkoutToken>` 由前缀路由命中,
// 两者都与 Nginx 的 `location ~* "^/pay/[^/]+/?$"` 同口径(大小写不敏感)。
for path in [
"/pay",
"/pay/",
"/PAY",
"/pay/checkout-token",
"/PAY/CheckOutToken/",
] {
assert_eq!(classify_path(path), spa_fallback, "path: {path}");
}
// 多段路径与前缀同名邻居不允许被吞进 SPA fallback。
for path in ["/pay/two/segments", "/payment/token", "/paycheckout/token"] {
assert_eq!(
classify_path(path),
RouteDecision::Local(LocalResponse::Static {
root: StaticRoot::Web,
mode: StaticMode::Exact,
}),
"path: {path}"
);
}
}
#[test]
fn applies_configured_body_limit_to_generic_api_routes_only() {
let mut generic_api = classify_path("/api/assets/history");
@@ -11,7 +11,6 @@ use tokio::sync::oneshot;
use tracing::{
Instrument, Subscriber,
field::{Field, Visit},
instrument::WithSubscriber,
span::{Attributes, Id, Record},
};
use tracing_subscriber::{Layer, layer::Context, prelude::*, registry::LookupSpan};
@@ -222,8 +221,70 @@ fn request() -> LlmRunRequest {
.with_model("requested-model")
}
/// 在整段流程期间把 capture 固定为当前 dispatcher。
///
/// tracing 的 callsite interest 是**进程级**缓存,且只在调用点首次被命中时计算一次
/// (`DefaultCallsite::register` → `DISPATCHERS.rebuilder()`;进程里只有一个 dispatcher 时会
/// 退化成命中线程自己的 dispatcher)。并发跑测试时,本文件之外那些没有 subscriber 的测试线程
/// 只要抢到 `llm.request` 调用点的首次注册,它就会被永久缓存成 `never`,`span!` 宏随后静默
/// 跳过 span 创建,`provider_span` 便会看到 0 个 span。
///
/// 注意:`set_default` 与 `with_subscriber` 在 interest 缓存这件事上**等价**——两者都只是新建一个
/// `Dispatch` 并触发一次 `rebuild_interest`,只能纠正“已经注册过”的调用点,纠正不了
/// `Rebuilder::JustOne → get_default() → 缓存 never` 这条首次注册分支;真正把调用点救回来的是
/// `warm_up_provider_span_callsite` 在自家 subscriber 下命中同一调用点(覆盖两种顺序,
/// 并且把 dispatcher 从“只在每次 poll 生效”变成整段流程生效)。
async fn run_under_capture<F: Future>(capture: &Capture, future: F) -> F::Output {
// `set_default` 是线程绑定的:本文件用的是默认 `#[tokio::test]`(current-thread 运行时,
// 被测 future 与断言在同一条线程上)。若以后改成 multi_thread 或把流程 spawn 出去,
// 必须换回 `with_current_subscriber()` / `with_subscriber`,否则 span 会在没有 dispatcher 的线程上丢。
let _guard =
tracing::subscriber::set_default(tracing_subscriber::registry().with(capture.clone()));
future.await
}
/// 让 `llm.request` 调用点在本进程内稳定可用:用一个必然失败的最小请求(指向刚释放的
/// loopback 端口,连接直接被拒绝)命中同一个调用点,直到连续两轮都能采集到 span 为止。
/// 请求失败无妨——只要 `llm.request` span 被创建就说明调用点在我们的 subscriber 下注册成功。
/// 不 sleep、不放宽断言。
async fn warm_up_provider_span_callsite() {
let listener = TcpListener::bind("127.0.0.1:0").expect("warmup listener should bind");
let address = listener
.local_addr()
.expect("warmup address should resolve");
drop(listener);
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
format!("http://{address}"),
PRIVATE_KEY.into(),
"default-model".into(),
500,
0,
1,
)
.expect("warmup config should build");
let client = LlmClient::new(config).expect("warmup client should build");
let mut captured_rounds = 0;
for _ in 0..8 {
let probe = Capture::default();
{
let _guard = tracing::subscriber::set_default(
tracing_subscriber::registry().with(probe.clone()),
);
let _ = client.run(request()).await;
}
let captured = !probe.spans.lock().expect("capture should lock").is_empty();
captured_rounds = if captured { captured_rounds + 1 } else { 0 };
if captured_rounds >= 2 {
return;
}
}
panic!("无法在测试 subscriber 下恢复 llm.request 调用点的 interest 缓存");
}
#[tokio::test]
async fn provider_span_covers_awaited_execution_and_keeps_parent_without_arguments() {
warm_up_provider_span_callsite().await;
let fixture = provider_fixture(
"200 OK",
"application/json",
@@ -231,7 +292,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen
);
let capture = Capture::default();
let response =
async {
run_under_capture(&capture, async {
async {
let mut future = Box::pin(fixture.client.run(request()));
assert!(capture.spans.lock().unwrap().values().all(|span| span.name != "llm.request"));
@@ -245,8 +306,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen
fixture.release.send(()).unwrap();
future.await.unwrap()
}.instrument(tracing::info_span!("test.request")).await
}
.with_subscriber(tracing_subscriber::registry().with(capture.clone()))
})
.await;
fixture.server.join().unwrap();
assert_eq!(response.text, "completed");
@@ -255,6 +315,7 @@ async fn provider_span_covers_awaited_execution_and_keeps_parent_without_argumen
#[tokio::test]
async fn stream_callbacks_inherit_provider_span_and_keep_result() {
warm_up_provider_span_callsite().await;
let fixture = provider_fixture(
"200 OK",
"text/event-stream",
@@ -263,7 +324,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() {
let capture = Capture::default();
let mut deltas = Vec::new();
let response =
async {
run_under_capture(&capture, async {
async {
let mut future = Box::pin(fixture.client.stream_run(request(), |delta| {
tracing::info!(target: "llm_observability_test_delta", "delta received");
@@ -276,8 +337,7 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() {
fixture.release.send(()).unwrap();
future.await.unwrap()
}.instrument(tracing::info_span!("test.request")).await
}
.with_subscriber(tracing_subscriber::registry().with(capture.clone()))
})
.await;
fixture.server.join().unwrap();
assert_eq!(response.text, "hello");
@@ -291,21 +351,21 @@ async fn stream_callbacks_inherit_provider_span_and_keep_result() {
#[tokio::test]
async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() {
warm_up_provider_span_callsite().await;
let fixture = provider_fixture(
"401 Unauthorized",
"application/json",
r#"{"error":{"message":"upstream-rejected"}}"#,
);
let capture = Capture::default();
let error = async {
let error = run_under_capture(&capture, async {
async {
fixture.release.send(()).unwrap();
fixture.client.run(request()).await.unwrap_err()
}
.instrument(tracing::info_span!("test.request"))
.await
}
.with_subscriber(tracing_subscriber::registry().with(capture.clone()))
})
.await;
fixture.server.join().unwrap();
assert!(
@@ -315,7 +375,7 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() {
let fixture = provider_fixture("200 OK", "application/json", "{}");
let capture = Capture::default();
async {
run_under_capture(&capture, async {
async {
let mut future = Box::pin(fixture.client.run(request()));
tokio::select! {
@@ -326,7 +386,8 @@ async fn provider_span_preserves_upstream_errors_and_closes_on_cancellation() {
drop(future);
fixture.release.send(()).unwrap();
}.instrument(tracing::info_span!("test.request")).await
}.with_subscriber(tracing_subscriber::registry().with(capture.clone())).await;
})
.await;
fixture.server.join().unwrap();
capture.assert_completed("run");
}
+16 -3
View File
@@ -18,6 +18,16 @@ const STAGE_ROUTE_ENTRIES = [
['game-publish', '/games/publish'],
] as const satisfies readonly (readonly [SelectionStage, string])[];
/**
* 带动态段、需要整体回退 index.html 的对外路由前缀 → 归属 stage。
* 例如收银台深链 `/pay/<checkoutToken>`(后端 `payment.rs` 用 `format!("/pay/{}", token)` 生成,
* 前端从最后一个路径段读 token)。Nginx 必须按「前缀 + 恰好一个路径段」的锚定形状放行:
* 只放行裸前缀会让真实收银台链接落到默认 location 变成 404,放行过宽又会把未知路径吞掉。
*/
export const APP_PREFIX_ROUTE_ENTRIES = [
['/pay', 'payment-checkout'],
] as const satisfies readonly (readonly [string, SelectionStage])[];
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
Object.fromEntries(STAGE_ROUTE_ENTRIES) as Record<SelectionStage, string>;
@@ -41,10 +51,13 @@ export function normalizeAppPath(pathname: string) {
export function resolveSelectionStageFromPath(
pathname: string,
): SelectionStage {
if (normalizeAppPath(pathname).startsWith('/pay/')) {
return 'payment-checkout';
const normalizedPath = normalizeAppPath(pathname);
for (const [prefix, stage] of APP_PREFIX_ROUTE_ENTRIES) {
if (normalizedPath.startsWith(`${prefix}/`)) {
return stage;
}
}
return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform';
return ROUTE_STAGE_BY_PATH.get(normalizedPath) ?? 'platform';
}
export function resolveInitialSelectionStageFromPath(