diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index 75d2c8772..74ce576c6 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -3875,8 +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"; @@ -3911,22 +3922,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 postJson("/backend/status", {}); + if (seq !== codexPlusBackendCheckSeq || codexPlusBackendGeneration !== window.__codexPlusBackendGeneration) return; + if (nextStatus?.status === "ok") { + codexPlusBackendFailureCount = 0; + codexPlusBackendStatus = window.__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 = window.__codexPlusBackendStatus = nextStatus; + } + } + renderBackendStatus(); + } finally { + codexPlusBackendCheckInFlight = false; } - renderBackendStatus(); } async function openManagerFromCodex() { @@ -3939,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(); } @@ -6223,44 +6251,50 @@ } async function postJson(path, payload) { - 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: "未连接" }; - } - } - sendCodexPlusDiagnostic("bridge_missing_for_route", { path }); - 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)), - ]); - } 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: "未连接" }; + return { + status: "failed", + message: error?.name === "AbortError" ? "后端检查超时" : "未连接", + timeout: error?.name === "AbortError", + }; + } finally { + clearTimeout(timeoutId); + } + } + if (!window.__codexSessionDeleteBridge) { + if (path === "/backend/status") { + return await fetchBackendStatusFromHelper(path, payload); + } + sendCodexPlusDiagnostic("bridge_missing_for_route", { path }); + return { status: "failed", message: "桥接不可用,请重启启动器" }; + } + function bridgeWithBackendTimeout(path, payload) { + 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 6a1417e20..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,16 +123,16 @@ pub fn build_bridge_script(binding_name: &str) -> String { pub fn bridge_health_check_script() -> &'static str { r#" (() => { + // 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; - 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; - } + 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 c0a3d6f22..5a9d049f1 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,10 @@ 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 + // 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 } pub fn browser_identity_changed(previous: Option<&str>, current: &str) -> bool { @@ -2439,17 +2445,39 @@ 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, browser_identity_changed: bool, bridge_reinjector: Option, + 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", @@ -2459,11 +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 { + if !should_reinject_after_health_result(healthy, browser_identity_changed, health_failures) { return false; } @@ -2472,7 +2504,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 +2520,7 @@ async fn check_and_reinject_bridge_inner( "helper_port": helper_port }), ); + *health_failures = 0; true } Err(error) => { @@ -3126,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 4fedd9b17..3ed43497d 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -841,12 +841,77 @@ 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("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); @@ -4202,13 +4267,49 @@ 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_uses_persisted_real_probe_result() { 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("__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]