Skip to main content

onlyne_client/session/dispatch/
transport.rs

1use super::*;
2
3use super::env::missing_capability;
4use super::outbound::queue_outbound_locked;
5use super::retire::{retire_idle_locked, stored_close_reason};
6use super::state::{
7    DispatchInner, DispatchState, FrameGuard, SessionSlot, has_attached_transport,
8    rebase_generation, slot_key_named, slot_key_serving_task, slot_task,
9};
10
11/// The name one held frame is addressed to.
12///
13/// A role recipient keeps its own name, which is the role the merged relay is
14/// addressed to. Any other recipient keeps the spelling the operator reads in a
15/// session listing, and the merged relay addressed to it is refused and recorded
16/// rather than quietly dropped: a read-only session cannot answer a conversation
17/// it no longer serves.
18fn held_recipient(to: &Principal) -> String {
19    to.role_name()
20        .map(str::to_string)
21        .unwrap_or_else(|| to.to_string())
22}
23
24/// Whether one session slot is the session an adapter mount named.
25///
26/// The mount carries the id the client spawned the plugin with
27/// (`ONLYNE_SESSION_ID`), which is the slot's key and its stored reference.
28/// Each task gets its own session, so those spellings all name one session; the
29/// extra checks stay because a slot keeps the id its session was born with even
30/// after its task binding changes.
31pub(super) fn names_session(key: &str, slot: &SessionSlot, session_id: &str) -> bool {
32    key == session_id
33        || slot.session.task_id == session_id
34        || slot.task_id.as_deref() == Some(session_id)
35}
36
37/// Whether a connection other than `io` is the one serving one slot.
38fn attached_to_other(inner: &DispatchInner, key: &str, slot: &SessionSlot, io: &AdapterIo) -> bool {
39    inner.transports.iter().any(|(session_id, (live, _))| {
40        !live.same_connection(io) && names_session(key, slot, session_id)
41    })
42}
43
44/// Whether `io` is the connection serving one session, as the binding rules left
45/// it.
46///
47/// A connection this client holds read-only is never the answer: a mount that
48/// finds its session already served lands in `revived` and never in
49/// `transports`, which is the map this reads. Nothing else about the connection
50/// is consulted — a frame carries its sender, so a connection serving one
51/// session cannot answer for another by naming it.
52pub(super) fn serves_session(inner: &DispatchInner, session_id: &str, io: &AdapterIo) -> bool {
53    let Some(key) = slot_key_named(inner, session_id) else {
54        return false;
55    };
56    let Some(slot) = inner.sessions.get(&key) else {
57        return false;
58    };
59    inner.transports.iter().any(|(served, (transport, _))| {
60        transport.same_connection(io) && names_session(&key, slot, served)
61    })
62}
63
64/// Whether one connection is held read-only: it mounted a session this client
65/// already serves through a different live connection.
66pub(super) fn is_revived_connection(inner: &DispatchInner, io: &AdapterIo) -> bool {
67    inner
68        .revived
69        .iter()
70        .any(|(_, revived, _)| revived.same_connection(io))
71}
72
73/// Whether one slot is held by a connection this client keeps read-only.
74///
75/// A slot demoted by [`note_binding_locked`] — its task taken by a newer session
76/// — is served by no transport at all: the connection that came back for it
77/// waits in `revived` under the name it mounted with, which is the name that
78/// names this slot. While that connection is there the slot has an owner:
79/// `retire_revived` retires it with the completion that answers what the held
80/// connection wrote. A demoted slot whose held connection has since gone has no
81/// such owner left, so the answer is read off the held names rather than off the
82/// demotion alone — and it is read by asking which slots a name names, never by
83/// asking which slot a name resolves to, because two slots can answer to one
84/// name and only one of them is the held connection's.
85pub(super) fn held_read_only(inner: &DispatchInner, key: &str, slot: &SessionSlot) -> bool {
86    slot.read_only
87        && inner
88            .revived
89            .iter()
90            .any(|(name, _, _)| names_session(key, slot, name))
91}
92
93/// Record a returning connection as read-only, once per connection.
94pub(super) fn record_revived_connection(
95    inner: &mut DispatchInner,
96    session_id: &str,
97    io: AdapterIo,
98    capabilities: Vec<Capability>,
99) {
100    if !is_revived_connection(inner, &io) {
101        inner
102            .revived
103            .push((session_id.to_string(), io, capabilities));
104    }
105}
106
107/// Give one session back to the oldest connection that was held read-only for it.
108///
109/// A held connection is read-only only while another connection serves its
110/// session, so the moment that connection goes is the moment the held one becomes
111/// the session's only transport. Without this, a plugin that redials while the
112/// client still holds the dead socket behind it is silenced for the rest of the
113/// session: the assignment it came for is written to a connection nobody reads,
114/// and nothing promotes it later. The demotion lifts with the promotion, so the
115/// reconnected agent keeps both its session and its delivery rights.
116fn promote_held_connection(inner: &mut DispatchInner, key: &str) {
117    let attached = inner
118        .sessions
119        .get(key)
120        .is_some_and(|slot| has_attached_transport(inner, key, slot));
121    if attached {
122        return;
123    }
124    let held = inner.revived.iter().position(|(name, _, _)| {
125        slot_key_named(inner, name).is_some_and(|held_key| held_key == key)
126    });
127    let Some(index) = held else { return };
128    let (name, io, capabilities) = inner.revived.remove(index);
129    if let Some(slot) = inner.sessions.get_mut(key) {
130        slot.read_only = false;
131    }
132    tracing::info!(
133        session = %name,
134        "a held connection takes the session its predecessor left"
135    );
136    attach_transport_locked(inner, &name, io, capabilities);
137}
138
139/// Settle what one mounting connection means for the session it names.
140///
141/// A mount that finds nothing serving its session takes it and clears the clock
142/// [`DispatchState::release_connection`] started: that is the agent that came
143/// back inside the reconnect grace, and the always-running agent serving task
144/// after task lives in this path. A mount that finds the session already served
145/// takes nothing — either another connection holds that very slot, or the task it
146/// names now answers from a slot of its own, which is the case where a newer
147/// session was spawned to retry the work while the old agent's process came back.
148/// Such a connection is recorded read-only, and the slot it names is demoted too
149/// when it owns a slot of its own.
150///
151/// Every binding path runs this one judgement, including the ready report, which
152/// reaches an agent without writing a transport. Concurrency is what decides, not
153/// the drop clock: the retry that claims an unclaimed session is served, and the
154/// connection that returns to a session already served is held, whichever of the
155/// two mounted first. [`DispatchState::release_connection`] promotes a held
156/// connection when the live one it waited behind goes away, so a plugin that
157/// redials over a socket the client has not yet seen die still gets its session.
158pub(super) fn note_binding_locked(
159    inner: &mut DispatchInner,
160    session_id: &str,
161    io: &AdapterIo,
162) -> bool {
163    if is_revived_connection(inner, io) {
164        return false;
165    }
166    let Some(key) = slot_key_named(inner, session_id) else {
167        return true;
168    };
169    let Some(slot) = inner.sessions.get(&key) else {
170        return true;
171    };
172    let task = slot_task(slot);
173    let taken = attached_to_other(inner, &key, slot, io);
174    let moved_on = !taken
175        && inner.sessions.iter().any(|(other, other_slot)| {
176            *other != key
177                && slot_task(other_slot) == task
178                && attached_to_other(inner, other, other_slot, io)
179        });
180    let revived = taken || moved_on;
181    // A mount that takes a session whose death clock was running is the agent
182    // coming back inside the reconnect grace: the barrier had already passed, so
183    // `ready` says the plugin spoke once, and the clock says the connection it
184    // spoke through has since ended. That is the only shape this rebase is for.
185    // A first mount has no clock running over a session that ever spoke, and a
186    // settled session owes no work its reporter would answer for.
187    let returning = !revived && slot.dropped_at.is_some() && slot.ready && slot.task_id.is_some();
188    if let Some(slot) = inner.sessions.get_mut(&key) {
189        if revived {
190            // Only the spelling where this name's own slot is still served by
191            // the newer connection leaves a slot of its own to silence; when it
192            // is, the slot belongs to that live connection and keeps its rights.
193            slot.read_only = moved_on;
194        } else {
195            slot.dropped_at = None;
196            slot.read_only = false;
197            // The mount is a frame this session's agent sent, and taking it is
198            // what says the agent is here now. Without this the liveness stamp
199            // would still read the moment before the drop, and the sweep's
200            // silence arm would judge a returning agent that has not beaten yet
201            // on the age of a frame from the process before it.
202            slot.last_beat = Some(Instant::now());
203        }
204    }
205    if revived {
206        tracing::warn!(
207            session = %session_id,
208            task = %task,
209            "a plugin mounted a session this client already serves; it is held read-only"
210        );
211        return false;
212    }
213    if returning {
214        rebase_returned_reporter(inner, &key);
215    }
216    true
217}
218
219/// Rebase the watermark of a session whose agent came back, so its next frame
220/// lands.
221///
222/// A plugin restarts its own sequence at its base and its generation is a
223/// constant, while the watermark the row holds is the last sequence the process
224/// that left reached. Left alone, every frame the returning reporter sends reads
225/// at or below that watermark and is dropped as a stale duplicate until its
226/// sequence climbs past it — for a session that had been running a while, the
227/// whole rest of its work, reported into a tuple that never moves.
228///
229/// The rebase itself is [`rebase_generation`]'s: a new generation over the tuple
230/// the client already holds, because the generation is the half the reporter
231/// cannot be talked out of — its `generation` field is a constant it never
232/// raises, so stamping the beat with the session's own generation is what lets a
233/// frame from the new generation through at all, and the sequence starts again
234/// under it.
235///
236/// The body is the stored tuple with the agent dimension put back to `Booting`
237/// and the recovery line beside it dropped. That is not a guess about the agent:
238/// the connection that witnessed the last agent fact is the one that ended, and
239/// the plugin that just mounted has reported no turn fact yet, which is exactly
240/// what `Booting` means. `recovery` goes because the reducer's own coupling
241/// refuses a recovery substate beside a booting agent, and an accepted receipt
242/// goes to `Pending` for the same reason — the repair `compose_observation`
243/// already makes for a plugin that reports a booting process over a closed
244/// drain. Everything else the client owns — the delivery drain, the reconcile
245/// tuning, the counter — rides forward untouched, and so does the resource.
246///
247/// The generation being replaced is the one whose connection ended: this client
248/// is the only authority on its own connection bookkeeping, and it carries the
249/// observation content forward rather than replacing it, which is what the
250/// attestation the reducer asks for is guarding against. A reporter from a
251/// connection this client holds read-only never reaches the reducer at all —
252/// `serves_session` refuses its frames before the gate — so lowering the
253/// watermark here admits a returning reporter and nothing else.
254fn rebase_returned_reporter(inner: &mut DispatchInner, key: &str) {
255    let Some(slot) = inner.sessions.get(key) else {
256        return;
257    };
258    let task_id = slot.session.task_id.clone();
259    let verdict = rebase_generation(inner, &task_id, |stored| {
260        let mut body = stored.clone();
261        body.agent = AgentState::Booting;
262        body.recovery = RecoveryState::None;
263        if body.delivery == DeliveryState::Accepted {
264            body.delivery = DeliveryState::Pending;
265        }
266        body
267    });
268    match verdict {
269        Ok(Some(Verdict::Applied(next))) => tracing::info!(
270            task = %task_id,
271            generation = next.version.generation,
272            "a returning plugin's watermark was rebased onto a new generation"
273        ),
274        Ok(Some(verdict)) => tracing::warn!(
275            task = %task_id,
276            ?verdict,
277            "the returning plugin's watermark was not rebased"
278        ),
279        Ok(None) => tracing::warn!(
280            task = %task_id,
281            "the returning plugin's row was not there to rebase"
282        ),
283        Err(error) => tracing::warn!(
284            task = %task_id,
285            error = %error,
286            "the returning plugin's watermark was not rebased"
287        ),
288    }
289}
290
291/// Attach one plugin connection to the session it names, or hold it read-only.
292///
293/// This is the only place a mount becomes a transport, so the read-only
294/// connection of §1 (b) never lands in `transports` and never steals the
295/// assignment, delivery, or note addressed to the connection that serves the
296/// session now. Answers whether the connection took the session.
297fn attach_transport_locked(
298    inner: &mut DispatchInner,
299    session_id: &str,
300    io: AdapterIo,
301    capabilities: Vec<Capability>,
302) -> bool {
303    if !note_binding_locked(inner, session_id, &io) {
304        record_revived_connection(inner, session_id, io, capabilities);
305        return false;
306    }
307    inner
308        .transports
309        .insert(session_id.to_string(), (io, capabilities));
310    true
311}
312
313impl DispatchState {
314    /// Hold `io` for as long as one of its inbound frames is being handled.
315    pub fn hold_frame(&self, io: &AdapterIo) -> FrameGuard<'_> {
316        self.inner.lock().in_frame.push(io.clone());
317        FrameGuard {
318            state: self,
319            io: io.clone(),
320        }
321    }
322
323    /// Bind one adapter connection to the session it named.
324    ///
325    /// The name is the session id the client spawned the plugin with, which is
326    /// enough on its own: a plugin that mounts before the client staged its
327    /// session is remembered here and takes the payload the moment it is
328    /// staged, and a plugin that mounts after finds its session waiting.
329    pub fn bind_adapter(&self, session_id: &str, io: AdapterIo, capabilities: Vec<Capability>) {
330        attach_transport_locked(&mut self.inner.lock(), session_id, io, capabilities);
331    }
332
333    /// Remember the delivery handle for one task.
334    ///
335    /// The handle goes to the session serving the task, not to a read-only slot
336    /// that came back for it, so the ack this earns answers the live delivery.
337    pub fn attach_msg_id(&self, task_id: &str, msg_id: &str) {
338        let mut inner = self.inner.lock();
339        let Some(key) = slot_key_serving_task(&inner, task_id) else {
340            return;
341        };
342        if let Some(slot) = inner.sessions.get_mut(&key) {
343            slot.msg_id = Some(msg_id.to_string());
344        }
345    }
346
347    /// Take one plugin `send` frame and answer what the plugin is told.
348    ///
349    /// A live connection's envelope goes to the durable outbound queue exactly as
350    /// it always has, and the answer keeps the shape the plugin reads. A frame
351    /// from a connection this client holds read-only is held instead (§1 (c)): it
352    /// leaves as part of the merged handoff its task's completion routes, so the
353    /// recipient sees one message per downstream role and can tell which session
354    /// wrote which half of it.
355    pub fn plugin_send(&self, io: &AdapterIo, envelope: &Envelope) -> Result<serde_json::Value> {
356        let mut inner = self.inner.lock();
357        let Some(session_id) = inner
358            .revived
359            .iter()
360            .find(|(_, revived, _)| revived.same_connection(io))
361            .map(|(session_id, _, _)| session_id.clone())
362        else {
363            let op_id = queue_outbound_locked(&mut inner, envelope)?;
364            return Ok(serde_json::json!({"queued": true, "op_id": op_id}));
365        };
366        let task = slot_key_named(&inner, &session_id)
367            .and_then(|key| inner.sessions.get(&key))
368            .map(slot_task)
369            .unwrap_or(session_id);
370        let held = Handoff {
371            to_role: held_recipient(&envelope.to),
372            text: Some(envelope.body.text.clone().unwrap_or_default()),
373        };
374        tracing::warn!(
375            task = %task,
376            to = %held.to_role,
377            "a read-only session's send is held for that task's completion"
378        );
379        inner.held_handoffs.entry(task).or_default().push(held);
380        Ok(serde_json::json!({"queued": true, "held": true}))
381    }
382
383    /// Park one plugin connection as this role's waiting agent.
384    ///
385    /// Only a mount that names no session parks: it is a plugin that attached
386    /// before any work existed, so it takes the next session this role stages
387    /// (plan §6 line 285).
388    pub fn park_transport(&self, io: AdapterIo, capabilities: Vec<Capability>) {
389        self.inner.lock().parked = Some((io, capabilities));
390    }
391
392    /// Claim this role's waiting agent for one staged session.
393    ///
394    /// An always-running plugin mounts naming no session, so the park holds the
395    /// only connection that can serve the session staged next (plan §6 line 285).
396    /// The claim binds that connection to the session it takes. A claim left
397    /// unbound strands the staged work: the session has a payload and this
398    /// client holds no record of the socket that serves it.
399    pub(super) fn claim_parked_transport(
400        &self,
401        session_id: &str,
402    ) -> Option<(AdapterIo, Vec<Capability>)> {
403        let mut inner = self.inner.lock();
404        let (io, capabilities) = inner.parked.take()?;
405        if !attach_transport_locked(&mut inner, session_id, io.clone(), capabilities.clone()) {
406            // The waiting agent is an older connection returning for a session
407            // the role already serves: it holds the socket and takes nothing.
408            return None;
409        }
410        Some((io, capabilities))
411    }
412
413    /// The connection that serves one session, when its plugin is attached.
414    ///
415    /// A plugin names the session it was spawned for, and the slot's key is the
416    /// other spelling worth trying.
417    pub fn session_transport(&self, session_id: &str) -> Option<(AdapterIo, Vec<Capability>)> {
418        let inner = self.inner.lock();
419        if let Some(transport) = inner.transports.get(session_id) {
420            return Some(transport.clone());
421        }
422        let key = inner
423            .sessions
424            .iter()
425            .find(|(key, slot)| names_session(key, slot, session_id))
426            .map(|(key, _)| key.clone())?;
427        inner.transports.get(&key).cloned()
428    }
429
430    /// Tell the plugin serving one session to tear itself down, when that plugin
431    /// implements `recycle`. A plugin without the capability is skipped: the
432    /// caller's backend close stops the process either way.
433    pub async fn recycle_plugin(&self, task_id: &str, reason: &str, outcome: Option<Outcome>) {
434        let Some((io, capabilities)) = self.session_transport(task_id) else {
435            return;
436        };
437        if missing_capability(&capabilities, Capability::Recycle) {
438            tracing::debug!(
439                task = %task_id,
440                "plugin does not implement recycle; the host closes the resource"
441            );
442            return;
443        }
444        let args = RecycleArgs {
445            task_id: task_id.to_string(),
446            reason: reason.to_string(),
447            outcome,
448        };
449        if let Err(error) = io.notify(AdapterMsg::Host(HostOp::Recycle(args))).await {
450            tracing::warn!(error = %error, task = %task_id, "recycle frame did not reach the plugin");
451        }
452    }
453
454    /// Ask the plugin serving one session for a fresh observation.
455    ///
456    /// The plugin answers with a heartbeat report, which is the reducer's
457    /// evidence and the projection the operator reads. Answers whether a probe
458    /// frame actually went out, and `false` says nothing was asked: a session no
459    /// connection serves has no plugin to put the question to, and a `notify` that
460    /// failed left the frame in this process. The caller must not record either
461    /// as a probe that landed, because the answer a live plugin would have given
462    /// never existed — the projection that says a plugin is gone is written when
463    /// the session ends, never by this call.
464    pub async fn probe_plugin(&self, task_id: &str) -> bool {
465        let Some((io, _)) = self.session_transport(task_id) else {
466            tracing::warn!(task = %task_id, "probe found no plugin connection to ask");
467            return false;
468        };
469        let request = serde_json::json!({"task_id": task_id});
470        if let Err(error) = io.notify(AdapterMsg::Host(HostOp::Probe(request))).await {
471            tracing::warn!(error = %error, task = %task_id, "probe frame did not reach the plugin");
472            return false;
473        }
474        true
475    }
476
477    /// Release the bindings served by one plugin connection.
478    ///
479    /// A graceful detach retires each idle session because the agent that owned
480    /// it has left. An attached transport preserves the idle resource because
481    /// the same agent is still reachable. A connection ending through
482    /// another path preserves the slot and resource for an agent reconnection and
483    /// starts the reconnect clock on it, which is what bounds how long a session
484    /// waits for an agent that is never coming back. Every released binding
485    /// retires its task progress clock. A slot carrying work remains under
486    /// lifecycle ownership, and it carries that same clock: a goodbye and a
487    /// silent drop both leave no heartbeat coming for the task it owes, and the
488    /// window is what ends a session whose agent never returns.
489    ///
490    /// The session ids a goodbye retired come back, because that retirement wrote
491    /// their rows: the resource close and, for a completed session, the agent's
492    /// exit. The server mirrors only what this client reports, and a publish
493    /// cannot run under this lock, so the caller is handed what to publish — the
494    /// same answer [`DispatchState::retire_dropped_ghosts`] gives its sweep.
495    pub fn release_connection(
496        &self,
497        session_id: Option<&str>,
498        io: &AdapterIo,
499        graceful_detach: bool,
500    ) -> Vec<String> {
501        let mut inner = self.inner.lock();
502        // A read-only connection ending is not the session losing its agent: the
503        // live connection still serves it, and its drop clock stays untouched.
504        let revived_connection = {
505            let before = inner.revived.len();
506            inner
507                .revived
508                .retain(|(_, revived, _)| !revived.same_connection(io));
509            before != inner.revived.len()
510        };
511        if inner
512            .parked
513            .as_ref()
514            .is_some_and(|(parked, _)| parked.same_connection(io))
515        {
516            inner.parked = None;
517        }
518        let released: Vec<String> = inner
519            .transports
520            .iter()
521            .filter(|(session, (transport, _))| {
522                transport.same_connection(io)
523                    && session_id.is_none_or(|mounted| mounted == session.as_str())
524            })
525            .map(|(session, _)| session.clone())
526            .collect();
527        let served_tasks: Vec<String> = released
528            .iter()
529            .map(|session| {
530                inner
531                    .sessions
532                    .iter()
533                    .find(|(key, slot)| names_session(key, slot, session))
534                    .map(|(_, slot)| {
535                        slot.task_id
536                            .clone()
537                            .unwrap_or_else(|| slot.session.task_id.clone())
538                    })
539                    .unwrap_or_else(|| session.clone())
540            })
541            .collect();
542        for task_id in served_tasks {
543            inner.stall.forget(&task_id);
544        }
545        for session in &released {
546            inner.transports.remove(session);
547        }
548        if !revived_connection {
549            // The window of `[client] reconnect_grace_secs` starts wherever a
550            // session loses the connection that would have sent its next
551            // heartbeat and no other one is attached: the agent left without
552            // saying so — every session that connection served — or it said
553            // goodbye while its session still owed a task, which leaves no beat
554            // coming either. The session keeps its slot and its resource for the
555            // window, and the mount that returns inside it clears the stamp. A
556            // gracefully detached session holding no task needs no window: the
557            // idle retirement below takes that slot now.
558            let now = Instant::now();
559            for session in &released {
560                if let Some((_, slot)) = inner.sessions.iter_mut().find(|(key, slot)| {
561                    names_session(key, slot, session)
562                        && (!graceful_detach || slot.task_id.is_some())
563                }) {
564                    slot.dropped_at = Some(now);
565                }
566            }
567        }
568        for session in &released {
569            // Nothing serves this name any more, so the first connection that
570            // mounted it read-only behind the one that just went becomes its
571            // transport; a session with no such connection keeps waiting out the
572            // reconnect grace, which is the sweep's to answer.
573            if let Some(key) = slot_key_named(&inner, session) {
574                promote_held_connection(&mut inner, &key);
575            }
576        }
577        let mut retired: Vec<String> = Vec::new();
578        if graceful_detach {
579            let idle: Vec<String> = released
580                .iter()
581                .filter_map(|session| {
582                    inner
583                        .sessions
584                        .iter()
585                        .find(|(key, slot)| {
586                            names_session(key, slot, session) && slot.task_id.is_none()
587                        })
588                        .map(|(key, _)| key.clone())
589                })
590                .collect();
591            for key in idle {
592                // The id that travels is the session's own, the one whose row the
593                // retirement below is about to write.
594                let Some(task_id) = inner
595                    .sessions
596                    .get(&key)
597                    .map(|slot| slot.session.task_id.clone())
598                else {
599                    continue;
600                };
601                let reason = inner
602                    .sessions
603                    .get(&key)
604                    .and_then(|slot| stored_close_reason(&inner, &slot.session.task_id))
605                    .unwrap_or(onlyne_session::CloseReason::Completed);
606                if retire_idle_locked(&mut inner, &key, reason) {
607                    retired.push(task_id);
608                }
609            }
610        }
611        retired
612    }
613}
614
615#[cfg(test)]
616mod tests;