From 62ad1497b0e7c74fe9469ab58b4a3580b504e824 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:16:20 +0200 Subject: [PATCH 1/2] fix(client-runtime): commit a pending session delete when the app is backgrounded A deleted session stays undoable for four seconds, and that window lives only in memory. Deleting several sessions in a row commits each one when the next is deleted, but the last one waits out its window: if Android killed the backgrounded app before then, its close-session was never sent, and the session came back once its local dismissal expired. Going to the background now commits a pending delete at once; its undo toast is out of sight by then anyway. The new runtime test deletes three sessions in a row and checks that each close-session is sent exactly once, that a session list the bridge sent before closing the later ones does not bring them back, and that backgrounding sends the last one. Co-Authored-By: Claude Code --- crates/client-runtime/src/runtime.rs | 8 ++ crates/client-runtime/src/runtime/tests.rs | 102 +++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/crates/client-runtime/src/runtime.rs b/crates/client-runtime/src/runtime.rs index 52da0e7..a525a83 100644 --- a/crates/client-runtime/src/runtime.rs +++ b/crates/client-runtime/src/runtime.rs @@ -948,6 +948,14 @@ impl Loop { } Msg::Pause => { // Backgrounded: the OS may kill the process from here on. + // A delete still in its undo window is committed now — + // its toast is out of sight, and the window lives only + // in memory: a process killed before it closed would + // never send the `close-session`, and the session would + // come back once its dismissal expired. + if self.stores.delete_controller.is_pending() { + self.on_undo_timer().await; + } self.flush_acks(); self.flush_writes().await; self.ws.set_ping_interval(BACKGROUND_PING_EVERY); diff --git a/crates/client-runtime/src/runtime/tests.rs b/crates/client-runtime/src/runtime/tests.rs index 0236847..751b1c9 100644 --- a/crates/client-runtime/src/runtime/tests.rs +++ b/crates/client-runtime/src/runtime/tests.rs @@ -1758,6 +1758,108 @@ async fn the_undo_toast_clears_itself_when_the_window_expires_without_a_tap() { .await; } +/// A heartbeat listing `ids` for the machine of the session-delete test. +fn heartbeat_listing(ids: &[&str]) -> BridgeToPhone { + let sessions: Vec = ids + .iter() + .map(|id| { + serde_json::json!({"id": id, "agent": "claude-code", "slug": id, "cwd": "/w", + "lastActivity": "t", "lineCount": 0, "title": null, "project": "p"}) + }) + .collect(); + protocol::codec::decode_bridge_to_phone( + &serde_json::json!({ + "type": "sessions", + "machine": "laptop", + "sessions": sessions, + "agents": [], + "protocolVersion": protocol::capabilities::PROTOCOL_VERSION, + }) + .to_string(), + ) + .unwrap() +} + +/// Deleting sessions one after another: each delete commits the one before +/// it at once (one `close-session` each), a heartbeat sent before the +/// bridge has closed the later ones does not bring them back, and the +/// last one — still in its undo window — is committed as soon as the app +/// is backgrounded rather than lost with a killed process. +#[tokio::test] +async fn deleting_sessions_in_a_row_closes_each_one_exactly_once() { + LocalSet::new() + .run_until(async { + let mut mock = mock_relay().await; + let phone = keypair_from_secret_hex(SEC_PHONE).unwrap(); + let machine = generate_keypair(); + let mut state = client_core::stores::machines::MachinesState::default(); + state.register_machine(&machine.pubkey_hex, "bridge", None, None, &[]); + let kv = MemoryKv::seeded([( + crate::stores::MACHINES_KEY, + client_core::stores::machines::serialize_machines(&state.machines), + )]); + let ports = CorePorts { kv: Rc::new(kv), ..CorePorts::default() }; + let core = core_for_ports(&mock, &phone, Rc::new(Spy::default()), ports).await; + core.start(); + eose_all(&mut mock).await; + let refresh = next_command_via(&mut mock, &phone, &phone.pubkey_hex, &machine).await; + assert!(matches!(refresh, Some(PhoneToBridge::RefreshSessions(_))), "{refresh:?}"); + + let listed = heartbeat_listing(&["s1", "s2", "s3"]); + push_bridge_to_phone_event(&mock, &machine, &phone.pubkey_hex, "cd-1", &listed); + settle().await; + let sessions = |view: MachinesView| -> Vec { + view.machines[&machine.pubkey_hex].sessions.keys().cloned().collect() + }; + assert_eq!(sessions(core.machines_view().await), ["s1", "s2", "s3"]); + + for id in ["s1", "s2", "s3"] { + core.dispatch(Intent::DeleteSession { + machine: machine.pubkey_hex.clone(), + session_id: id.into(), + label: None, + }) + .await; + } + assert!(sessions(core.machines_view().await).is_empty()); + let toast = core.ui_view().await.undo_toast.expect("the last delete is undoable"); + assert_eq!(toast.session_id, "s3"); + + // s1 and s2 were committed by the delete that followed each. + let closed = |cmd: Option| match cmd { + Some(PhoneToBridge::CloseSession(m)) => m.session_id, + other => panic!("expected a close-session, got {other:?}"), + }; + for id in ["s1", "s2"] { + assert_eq!(closed(next_command_via(&mut mock, &phone, &phone.pubkey_hex, &machine).await), id); + } + + // The bridge has closed s1 only; its next list still has the + // other two, which must stay deleted on the phone. + let after_first = heartbeat_listing(&["s2", "s3"]); + push_bridge_to_phone_event(&mock, &machine, &phone.pubkey_hex, "cd-1", &after_first); + settle().await; + assert!(sessions(core.machines_view().await).is_empty()); + + // Backgrounded inside s3's undo window: committed now, well + // before the window would have run out. + core.pause(); + let last = tokio::time::timeout( + std::time::Duration::from_millis(client_core::delete_controller::UNDO_DELAY_MS / 2), + next_command_via(&mut mock, &phone, &phone.pubkey_hex, &machine), + ) + .await + .expect("the pending delete is sent when the app is backgrounded"); + assert_eq!(closed(last), "s3"); + assert!(core.ui_view().await.undo_toast.is_none()); + + // Nothing is left to undo, and nothing is sent twice. + core.dispatch(Intent::UndoDelete).await; + assert!(sessions(core.machines_view().await).is_empty()); + }) + .await; +} + /// The next command EVENT the identity `phone` signed for `machine`, /// with its payload decrypted as from `payload_key` (the phone's /// identity or its session key). Every EVENT is ACKed on the way. From 163c91350a7926df08267cc5ed8c6ae1848eee82 Mon Sep 17 00:00:00 2001 From: deymosh <232244450+deymosh@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:16:20 +0200 Subject: [PATCH 2/2] test: cover closing several sessions in a row on the bridge and the host The bridge test closes three running sessions and one still starting in a row: each is ended in the agent host and acknowledged, the next list has none of them, and replies and events the host still had in flight bring none back. The DeepSeek driver test ends sessions that share one harness process together and checks that the process is closed only after the last one. Co-Authored-By: Claude Code --- crates/bridge-core/tests/sessions.rs | 65 +++++++++++++++++++ .../deepseek/__tests__/deepseekDriver.test.ts | 18 +++++ 2 files changed, 83 insertions(+) diff --git a/crates/bridge-core/tests/sessions.rs b/crates/bridge-core/tests/sessions.rs index 89669c3..1b90f40 100644 --- a/crates/bridge-core/tests/sessions.rs +++ b/crates/bridge-core/tests/sessions.rs @@ -456,6 +456,71 @@ fn close_session_ends_it_tombstones_it_and_forgets_its_transcript() { assert!(rig.transcripts.entries(&s).is_empty()); } +#[test] +fn closing_sessions_in_a_row_ends_each_and_late_host_events_bring_none_back() { + let mut rig = Rig::new(); + rig.host_up(); + let ready: Vec = ["alpha", "beta", "alpha"].iter().map(|a| rig.ready_session(a)).collect(); + for s in &ready { + rig.say(s, "x"); + } + // One more that the host has not answered yet: closed while starting. + rig.send(json!({"type":"create-session","agent":"alpha"})); + let (start_id, starting) = rig.start_request(); + rig.take(); + + let mut all = ready.clone(); + all.push(starting.session_id.clone()); + for s in &all { + rig.send(json!({"type":"close-session","sessionId":s})); + } + + let ended: Vec = rig + .host_frames() + .into_iter() + .filter_map(|f| match f.message { + BridgeMessage::EndSession { session_id } => Some(session_id), + _ => None, + }) + .collect(); + assert_eq!(ended, all, "every session is ended in the host, the starting one included"); + rig.advance(5_000); + let msgs = rig.messages(); + let acked: Vec<&str> = msgs + .iter() + .filter_map(|m| match m { + BridgeToPhone::CloseSessionAck(a) if a.success => Some(a.session_id.as_str()), + _ => None, + }) + .collect(); + assert_eq!(acked, all.iter().map(String::as_str).collect::>()); + let hb = last_heartbeat(&msgs); + assert!(hb.sessions.is_empty()); + let mut removed = hb.removed_sessions.clone().unwrap_or_default(); + removed.sort(); + let mut expected = all.clone(); + expected.sort(); + assert_eq!(removed, expected); + + // What the host still had in flight arrives after the closes. + rig.host_reply(&start_id, HostMessage::Ack); + rig.host_event(&starting.session_id, SessionEvent::Ready {}); + rig.say(&ready[0], "late"); + rig.host_event(&ready[1], SessionEvent::Ended { error: None, resume_lost: false }); + rig.advance(5_000); + let msgs = rig.messages(); + assert!(outputs(&msgs).is_empty(), "no output of a closed session reaches the phone"); + if let Some(hb) = msgs.iter().rev().find_map(|m| match m { + BridgeToPhone::Sessions(h) => Some(h), + _ => None, + }) { + assert!(hb.sessions.is_empty(), "a closed session came back: {:?}", hb.sessions); + } + for s in &all { + assert!(rig.transcripts.entries(s).is_empty()); + } +} + #[test] fn shutdown_publishes_every_session_offline_and_stops() { let mut rig = Rig::new(); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts index 38d08b9..bfc5646 100644 --- a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts @@ -479,6 +479,24 @@ describe('DeepSeekSession lifecycle', () => { await vi.waitFor(() => expect(ready.harness.child.killed).toContain('SIGTERM')); }); + it('closes sessions ended together one by one, and the shared process only after the last', async () => { + const ready = withDriver(); + const sessions: DriverSession[] = []; + for (const sessionId of ['b1', 'b2', 'b3']) { + await started(ready, { sessionId }); + sessions.push(ready.session); + } + expect(ready.spawns).toHaveLength(1); + + await Promise.all([sessions[0]!.end(), sessions[1]!.end()]); + expect([...ready.harness.closed].sort()).toEqual(['s1', 's2']); + expect(ready.harness.child.killed).toEqual([]); + + await Promise.all([sessions[2]!.end(), sessions[2]!.end()]); + expect([...ready.harness.closed].sort()).toEqual(['s1', 's2', 's3']); + await vi.waitFor(() => expect(ready.harness.child.killed).toContain('SIGTERM')); + }); + it('ends every session of a harness process that goes away', async () => { const ready = withDriver(); const first = await started(ready, { sessionId: 'b1' });