From ade2001dd032309b143b420864cdd4a387d93015 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Thu, 3 Sep 2026 04:27:10 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=81=AF=E7=9E=AC=E6=97=B6=E5=8F=98=E9=BB=84?= =?UTF-8?q?=E5=92=8C=E6=A1=A5=E6=8E=A5=E9=87=8D=E5=A4=8D=E9=87=8D=E6=B3=A8?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题现象:桥接健康检查与后端状态轮询并发请求,短暂失败会误报后端异常并触发重复注入,导致状态灯偶尔由绿色变黄色。 解决方案:健康检查仅确认桥接存在;前端增加请求防重入、超时和连续失败阈值;watchdog 连续失败后才重注入,并补充回归测试。 --- assets/inject/renderer-inject.js | 88 +++++++++++++--------- crates/codex-plus-core/src/bridge.rs | 12 +-- crates/codex-plus-core/src/launcher.rs | 22 +++++- crates/codex-plus-core/tests/cdp_bridge.rs | 10 ++- 4 files changed, 80 insertions(+), 52 deletions(-) diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 75d2c8772..6d5419485 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -3877,6 +3877,9 @@ let codexPlusUserScripts = { enabled: true, builtin_dir: "", user_dir: "", scripts: [] }; let codexPlusBackendStatus = { status: "checking", message: "正在检查后端…" }; let codexPlusBackendCheckSeq = 0; + let codexPlusBackendCheckInFlight = false; + let codexPlusBackendFailureCount = 0; + const CODEX_PLUS_BACKEND_FAILURE_THRESHOLD = 3; function renderBackendStatus() { const status = codexPlusBackendStatus.status || "failed"; @@ -3911,22 +3914,35 @@ } async function checkBackendStatus() { + if (codexPlusBackendCheckInFlight) return; + codexPlusBackendCheckInFlight = true; const seq = ++codexPlusBackendCheckSeq; - const nextStatus = await withBackendTimeout(postJson("/backend/status", {})); - if (seq !== codexPlusBackendCheckSeq) return; - codexPlusBackendStatus = nextStatus; - if (nextStatus?.status === "ok" && typeof nextStatus.hideOfficialUsageAlert === "boolean") { - window.__CODEX_PLUS_HIDE_OFFICIAL_USAGE_ALERT__ = nextStatus.hideOfficialUsageAlert; - refreshOfficialUsageAlertVisibility(); - } - if (nextStatus?.status !== "ok") { - sendCodexPlusDiagnostic("backend_check_failed", { - status: nextStatus?.status || "unknown", - message: nextStatus?.message || "", - timeout: !!nextStatus?.timeout, - }); + try { + const nextStatus = await withBackendTimeout(postJson("/backend/status", {})); + if (seq !== codexPlusBackendCheckSeq) return; + if (nextStatus?.status === "ok") { + codexPlusBackendFailureCount = 0; + codexPlusBackendStatus = nextStatus; + if (typeof nextStatus.hideOfficialUsageAlert === "boolean") { + window.__CODEX_PLUS_HIDE_OFFICIAL_USAGE_ALERT__ = nextStatus.hideOfficialUsageAlert; + refreshOfficialUsageAlertVisibility(); + } + } else { + codexPlusBackendFailureCount += 1; + sendCodexPlusDiagnostic("backend_check_failed", { + status: nextStatus?.status || "unknown", + message: nextStatus?.message || "", + timeout: !!nextStatus?.timeout, + consecutiveFailures: codexPlusBackendFailureCount, + }); + if (codexPlusBackendFailureCount >= CODEX_PLUS_BACKEND_FAILURE_THRESHOLD) { + codexPlusBackendStatus = nextStatus; + } + } + renderBackendStatus(); + } finally { + codexPlusBackendCheckInFlight = false; } - renderBackendStatus(); } async function openManagerFromCodex() { @@ -6223,18 +6239,30 @@ } async function postJson(path, payload) { + async function fetchBackendStatusFromHelper(path, payload) { + const controller = typeof AbortController === "function" ? new AbortController() : null; + const timeoutId = setTimeout(() => controller?.abort(), 2000); + try { + const response = await fetch(`${helperBase}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload || {}), + ...(controller ? { signal: controller.signal } : {}), + }); + return await response.json(); + } catch (error) { + return { + status: "failed", + message: error?.name === "AbortError" ? "后端检查超时" : "未连接", + timeout: error?.name === "AbortError", + }; + } finally { + clearTimeout(timeoutId); + } + } if (!window.__codexSessionDeleteBridge) { if (path === "/backend/status") { - try { - const response = await fetch(`${helperBase}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload || {}), - }); - return await response.json(); - } catch (error) { - return { status: "failed", message: "未连接" }; - } + return await fetchBackendStatusFromHelper(path, payload); } sendCodexPlusDiagnostic("bridge_missing_for_route", { path }); return { status: "failed", message: "桥接不可用,请重启启动器" }; @@ -6245,18 +6273,6 @@ new Promise((resolve) => setTimeout(() => resolve({ status: "failed", message: "后端检查超时", timeout: true }), 2000)), ]); } - async function fetchBackendStatusFromHelper(path, payload) { - try { - const response = await fetch(`${helperBase}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload || {}), - }); - return await response.json(); - } catch (error) { - return { status: "failed", message: "未连接" }; - } - } try { if (path === "/backend/status") { const result = await bridgeWithBackendTimeout(path, payload); diff --git a/crates/codex-plus-core/src/bridge.rs b/crates/codex-plus-core/src/bridge.rs index 6a1417e20..4f491df49 100644 --- a/crates/codex-plus-core/src/bridge.rs +++ b/crates/codex-plus-core/src/bridge.rs @@ -121,16 +121,8 @@ pub fn build_bridge_script(binding_name: &str) -> String { pub fn bridge_health_check_script() -> &'static str { r#" (() => { - const bridge = window.__codexSessionDeleteBridge; - if (typeof bridge !== "function") return false; - try { - return Promise.race([ - Promise.resolve(bridge("/backend/status", {})).then((result) => !!result && result.status === "ok"), - new Promise((resolve) => setTimeout(() => resolve(false), 2000)), - ]); - } catch (error) { - return false; - } + // Watchdog health checks must not create a second bridge request. + return typeof window.__codexSessionDeleteBridge === "function"; })() "# } diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index c0a3d6f22..477c521c0 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -21,6 +21,7 @@ use crate::status::{LaunchStatus, StatusStore}; static PET_OVERLAY_SYNC_FAILED: AtomicBool = AtomicBool::new(false); static PET_CURSOR_DRIVER_FAILED: AtomicBool = AtomicBool::new(false); +const BRIDGE_HEALTH_FAILURE_THRESHOLD: u8 = 2; const MACOS_DEBUG_TAKEOVER_WAIT_MS: u64 = 5_000; const MACOS_DEBUG_TAKEOVER_INTERVAL_MS: u64 = 100; @@ -944,6 +945,7 @@ impl LaunchHooks for DefaultLaunchHooks { #[cfg(windows)] let pet_cursor_task = tokio::spawn(run_pet_real_mouse_cursor_driver(debug_port)); let mut observed_browser_id: Option = None; + let mut bridge_health_failures = 0u8; let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); loop { tokio::select! { @@ -968,6 +970,7 @@ impl LaunchHooks for DefaultLaunchHooks { helper_port, identity_changed, bridge_reinjector.clone(), + &mut bridge_health_failures, ), ); record_pet_overlay_sync_result(debug_port, helper_port, pet_result); @@ -2424,7 +2427,9 @@ async fn retry_injection(debug_port: u16, helper_port: u16) -> anyhow::Result<() } pub async fn check_and_reinject_bridge(debug_port: u16, helper_port: u16) -> bool { - check_and_reinject_bridge_inner(debug_port, helper_port, false, None).await + let mut health_failures = 0; + check_and_reinject_bridge_inner(debug_port, helper_port, false, None, &mut health_failures) + .await } pub fn browser_identity_changed(previous: Option<&str>, current: &str) -> bool { @@ -2444,6 +2449,7 @@ async fn check_and_reinject_bridge_inner( helper_port: u16, browser_identity_changed: bool, bridge_reinjector: Option, + health_failures: &mut u8, ) -> bool { let healthy = if browser_identity_changed { false @@ -2464,6 +2470,16 @@ async fn check_and_reinject_bridge_inner( } }; if healthy { + *health_failures = 0; + return false; + } + + if browser_identity_changed { + *health_failures = BRIDGE_HEALTH_FAILURE_THRESHOLD; + } else { + *health_failures = health_failures.saturating_add(1); + } + if *health_failures < BRIDGE_HEALTH_FAILURE_THRESHOLD { return false; } @@ -2472,7 +2488,8 @@ async fn check_and_reinject_bridge_inner( serde_json::json!({ "debug_port": debug_port, "helper_port": helper_port, - "browser_identity_changed": browser_identity_changed + "browser_identity_changed": browser_identity_changed, + "consecutive_health_failures": *health_failures }), ); let default_reinjector: BridgeReinjector = @@ -2487,6 +2504,7 @@ async fn check_and_reinject_bridge_inner( "helper_port": helper_port }), ); + *health_failures = 0; true } Err(error) => { diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index 4fedd9b17..33dad17f1 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -841,6 +841,9 @@ fn injection_script_times_out_backend_bridge_calls_and_falls_back_to_helper() { let script = assets::injection_script(57321); assert!(script.contains("bridgeWithBackendTimeout")); + assert!(script.contains("AbortController")); + assert!(script.contains("codexPlusBackendCheckInFlight")); + assert!(script.contains("CODEX_PLUS_BACKEND_FAILURE_THRESHOLD = 3")); assert!(script.contains("backend_bridge_timeout")); assert!(!script.contains("/backend/repair")); assert!(script.contains("backend_status_bridge_failed_http_fallback_ok")); @@ -4202,13 +4205,12 @@ fn runtime_evaluate_params_can_await_promise_for_bridge_health_checks() { } #[test] -fn bridge_health_check_script_uses_real_backend_round_trip() { +fn bridge_health_check_script_only_checks_injected_bridge() { let script = bridge::bridge_health_check_script(); assert!(script.contains("__codexSessionDeleteBridge")); - assert!(script.contains("/backend/status")); - assert!(script.contains("Promise.race")); - assert!(script.contains("setTimeout")); + assert!(script.contains("typeof window.__codexSessionDeleteBridge === \"function\"")); + assert!(!script.contains("/backend/status")); } #[test] From 2e5da00b3b3315866bcefa1190a8d68d432dae36 Mon Sep 17 00:00:00 2001 From: hcw <1416522360@qq.com> Date: Fri, 4 Sep 2026 23:41:22 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=E6=A1=A5=E6=8E=A5?= =?UTF-8?q?=E5=81=A5=E5=BA=B7=E6=8E=A2=E6=B5=8B=E8=AF=AF=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E9=BB=84=E7=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查回应:健康检查改用渲染层真实后端请求结果,CDP 探测超时按不确定状态处理,不触发重复注入;超时 Promise 的迟到结果会被安全丢弃。补充函数存在但请求失败、连续失败阈值和超时保护测试。 --- assets/inject/renderer-inject.js | 40 +++++--- crates/codex-plus-core/src/bridge.rs | 14 ++- crates/codex-plus-core/src/launcher.rs | 82 +++++++++++++--- crates/codex-plus-core/tests/cdp_bridge.rs | 103 ++++++++++++++++++++- 4 files changed, 209 insertions(+), 30 deletions(-) diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 6d5419485..74ce576c6 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -3875,11 +3875,19 @@ } let codexPlusUserScripts = { enabled: true, builtin_dir: "", user_dir: "", scripts: [] }; - let codexPlusBackendStatus = { status: "checking", message: "正在检查后端…" }; + let codexPlusBackendStatus = window.__codexPlusBackendStatus || { status: "checking", message: "正在检查后端…" }; let codexPlusBackendCheckSeq = 0; let codexPlusBackendCheckInFlight = false; let codexPlusBackendFailureCount = 0; const CODEX_PLUS_BACKEND_FAILURE_THRESHOLD = 3; + const codexPlusBackendGeneration = (Number(window.__codexPlusBackendGeneration) || 0) + 1; + window.__codexPlusBackendGeneration = codexPlusBackendGeneration; + + function recordCodexPlusBridgeSuccess() { + if (codexPlusBackendGeneration !== window.__codexPlusBackendGeneration) return; + const health = window.__codexPlusBridgeHealth || (window.__codexPlusBridgeHealth = {}); + health.lastSuccessAt = Date.now(); + } function renderBackendStatus() { const status = codexPlusBackendStatus.status || "failed"; @@ -3918,11 +3926,11 @@ codexPlusBackendCheckInFlight = true; const seq = ++codexPlusBackendCheckSeq; try { - const nextStatus = await withBackendTimeout(postJson("/backend/status", {})); - if (seq !== codexPlusBackendCheckSeq) return; + const nextStatus = await postJson("/backend/status", {}); + if (seq !== codexPlusBackendCheckSeq || codexPlusBackendGeneration !== window.__codexPlusBackendGeneration) return; if (nextStatus?.status === "ok") { codexPlusBackendFailureCount = 0; - codexPlusBackendStatus = nextStatus; + codexPlusBackendStatus = window.__codexPlusBackendStatus = nextStatus; if (typeof nextStatus.hideOfficialUsageAlert === "boolean") { window.__CODEX_PLUS_HIDE_OFFICIAL_USAGE_ALERT__ = nextStatus.hideOfficialUsageAlert; refreshOfficialUsageAlertVisibility(); @@ -3936,7 +3944,7 @@ consecutiveFailures: codexPlusBackendFailureCount, }); if (codexPlusBackendFailureCount >= CODEX_PLUS_BACKEND_FAILURE_THRESHOLD) { - codexPlusBackendStatus = nextStatus; + codexPlusBackendStatus = window.__codexPlusBackendStatus = nextStatus; } } renderBackendStatus(); @@ -3955,7 +3963,11 @@ } function scheduleBackendHeartbeat() { - if (window.__codexPlusBackendHeartbeat) return; + if (codexPlusBackendGeneration !== window.__codexPlusBackendGeneration) return; + if (window.__codexPlusBackendHeartbeat && + window.__codexPlusBackendHeartbeatGeneration === codexPlusBackendGeneration) return; + if (window.__codexPlusBackendHeartbeat) clearInterval(window.__codexPlusBackendHeartbeat); + window.__codexPlusBackendHeartbeatGeneration = codexPlusBackendGeneration; window.__codexPlusBackendHeartbeat = setInterval(checkBackendStatus, 5000); checkBackendStatus(); } @@ -6268,15 +6280,21 @@ return { status: "failed", message: "桥接不可用,请重启启动器" }; } function bridgeWithBackendTimeout(path, payload) { - return Promise.race([ - window.__codexSessionDeleteBridge(path, payload), - new Promise((resolve) => setTimeout(() => resolve({ status: "failed", message: "后端检查超时", timeout: true }), 2000)), - ]); + let request; + try { + request = window.__codexSessionDeleteBridge(path, payload); + } catch (error) { + return Promise.resolve({ status: "failed", message: error?.message || "未连接" }); + } + return withBackendTimeout(request); } try { if (path === "/backend/status") { const result = await bridgeWithBackendTimeout(path, payload); - if (result?.status === "ok") return result; + if (result?.status === "ok") { + recordCodexPlusBridgeSuccess(); + return result; + } if (result?.timeout) sendCodexPlusDiagnostic("backend_bridge_timeout", { path }); const fallback = await fetchBackendStatusFromHelper(path, payload); if (fallback?.status === "ok") { diff --git a/crates/codex-plus-core/src/bridge.rs b/crates/codex-plus-core/src/bridge.rs index 4f491df49..28ccf4eb9 100644 --- a/crates/codex-plus-core/src/bridge.rs +++ b/crates/codex-plus-core/src/bridge.rs @@ -96,6 +96,8 @@ pub fn build_bridge_script(binding_name: &str) -> String { (() => {{ window.__codexSessionDeleteCallbacks = new Map(); window.__codexSessionDeleteSeq = 0; + window.__codexPlusBridgeHealth = window.__codexPlusBridgeHealth || {{}}; + window.__codexPlusBridgeHealth.lastInjectionAt = Date.now(); window.__codexSessionDeleteResolve = (id, result) => {{ const callback = window.__codexSessionDeleteCallbacks.get(id); if (!callback) return; @@ -121,8 +123,16 @@ pub fn build_bridge_script(binding_name: &str) -> String { pub fn bridge_health_check_script() -> &'static str { r#" (() => { - // Watchdog health checks must not create a second bridge request. - return typeof window.__codexSessionDeleteBridge === "function"; + // The renderer heartbeat records real bridge results. Reading this state + // keeps the watchdog probe synchronous and cannot create a duplicate call. + const bridge = window.__codexSessionDeleteBridge; + const health = window.__codexPlusBridgeHealth; + if (typeof bridge !== "function" || !health) return false; + const now = Date.now(); + const lastSuccessAt = Number(health.lastSuccessAt) || 0; + const lastInjectionAt = Number(health.lastInjectionAt) || 0; + if (lastInjectionAt > 0 && now - lastInjectionAt <= 5000) return true; + return lastSuccessAt > 0 && now - lastSuccessAt <= 15000; })() "# } diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 477c521c0..5a9d049f1 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -2427,7 +2427,8 @@ async fn retry_injection(debug_port: u16, helper_port: u16) -> anyhow::Result<() } pub async fn check_and_reinject_bridge(debug_port: u16, helper_port: u16) -> bool { - let mut health_failures = 0; + // This one-shot entry point preserves its historical immediate-repair behavior. + let mut health_failures = BRIDGE_HEALTH_FAILURE_THRESHOLD.saturating_sub(1); check_and_reinject_bridge_inner(debug_port, helper_port, false, None, &mut health_failures) .await } @@ -2444,6 +2445,27 @@ fn should_probe_launcher_cdp(is_windows: bool, has_codex_process: bool) -> bool is_windows && !has_codex_process } +fn should_reinject_after_health_result( + healthy: Option, + browser_identity_changed: bool, + health_failures: &mut u8, +) -> bool { + let Some(healthy) = healthy else { + *health_failures = 0; + return false; + }; + if healthy { + *health_failures = 0; + return false; + } + if browser_identity_changed { + *health_failures = BRIDGE_HEALTH_FAILURE_THRESHOLD; + } else { + *health_failures = health_failures.saturating_add(1); + } + *health_failures >= BRIDGE_HEALTH_FAILURE_THRESHOLD +} + async fn check_and_reinject_bridge_inner( debug_port: u16, helper_port: u16, @@ -2452,10 +2474,10 @@ async fn check_and_reinject_bridge_inner( health_failures: &mut u8, ) -> bool { let healthy = if browser_identity_changed { - false + Some(false) } else { match bridge_health_ok(debug_port).await { - Ok(healthy) => healthy, + Ok(healthy) => Some(healthy), Err(error) => { let _ = crate::diagnostic_log::append_diagnostic_log( "bridge.health_check_failed", @@ -2465,21 +2487,15 @@ async fn check_and_reinject_bridge_inner( "message": error.to_string() }), ); - false + // A CDP timeout only means that the renderer did not answer + // this probe in time. The bridge heartbeat is the source of + // truth for actual availability; do not reinject on an + // indeterminate CDP result or a busy page will cause churn. + None } } }; - if healthy { - *health_failures = 0; - return false; - } - - if browser_identity_changed { - *health_failures = BRIDGE_HEALTH_FAILURE_THRESHOLD; - } else { - *health_failures = health_failures.saturating_add(1); - } - if *health_failures < BRIDGE_HEALTH_FAILURE_THRESHOLD { + if !should_reinject_after_health_result(healthy, browser_identity_changed, health_failures) { return false; } @@ -3144,6 +3160,42 @@ mod tests { assert!(!should_probe_launcher_cdp(false, false)); } + #[test] + fn bridge_health_failures_reinject_only_after_consecutive_unhealthy_results() { + let mut failures = 0; + assert!(!should_reinject_after_health_result( + Some(false), + false, + &mut failures + )); + assert_eq!(failures, 1); + assert!(should_reinject_after_health_result( + Some(false), + false, + &mut failures + )); + assert_eq!(failures, BRIDGE_HEALTH_FAILURE_THRESHOLD); + assert!(!should_reinject_after_health_result( + Some(true), + false, + &mut failures + )); + assert_eq!(failures, 0); + + failures = 1; + assert!(!should_reinject_after_health_result( + None, + false, + &mut failures + )); + assert_eq!(failures, 0); + assert!(should_reinject_after_health_result( + Some(false), + true, + &mut failures + )); + } + #[test] fn helper_bind_retry_covers_fixed_proxy_ports_and_macos_restarts() { assert_eq!( diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index 33dad17f1..3ed43497d 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -842,14 +842,76 @@ fn injection_script_times_out_backend_bridge_calls_and_falls_back_to_helper() { assert!(script.contains("bridgeWithBackendTimeout")); assert!(script.contains("AbortController")); + assert!(script.contains("recordCodexPlusBridgeSuccess")); + assert!(script.contains("lastSuccessAt")); assert!(script.contains("codexPlusBackendCheckInFlight")); assert!(script.contains("CODEX_PLUS_BACKEND_FAILURE_THRESHOLD = 3")); + assert!(script.contains("codexPlusBackendGeneration !== window.__codexPlusBackendGeneration")); + assert!(script.contains("__codexPlusBackendHeartbeatGeneration")); + assert!(script.contains("clearInterval(window.__codexPlusBackendHeartbeat)")); + assert!(!script.contains("await withBackendTimeout(postJson(\"/backend/status\", {}))")); assert!(script.contains("backend_bridge_timeout")); assert!(!script.contains("/backend/repair")); assert!(script.contains("backend_status_bridge_failed_http_fallback_ok")); assert!(script.contains("backend_status_bridge_and_http_failed")); } +#[test] +fn injection_script_keeps_one_backend_heartbeat_per_generation() { + let script = assets::injection_script(57321); + let start = script + .find("function scheduleBackendHeartbeat()") + .expect("backend heartbeat scheduler should exist"); + let end = script[start..] + .find("\n function userScriptStatusLabel") + .map(|offset| start + offset) + .expect("backend heartbeat scheduler should have an end marker"); + let scheduler = &script[start..end]; + let scheduler_json = serde_json::to_string(scheduler).expect("scheduler should serialize"); + let harness = format!( + r#" +const vm = require("node:vm"); +const source = {scheduler}; +const runCase = (generation, heartbeat, heartbeatGeneration, expected) => {{ + let timers = 0; + let clears = 0; + let checks = 0; + const context = {{ + window: {{ + __codexPlusBackendGeneration: generation, + __codexPlusBackendHeartbeat: heartbeat, + __codexPlusBackendHeartbeatGeneration: heartbeatGeneration, + }}, + codexPlusBackendGeneration: generation, + setInterval: () => ++timers, + clearInterval: () => ++clears, + checkBackendStatus: () => ++checks, + }}; + vm.runInNewContext(source + "\nthis.run = scheduleBackendHeartbeat;", context); + context.run(); + context.run(); + const actual = {{ timers, clears, checks }}; + if (JSON.stringify(actual) !== JSON.stringify(expected)) process.exit(1); +}}; +runCase(1, null, null, {{ timers: 1, clears: 0, checks: 1 }}); +runCase(1, 99, 1, {{ timers: 0, clears: 0, checks: 0 }}); +runCase(2, 99, 1, {{ timers: 1, clears: 1, checks: 1 }}); +"#, + scheduler = scheduler_json + ); + let output = Command::new("node") + .arg("-e") + .arg(harness) + .output() + .expect("node should run heartbeat scheduler harness"); + assert!( + output.status.success(), + "heartbeat scheduler harness failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn injection_script_explains_plugin_patch_is_unneeded_in_relay_mode() { let script = assets::injection_script(57321); @@ -4205,14 +4267,51 @@ fn runtime_evaluate_params_can_await_promise_for_bridge_health_checks() { } #[test] -fn bridge_health_check_script_only_checks_injected_bridge() { +fn bridge_health_check_script_uses_persisted_real_probe_result() { let script = bridge::bridge_health_check_script(); assert!(script.contains("__codexSessionDeleteBridge")); - assert!(script.contains("typeof window.__codexSessionDeleteBridge === \"function\"")); + assert!(script.contains("__codexPlusBridgeHealth")); + assert!(script.contains("lastSuccessAt")); + assert!(script.contains("lastInjectionAt")); assert!(!script.contains("/backend/status")); } +#[test] +fn bridge_health_check_script_rejects_stale_bridge_after_failed_requests() { + let script = serde_json::to_string(bridge::bridge_health_check_script()) + .expect("health script should serialize"); + let harness = format!( + r#" +const vm = require("node:vm"); +const source = {script}; +const bridge = () => Promise.resolve({{ status: "failed" }}); +const run = (health, hasBridge = true) => vm.runInNewContext(source, {{ + window: {{ __codexSessionDeleteBridge: hasBridge ? bridge : null, __codexPlusBridgeHealth: health }}, +}}); +const now = Date.now(); +if (run({{ lastInjectionAt: 0, lastSuccessAt: 1 }}) !== false) process.exit(1); +if (run({{ lastInjectionAt: 0, lastSuccessAt: now }}) !== true) process.exit(2); +if (run({{ lastInjectionAt: now, lastSuccessAt: 0 }}) !== true) process.exit(3); +if (run({{ lastInjectionAt: now - 6000, lastSuccessAt: 0 }}) !== false) process.exit(4); +if (run({{ lastInjectionAt: 1, lastSuccessAt: now - 16000 }}) !== false) process.exit(5); +if (run({{ lastInjectionAt: now, lastSuccessAt: now }}, false) !== false) process.exit(6); +"#, + script = script + ); + let output = Command::new("node") + .arg("-e") + .arg(harness) + .output() + .expect("node should run bridge health harness"); + assert!( + output.status.success(), + "bridge health harness failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn bridge_result_expressions_json_escape_inputs() { let resolve = bridge::resolve_bridge_expression("request\"1", &json!({"status": "ok"}))