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;