Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 74 additions & 40 deletions assets/inject/renderer-inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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() {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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") {
Expand Down
20 changes: 11 additions & 9 deletions crates/codex-plus-core/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
})()
"#
}
Expand Down
82 changes: 76 additions & 6 deletions crates/codex-plus-core/src/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> = None;
let mut bridge_health_failures = 0u8;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
tokio::select! {
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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<bool>,
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<BridgeReinjector>,
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",
Expand All @@ -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;
}

Expand All @@ -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 =
Expand All @@ -2487,6 +2520,7 @@ async fn check_and_reinject_bridge_inner(
"helper_port": helper_port
}),
);
*health_failures = 0;
true
}
Err(error) => {
Expand Down Expand Up @@ -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!(
Expand Down
Loading
Loading