objectiveai_mcp_proxy/dropper.rs
1//! Caller-driven forceful teardown + permanent ban of a response id.
2//!
3//! The embedder (the API) constructs a [`Dropper`], passes a clone into
4//! [`crate::setup`], and keeps a clone to invoke. Calling
5//! [`Dropper::drop`] for a `response_id`:
6//! 1. **forever-bans** it (a concurrency-safe set checked on every
7//! `initialize`), then
8//! 2. tears down any live session for it.
9//!
10//! Banning before teardown — paired with `handle_initialize` inserting
11//! before it checks the ban — guarantees no `initialize` racing a `drop`
12//! can leave a session alive (see the module's teardown / the
13//! `handle_initialize` checks).
14//!
15//! The teardown context (the [`SessionManager`] and the optional
16//! [`ReverseChannel`]) only exists once `setup` has built it, after the
17//! caller already holds the `Dropper`. It is therefore late-bound via a
18//! `OnceLock`, wired exactly once by `setup` ([`Dropper::wire`]).
19
20use std::sync::{Arc, OnceLock};
21
22use dashmap::DashMap;
23
24use crate::ReverseChannel;
25use crate::session_manager::SessionManager;
26
27/// Cheaply-cloneable handle for banning + dropping response ids. Shared
28/// between the caller (who invokes [`Self::drop`]) and the proxy (which
29/// checks [`Self::is_banned`] on every `initialize`).
30#[derive(Clone, Default)]
31pub struct Dropper {
32 inner: Arc<Inner>,
33}
34
35#[derive(Default)]
36struct Inner {
37 /// Forever-ban set. A response id present here is never (re)admitted.
38 banned: DashMap<String, ()>,
39 /// Teardown context, late-bound by [`Dropper::wire`] from `setup`.
40 ctx: OnceLock<TeardownCtx>,
41}
42
43struct TeardownCtx {
44 sessions: Arc<SessionManager>,
45 reverse_channel: Option<ReverseChannel>,
46}
47
48impl std::fmt::Debug for Dropper {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("Dropper").finish_non_exhaustive()
51 }
52}
53
54impl Dropper {
55 pub fn new() -> Self {
56 Self::default()
57 }
58
59 /// Forever-ban `response_id`, then tear down any live session for it.
60 ///
61 /// Bans FIRST so an `initialize` for the same id that runs
62 /// concurrently observes the ban (its post-insert check then tears
63 /// the session down itself). Teardown is idempotent, so a double
64 /// teardown across the race is harmless.
65 pub async fn drop(&self, response_id: String) {
66 self.inner.banned.insert(response_id.clone(), ());
67 if let Some(ctx) = self.inner.ctx.get() {
68 teardown(&response_id, &ctx.sessions, ctx.reverse_channel.as_ref()).await;
69 }
70 }
71
72 /// Whether `response_id` has been banned. Checked on every
73 /// `initialize` (before reuse/connect, and again after insert).
74 pub(crate) fn is_banned(&self, response_id: &str) -> bool {
75 self.inner.banned.contains_key(response_id)
76 }
77
78 /// Wire the proxy's teardown context into the dropper. Called once by
79 /// `setup` after it has built the session manager + reverse channel.
80 /// Idempotent (first write wins).
81 pub(crate) fn wire(
82 &self,
83 sessions: Arc<SessionManager>,
84 reverse_channel: Option<ReverseChannel>,
85 ) {
86 let _ = self.inner.ctx.set(TeardownCtx {
87 sessions,
88 reverse_channel,
89 });
90 }
91}
92
93/// Forceful teardown of one response id's session.
94///
95/// Removes the session from the registry (dropping it closes the
96/// proxy-side upstream connections), and — only if at least one upstream
97/// is a `client_objectiveai_mcp` (ws) upstream — fires the reverse-channel
98/// [`Drop`](objectiveai_sdk::client_objectiveai_mcp::server_request::Payload::Drop)
99/// so the CLI tears down its side (connections + plugin subprocesses).
100/// The `Drop` result is discarded. Idempotent: an absent id is a no-op,
101/// and the CLI's `drop` handler is itself idempotent.
102pub(crate) async fn teardown(
103 response_id: &str,
104 sessions: &SessionManager,
105 reverse_channel: Option<&ReverseChannel>,
106) {
107 let Some(session) = sessions.remove(response_id) else {
108 return;
109 };
110 let uses_objectiveai_mcp = session.connections.values().any(|u| u.is_ws());
111 if uses_objectiveai_mcp {
112 if let Some(rc) = reverse_channel {
113 rc.drop_response(response_id.to_string()).await;
114 }
115 }
116 // `session` (Arc<Session>) drops at end of scope → proxy-side upstream
117 // connections close.
118}