From e0612a501cf4b203f920fa0034f267fad5f63632 Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 21:40:50 +0800 Subject: [PATCH 1/2] fix(remote): keepalive WS Ping so the /acp tunnel doesn't idle-flap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare's tunnel idle-closes a WebSocket with no traffic (~100s) and tokio-tungstenite never pings on its own, so an idle /acp connection was flapping ~every 2 min: 8 reconnects in 45 min with 0 prompts, each a fresh session/new channel (diagnosed from the oab-prod-orca logs). Harmless while idle, but once chatting a >~100s quiet stretch would idle-close mid-turn and — until session/resume lands — reconnect into a new channel, losing agent context. Add a 45s tokio interval branch to run_once's select! loop that sends a WS Ping directly on the write half (the send() helper only frames JSON Text). 45s is well inside the ~100s idle window; the ping counts as traffic and keeps the tunnel open. The read arm already ignores inbound Ping/Pong, so this composes with the existing loop without touching the inbound path. Skip missed-tick behaviour avoids a burst of pings after any busy stretch. Diagnosed with Jellyfish (ECS log analysis). session/resume (so a real drop also preserves context) is a separate Part B item. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/remote.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 8bc81ca..2db1807 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -239,6 +239,15 @@ async fn run_once( // single writer stays in the loop, so frames are still serialized on the wire. let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::(); + // Keepalive: Cloudflare's tunnel idle-closes a WS with no traffic (~100s) and + // tokio-tungstenite never pings on its own, so an idle `/acp` connection flaps + // roughly every 2 min — and until `session/resume` lands, each reconnect opens + // a fresh channel (lost agent context). A periodic WS Ping well inside that + // window counts as traffic and keeps the tunnel open between prompts. `Skip` + // missed-tick behaviour avoids a burst of pings if the loop was ever busy. + let mut keepalive = tokio::time::interval(std::time::Duration::from_secs(45)); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Loop over BOTH inbound frames and outbound chat actions. `write` never leaves // the task; commands reach it only through `out_rx`. loop { @@ -280,6 +289,17 @@ async fn run_once( send(&mut write, &reply).await?; } + // Keepalive tick: send a WS Ping directly on `write` (the `send` helper + // only frames JSON Text). The read arm ignores the returning Pong + // (`Ping | Pong | Frame => continue`), so this composes with the rest of + // the loop without touching the inbound path. + _ = keepalive.tick() => { + write + .send(WsMessage::Ping(Vec::new())) + .await + .map_err(|e| format!("ws keepalive ping: {e}"))?; + } + // Inbound: a frame from the gateway. msg = read.next() => { let Some(msg) = msg else { break }; From 88c62fc676e5312d25bbccc299c6c73914f0e372 Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 22:22:34 +0800 Subject: [PATCH 2/2] feat(remote): log each keepalive ping to the Activity pane Emit an `app-log` info line on every keepalive tick so the operator can see in the Activity tab that the tunnel is being kept warm between prompts (not just infer it from the absence of reconnects). Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/remote.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 2db1807..af43b3b 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -298,6 +298,12 @@ async fn run_once( .send(WsMessage::Ping(Vec::new())) .await .map_err(|e| format!("ws keepalive ping: {e}"))?; + // Surface each keepalive in the Activity pane so the operator can + // see the tunnel being kept warm between prompts. + let _ = app.emit( + "app-log", + json!({ "level": "info", "msg": "remote: keepalive ping sent" }), + ); } // Inbound: a frame from the gateway.