liminal_server/server/connection/supervisor.rs
1use std::collections::HashMap;
2use std::net::{SocketAddr, TcpStream};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
5use std::sync::{Arc, Mutex, MutexGuard};
6use std::time::Duration;
7
8use beamr::atom::{Atom, AtomTable};
9use beamr::module::ModuleRegistry;
10use beamr::native::native_process::NativeHandlerFactory;
11use beamr::process::ExitReason;
12use beamr::scheduler::{Scheduler, SchedulerConfig};
13
14use liminal::protocol::WorkerRegistration;
15
16use super::notifier::ConnectionNotifier;
17use super::process::ConnectionProcess;
18use super::services::{ConnectionServices, LiminalConnectionServices};
19use crate::ServerError;
20use crate::config::types::ServerConfig;
21
22const CONNECTION_SCHEDULER_THREADS: usize = 4;
23const CONNECTION_SHUTDOWN_CONTROL_ATOM: &str = "liminal_server_connection_shutdown_control";
24
25#[cfg(test)]
26#[path = "supervisor_tests.rs"]
27mod tests;
28
29/// Supervisor that owns the beamr scheduler for per-connection processes.
30#[derive(Clone, Debug)]
31pub struct ConnectionSupervisor {
32 inner: Arc<SupervisorInner>,
33}
34
35impl ConnectionSupervisor {
36 /// Creates a connection supervisor backed by the configured liminal channels.
37 ///
38 /// # Errors
39 /// Returns [`ServerError`] when channel initialization or scheduler startup fails.
40 pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
41 let services = Arc::new(LiminalConnectionServices::from_config(config)?);
42 // The configured token (if any) is carried opaquely as bytes for a
43 // constant-time comparison against the handshake's `auth_token`. Absent
44 // `[auth]` leaves it `None`, so the connection stays open-access.
45 let auth_token = config
46 .auth
47 .as_ref()
48 .map(|auth| auth.token.clone().into_bytes());
49 SupervisorInner::new(services, None, auth_token).map(|inner| Self {
50 inner: Arc::new(inner),
51 })
52 }
53
54 /// Creates a connection supervisor with no configured channels.
55 ///
56 /// # Errors
57 /// Returns [`ServerError`] when scheduler startup fails.
58 pub fn new() -> Result<Self, ServerError> {
59 Self::with_services(Arc::new(LiminalConnectionServices::empty()?))
60 }
61
62 /// Creates a connection supervisor using an explicit service adapter.
63 ///
64 /// # Errors
65 /// Returns [`ServerError`] when scheduler startup fails.
66 pub fn with_services(services: Arc<dyn ConnectionServices>) -> Result<Self, ServerError> {
67 SupervisorInner::new(services, None, None).map(|inner| Self {
68 inner: Arc::new(inner),
69 })
70 }
71
72 /// Creates a connection supervisor with an explicit service adapter and the
73 /// configured connection auth token.
74 ///
75 /// This is the production constructor for callers that build services
76 /// themselves (the runtime needs the shared channel cluster before the
77 /// supervisor takes ownership) and therefore cannot use
78 /// [`Self::from_config`]: without it the configured `[auth]` token would be
79 /// silently dropped and the server would run open-access.
80 ///
81 /// # Errors
82 /// Returns [`ServerError`] when scheduler startup fails.
83 pub fn with_services_and_auth(
84 services: Arc<dyn ConnectionServices>,
85 auth_token: Option<Vec<u8>>,
86 ) -> Result<Self, ServerError> {
87 SupervisorInner::new(services, None, auth_token).map(|inner| Self {
88 inner: Arc::new(inner),
89 })
90 }
91
92 /// Creates a connection supervisor with an explicit service adapter and a
93 /// connection-keyed worker-registration notifier.
94 ///
95 /// The `notifier` is invoked when a worker registers on a connection and when
96 /// such a connection closes. Supervisors built via [`Self::with_services`],
97 /// [`Self::from_config`], or [`Self::new`] carry no notifier, so liminal still
98 /// runs standalone; a `WorkerRegister` frame is then accepted without any
99 /// application callback.
100 ///
101 /// # Errors
102 /// Returns [`ServerError`] when scheduler startup fails.
103 pub fn with_services_and_notifier(
104 services: Arc<dyn ConnectionServices>,
105 notifier: Arc<dyn ConnectionNotifier>,
106 ) -> Result<Self, ServerError> {
107 SupervisorInner::new(services, Some(notifier), None).map(|inner| Self {
108 inner: Arc::new(inner),
109 })
110 }
111
112 /// Spawns one supervised beamr process that owns `stream`.
113 ///
114 /// # Errors
115 /// Returns [`ServerError`] when stream configuration or beamr spawn fails.
116 pub fn spawn_connection(&self, stream: TcpStream) -> Result<ConnectionHandle, ServerError> {
117 self.inner.spawn_connection(stream)
118 }
119
120 /// Returns the underlying beamr scheduler.
121 #[must_use]
122 pub fn scheduler(&self) -> Arc<Scheduler> {
123 Arc::clone(&self.inner.scheduler)
124 }
125
126 /// Reaps connection processes that have exited outside the normal handler path.
127 #[must_use]
128 pub fn reap_crashed_connections(&self) -> usize {
129 self.inner.runtime.reap_crashed(&self.inner.scheduler)
130 }
131
132 /// Returns true when `pid` is still tracked by the supervisor.
133 #[must_use]
134 pub fn is_tracked(&self, pid: u64) -> bool {
135 self.inner.runtime.contains(pid)
136 }
137
138 /// Returns the number of tracked live connections.
139 #[must_use]
140 pub fn active_connection_count(&self) -> usize {
141 self.inner.runtime.active_count()
142 }
143
144 /// Returns the beamr process ids of the currently tracked live connections.
145 ///
146 /// Useful for addressing a specific connection — e.g. as the `pid` argument to
147 /// [`push_to_connection`](Self::push_to_connection) when the caller knows there
148 /// is a single connected client.
149 #[must_use]
150 pub fn active_connection_pids(&self) -> Vec<u64> {
151 self.inner
152 .runtime
153 .active_connections()
154 .into_iter()
155 .map(|connection| connection.pid)
156 .collect()
157 }
158
159 /// Broadcasts a best-effort shutdown notification to active connections.
160 ///
161 /// Connections with no active subscriptions ignore the notification. Failures
162 /// to enqueue the control message are logged and skipped; they are not retried.
163 pub fn notify_shutdown_subscribers(&self) {
164 self.inner
165 .broadcast_control(&ConnectionControl::NotifyShutdown);
166 }
167
168 /// Sends a force-close control message to every tracked connection process.
169 ///
170 /// Each live process attempts one shutdown notification before closing its
171 /// stream and exiting normally. Enqueue failures are logged and skipped.
172 pub fn force_close_active_connections(&self) {
173 for connection in self.inner.runtime.active_connections() {
174 tracing::warn!(
175 connection_pid = connection.pid,
176 peer_addr = ?connection.peer_addr,
177 "forcefully closing connection after drain timeout"
178 );
179 if !self
180 .inner
181 .enqueue_control(connection.pid, ConnectionControl::ForceClose)
182 {
183 tracing::warn!(
184 connection_pid = connection.pid,
185 peer_addr = ?connection.peer_addr,
186 "failed to request forceful connection close; process is not live"
187 );
188 }
189 }
190 }
191
192 /// Pushes an opaque payload to a specific connected client over that client's
193 /// existing connection and returns an awaiter for the client's correlated reply.
194 ///
195 /// This is the server-initiated leg (server-to-client), the inverse of every
196 /// other request frame. It allocates a correlation id, registers a one-shot
197 /// reply slot keyed by that id, and enqueues a [`ConnectionControl::Push`] for
198 /// the connection process owning `pid`; that process writes a [`Frame::Push`]
199 /// out on its socket. When the client answers with a `PushReply` carrying the
200 /// same correlation id, the connection process resolves the awaiter's slot. The
201 /// returned [`PushReplyAwaiter`] blocks (bounded) for that reply.
202 ///
203 /// # Errors
204 /// Returns [`ServerError`] when the correlation id cannot be allocated, the
205 /// reply slot cannot be registered, or the control message cannot be enqueued
206 /// for the (possibly already-gone) connection process.
207 pub fn push_to_connection(
208 &self,
209 pid: u64,
210 payload: Vec<u8>,
211 ) -> Result<PushReplyAwaiter, ServerError> {
212 let correlation_id = self.inner.runtime.next_push_correlation_id();
213 let receiver = self.inner.runtime.register_push(pid, correlation_id)?;
214 let control = ConnectionControl::Push {
215 correlation_id,
216 payload,
217 };
218 if self.inner.enqueue_control(pid, control) {
219 Ok(PushReplyAwaiter {
220 correlation_id,
221 receiver,
222 })
223 } else {
224 // The process is gone; drop the now-unreachable reply slot so it cannot
225 // leak in the correlation registry.
226 self.inner.runtime.cancel_push(correlation_id);
227 Err(ServerError::ListenerAccept {
228 message: format!("cannot push to connection process {pid}: process is not live"),
229 })
230 }
231 }
232
233 /// Flushes durable channel state through the configured liminal services.
234 ///
235 /// # Errors
236 /// Returns [`ServerError::ShutdownFlush`] when the underlying service flush fails.
237 pub fn flush_durable_state(&self) -> Result<(), ServerError> {
238 self.inner.runtime.services().flush_durable_state()
239 }
240
241 /// Stops the beamr scheduler used by connection processes.
242 pub fn shutdown(&self) {
243 self.inner.scheduler.shutdown();
244 }
245}
246
247/// Handle for one supervised connection process.
248#[derive(Clone, Debug)]
249pub struct ConnectionHandle {
250 pid: u64,
251 peer_addr: Option<SocketAddr>,
252 supervisor: Arc<SupervisorInner>,
253}
254
255impl ConnectionHandle {
256 /// Returns the beamr process id for this connection.
257 #[must_use]
258 pub const fn pid(&self) -> u64 {
259 self.pid
260 }
261
262 /// Returns the peer address if it was available from the accepted stream.
263 #[must_use]
264 pub const fn peer_addr(&self) -> Option<SocketAddr> {
265 self.peer_addr
266 }
267
268 /// Returns whether the beamr process is still live.
269 #[must_use]
270 pub fn is_live(&self) -> bool {
271 self.supervisor
272 .scheduler
273 .process_table()
274 .get(self.pid)
275 .is_some()
276 }
277
278 /// Requests an error exit for tests and supervisor control paths.
279 ///
280 /// # Errors
281 /// Returns [`ServerError`] when the process is no longer live.
282 pub fn request_crash(&self) -> Result<(), ServerError> {
283 if self
284 .supervisor
285 .scheduler
286 .enqueue_atom_message(self.pid, Atom::ERROR)
287 {
288 Ok(())
289 } else {
290 Err(ServerError::ListenerAccept {
291 message: format!("connection process {} is not live", self.pid),
292 })
293 }
294 }
295}
296
297/// Awaits the correlated reply to a single server-initiated push.
298///
299/// Returned by [`ConnectionSupervisor::push_to_connection`]. The reply slot is
300/// resolved when the originating connection process receives a `PushReply` frame
301/// carrying the same correlation id, so [`PushReplyAwaiter::receive`] blocks
302/// (bounded) for that one correlated answer.
303#[derive(Debug)]
304pub struct PushReplyAwaiter {
305 correlation_id: u64,
306 receiver: Receiver<Vec<u8>>,
307}
308
309impl PushReplyAwaiter {
310 /// Returns the correlation id this awaiter is matched on.
311 #[must_use]
312 pub const fn correlation_id(&self) -> u64 {
313 self.correlation_id
314 }
315
316 /// Blocks up to `timeout` for the client's correlated reply payload.
317 ///
318 /// # Errors
319 /// Returns [`ServerError::PushReplyTimeout`] when no reply arrives within
320 /// `timeout` (the worker is connected but slow), or
321 /// [`ServerError::PushReplyDisconnected`] when the connection process dropped
322 /// the reply slot (the connection closed — the prompt worker-death signal).
323 /// The two are distinct variants so callers classify by type, not message.
324 pub fn receive(&self, timeout: Duration) -> Result<Vec<u8>, ServerError> {
325 self.receiver
326 .recv_timeout(timeout)
327 .map_err(|error| match error {
328 RecvTimeoutError::Timeout => ServerError::PushReplyTimeout {
329 correlation_id: self.correlation_id,
330 },
331 RecvTimeoutError::Disconnected => ServerError::PushReplyDisconnected {
332 correlation_id: self.correlation_id,
333 },
334 })
335 }
336}
337
338pub(super) struct SupervisorInner {
339 scheduler: Arc<Scheduler>,
340 runtime: Arc<ConnectionRuntime>,
341}
342
343impl std::fmt::Debug for SupervisorInner {
344 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345 formatter
346 .debug_struct("SupervisorInner")
347 .field("runtime", &self.runtime)
348 .finish_non_exhaustive()
349 }
350}
351
352impl SupervisorInner {
353 fn new(
354 services: Arc<dyn ConnectionServices>,
355 notifier: Option<Arc<dyn ConnectionNotifier>>,
356 auth_token: Option<Vec<u8>>,
357 ) -> Result<Self, ServerError> {
358 let atoms = AtomTable::with_common_atoms();
359 let control_atom = atoms.intern(CONNECTION_SHUTDOWN_CONTROL_ATOM);
360 let registry = Arc::new(ModuleRegistry::new());
361
362 let scheduler = Scheduler::new(
363 SchedulerConfig {
364 thread_count: Some(CONNECTION_SCHEDULER_THREADS),
365 ..SchedulerConfig::default()
366 },
367 registry,
368 )
369 .map_err(|message| ServerError::ListenerAccept {
370 message: format!("failed to start connection scheduler: {message}"),
371 })?;
372 Ok(Self {
373 scheduler: Arc::new(scheduler),
374 runtime: Arc::new(ConnectionRuntime::new(
375 services,
376 control_atom,
377 notifier,
378 auth_token,
379 )),
380 })
381 }
382
383 fn spawn_connection(
384 self: &Arc<Self>,
385 stream: TcpStream,
386 ) -> Result<ConnectionHandle, ServerError> {
387 stream
388 .set_nonblocking(true)
389 .map_err(|error| ServerError::ListenerAccept {
390 message: format!("failed to configure connection stream: {error}"),
391 })?;
392 let peer_addr = stream.peer_addr().ok();
393 let holder = Arc::new(Mutex::new(Some(stream)));
394 let runtime = Arc::clone(&self.runtime);
395 let process_holder = Arc::clone(&holder);
396 let factory: NativeHandlerFactory = Box::new(move || {
397 Box::new(ConnectionProcess::from_holder(
398 Arc::clone(&runtime),
399 peer_addr,
400 &process_holder,
401 ))
402 });
403 let pid =
404 self.scheduler
405 .spawn_native(factory)
406 .map_err(|error| ServerError::ListenerAccept {
407 message: format!("failed to spawn connection process: {error}"),
408 })?;
409 self.runtime.register(pid, peer_addr)?;
410 Ok(ConnectionHandle {
411 pid,
412 peer_addr,
413 supervisor: Arc::clone(self),
414 })
415 }
416
417 fn broadcast_control(&self, control: &ConnectionControl) {
418 for connection in self.runtime.active_connections() {
419 if !self.enqueue_control(connection.pid, control.clone()) {
420 tracing::debug!(
421 connection_pid = connection.pid,
422 peer_addr = ?connection.peer_addr,
423 ?control,
424 "connection control message skipped because process is not live"
425 );
426 }
427 }
428 }
429
430 fn enqueue_control(&self, pid: u64, control: ConnectionControl) -> bool {
431 // Keep a key for the failure-path removal before the control is moved into
432 // the queue, so a non-`Copy` (push) control can still be located and pulled
433 // back out if the scheduler wakeup fails.
434 let removal_key = control.clone();
435 if self.runtime.push_control(pid, control).is_err() {
436 return false;
437 }
438 if self
439 .scheduler
440 .enqueue_atom_message(pid, self.runtime.control_atom())
441 {
442 true
443 } else {
444 self.runtime.remove_control(pid, &removal_key);
445 false
446 }
447 }
448}
449
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub(super) enum ConnectionControl {
452 NotifyShutdown,
453 ForceClose,
454 /// Server-initiated push of an opaque payload, correlated by `correlation_id`,
455 /// to be written out as a [`Frame::Push`] by the receiving connection process.
456 Push {
457 correlation_id: u64,
458 payload: Vec<u8>,
459 },
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
463pub struct ActiveConnection {
464 pid: u64,
465 peer_addr: Option<SocketAddr>,
466}
467
468#[derive(Debug)]
469pub(super) struct ConnectionRuntime {
470 services: Arc<dyn ConnectionServices>,
471 records: Mutex<HashMap<u64, ConnectionRecord>>,
472 controls: Mutex<Vec<QueuedConnectionControl>>,
473 control_atom: Atom,
474 /// One-shot reply slots for in-flight server pushes, keyed by correlation id.
475 /// The supervisor registers a slot in `push_to_connection`; the connection
476 /// process resolves it when the matching `PushReply` frame arrives. Each slot
477 /// records the owning connection pid so the close path can drop a connection's
478 /// outstanding slots and wake their awaiters with a prompt disconnected error.
479 push_replies: Mutex<HashMap<u64, PendingPush>>,
480 /// Monotonic source of push correlation ids. Server-allocated, so it never
481 /// collides with a client-chosen id on this connection.
482 next_push_id: AtomicU64,
483 /// Optional application hook invoked on worker registration and on the close
484 /// of a connection that had registered. `None` keeps liminal standalone: a
485 /// `WorkerRegister` is accepted with no callback.
486 notifier: Option<Arc<dyn ConnectionNotifier>>,
487 /// Configured connection auth token (the `[auth]` section's token as opaque
488 /// bytes). `Some` gates the `Connect` handshake — the frame's `auth_token` must
489 /// match under a constant-time comparison; `None` leaves the server open-access,
490 /// byte-identical to the pre-auth behaviour.
491 auth_token: Option<Vec<u8>>,
492}
493
494impl ConnectionRuntime {
495 fn new(
496 services: Arc<dyn ConnectionServices>,
497 control_atom: Atom,
498 notifier: Option<Arc<dyn ConnectionNotifier>>,
499 auth_token: Option<Vec<u8>>,
500 ) -> Self {
501 Self {
502 services,
503 records: Mutex::new(HashMap::new()),
504 controls: Mutex::new(Vec::new()),
505 control_atom,
506 push_replies: Mutex::new(HashMap::new()),
507 next_push_id: AtomicU64::new(1),
508 notifier,
509 auth_token,
510 }
511 }
512
513 /// Builds a runtime wrapping `services` for unit tests that exercise
514 /// `apply_frame` without a live scheduler. Uses a fresh interned control atom
515 /// and no notifier.
516 #[cfg(test)]
517 pub(super) fn for_tests(services: Arc<dyn ConnectionServices>) -> Self {
518 let atoms = AtomTable::with_common_atoms();
519 let control_atom = atoms.intern(CONNECTION_SHUTDOWN_CONTROL_ATOM);
520 Self::new(services, control_atom, None, None)
521 }
522
523 /// Builds a runtime wrapping `services` with a configured auth `token` for unit
524 /// tests that exercise the `Connect` handshake enforcement without a live
525 /// scheduler. Uses a fresh interned control atom and no notifier.
526 #[cfg(test)]
527 pub(super) fn for_tests_with_auth_token(
528 services: Arc<dyn ConnectionServices>,
529 token: Vec<u8>,
530 ) -> Self {
531 let atoms = AtomTable::with_common_atoms();
532 let control_atom = atoms.intern(CONNECTION_SHUTDOWN_CONTROL_ATOM);
533 Self::new(services, control_atom, None, Some(token))
534 }
535
536 /// Builds a runtime wrapping `services` with a `notifier` for unit tests that
537 /// exercise `apply_frame` and the close path without a live scheduler.
538 #[cfg(test)]
539 pub(super) fn for_tests_with_notifier(
540 services: Arc<dyn ConnectionServices>,
541 notifier: Arc<dyn ConnectionNotifier>,
542 ) -> Self {
543 let atoms = AtomTable::with_common_atoms();
544 let control_atom = atoms.intern(CONNECTION_SHUTDOWN_CONTROL_ATOM);
545 Self::new(services, control_atom, Some(notifier), None)
546 }
547
548 pub(super) fn services(&self) -> &dyn ConnectionServices {
549 self.services.as_ref()
550 }
551
552 /// Returns the configured connection auth token as opaque bytes, or `None` when
553 /// no `[auth]` section was configured (open access).
554 pub(super) fn auth_token(&self) -> Option<&[u8]> {
555 self.auth_token.as_deref()
556 }
557
558 /// Returns the configured connection-keyed notifier, if any.
559 pub(super) fn notifier(&self) -> Option<&Arc<dyn ConnectionNotifier>> {
560 self.notifier.as_ref()
561 }
562
563 /// Offers a channel publish to the notifier's observability-drain tap, returning
564 /// `true` when the application consumed it (so the connection process skips the
565 /// normal fan-out). `false` when no notifier is installed (liminal standalone) or
566 /// the notifier did not recognise the channel, so the caller can invoke it
567 /// unconditionally and fall through to the normal publish path.
568 pub(super) fn notifier_channel_publish(&self, pid: u64, channel: &str, payload: &[u8]) -> bool {
569 self.notifier
570 .as_ref()
571 .is_some_and(|notifier| notifier.on_channel_publish(pid, channel, payload))
572 }
573
574 /// Stores `registration` on the connection record for `pid`, so the close
575 /// path can later fire `on_worker_unregistered` for exactly the connections
576 /// that registered. A missing record (the connection already closed) is a
577 /// no-op.
578 ///
579 /// # Errors
580 /// Returns [`ServerError`] when the connection registry mutex is poisoned.
581 pub(super) fn set_registration(
582 &self,
583 pid: u64,
584 registration: WorkerRegistration,
585 ) -> Result<(), ServerError> {
586 if let Some(record) = lock(&self.records, "connection registry")?.get_mut(&pid) {
587 record.registration = Some(registration);
588 }
589 Ok(())
590 }
591
592 /// Allocates the next monotonic push correlation id.
593 fn next_push_correlation_id(&self) -> u64 {
594 self.next_push_id.fetch_add(1, Ordering::Relaxed)
595 }
596
597 /// Registers a one-shot reply slot for `correlation_id`, owned by connection
598 /// `pid`, and returns its receiver. The connection process resolves the slot
599 /// via [`resolve_push`]; the close path drops the connection's outstanding
600 /// slots via [`cancel_pushes_for_connection`].
601 ///
602 /// # Errors
603 /// Returns [`ServerError`] when the correlation registry mutex is poisoned.
604 fn register_push(
605 &self,
606 pid: u64,
607 correlation_id: u64,
608 ) -> Result<Receiver<Vec<u8>>, ServerError> {
609 let (sender, receiver) = channel();
610 lock(&self.push_replies, "push correlation registry")?
611 .insert(correlation_id, PendingPush { pid, sender });
612 Ok(receiver)
613 }
614
615 /// Drops a registered reply slot without resolving it (the push could not be
616 /// delivered, so no reply can ever arrive). Dropping the slot's `Sender` wakes
617 /// the awaiter with a disconnected error.
618 pub(super) fn cancel_push(&self, correlation_id: u64) {
619 if let Ok(mut slots) = self.push_replies.lock() {
620 slots.remove(&correlation_id);
621 }
622 }
623
624 /// Drops every reply slot owned by connection `pid`, waking each awaiter with a
625 /// disconnected error (the dropped `Sender` disconnects the awaiter's
626 /// `Receiver`). Called from the close path so a connection that exits with
627 /// in-flight pushes signals worker death immediately instead of leaving each
628 /// awaiter to block the full push-reply timeout. A slot that [`resolve_push`]
629 /// already removed is gone, so it is untouched here; an unknown pid is a no-op.
630 fn cancel_pushes_for_connection(&self, pid: u64) {
631 if let Ok(mut slots) = self.push_replies.lock() {
632 slots.retain(|_correlation_id, pending| pending.pid != pid);
633 }
634 }
635
636 /// Resolves the reply slot for `correlation_id` with the client's reply
637 /// payload, waking the [`PushReplyAwaiter`]. Called by the connection process
638 /// when a correlated `PushReply` frame arrives. A missing slot (already
639 /// resolved, cancelled, or unknown id) is ignored.
640 pub(super) fn resolve_push(&self, correlation_id: u64, payload: Vec<u8>) {
641 let pending = self
642 .push_replies
643 .lock()
644 .ok()
645 .and_then(|mut slots| slots.remove(&correlation_id));
646 if let Some(pending) = pending {
647 // The receiver may already be gone if the awaiter timed out; a failed
648 // send is benign (the reply is simply discarded).
649 pending.sender.send(payload).ok();
650 }
651 }
652
653 pub(super) const fn control_atom(&self) -> Atom {
654 self.control_atom
655 }
656
657 /// Sole registration path for a connection: the spawn thread inserts the
658 /// record synchronously, before `spawn_connection` returns the handle, so
659 /// `is_tracked`/`active_connection_count` reflect the connection
660 /// immediately. The connection handler never writes the registry (it only
661 /// reads via `mark_crashed`/`finish`), so there is a single writer here and
662 /// no register/ensure-register race.
663 ///
664 /// Ordering note: `spawn_native` only enqueues the process, so its first
665 /// slice may run on another worker thread before this insert lands. If that
666 /// first slice exits immediately (e.g. a missing-stream crash) its
667 /// `mark_crashed`/`finish` removes nothing and this insert then leaves a
668 /// record for an already-dead pid. That orphan is self-healing:
669 /// `reap_crashed`, driven continuously by the listener loop, removes any
670 /// record whose pid is absent from the scheduler process table.
671 fn register(&self, pid: u64, peer_addr: Option<SocketAddr>) -> Result<(), ServerError> {
672 lock(&self.records, "connection registry")?.insert(
673 pid,
674 ConnectionRecord {
675 peer_addr,
676 registration: None,
677 },
678 );
679 // Single-writer insert (see doc above) pairs one gauge increment with the
680 // decrement in `remove`, keeping `liminal_connections_active` equal to the
681 // live record count on every teardown route.
682 crate::metrics::connection_spawned();
683 Ok(())
684 }
685
686 pub(super) fn mark_crashed(&self, pid: u64, reason: ExitReason, peer_addr: Option<SocketAddr>) {
687 let removed = self.remove(pid).unwrap_or(ConnectionRecord {
688 peer_addr,
689 registration: None,
690 });
691 self.fire_unregistered(pid, &removed);
692 tracing::warn!(
693 connection_pid = pid,
694 peer_addr = ?removed.peer_addr,
695 reason = ?reason,
696 "connection process crashed"
697 );
698 }
699
700 pub(super) fn finish(&self, pid: u64) {
701 if let Some(removed) = self.remove(pid) {
702 self.fire_unregistered(pid, &removed);
703 }
704 }
705
706 /// Invokes `on_worker_unregistered` for a removed connection record that
707 /// carried a worker registration. A record with no registration (a plain
708 /// connection, or a worker connection that never registered) is a no-op, so
709 /// only connections that actually registered deregister.
710 fn fire_unregistered(&self, pid: u64, record: &ConnectionRecord) {
711 if record.registration.is_some() {
712 if let Some(notifier) = self.notifier.as_ref() {
713 notifier.on_worker_unregistered(pid);
714 }
715 }
716 }
717
718 fn reap_crashed(&self, scheduler: &Scheduler) -> usize {
719 let pids = match self.records.lock() {
720 Ok(records) => records.keys().copied().collect::<Vec<_>>(),
721 Err(error) => {
722 tracing::warn!(%error, "connection registry unavailable during crash reap");
723 return 0;
724 }
725 };
726 let mut reaped = 0;
727 for pid in pids {
728 if scheduler.process_table().get(pid).is_none() {
729 let removed = self.remove(pid);
730 if let Some(record) = removed.as_ref() {
731 self.fire_unregistered(pid, record);
732 }
733 let peer_addr = removed.and_then(|record| record.peer_addr);
734 // This process exited without ever reaching `mark_crashed`/`finish`
735 // (e.g. the beamr scheduler terminated it externally). beamr records
736 // the real `ExitReason` in its private `exit_tombstones` map, but its
737 // public `Scheduler` API exposes no non-blocking accessor for it
738 // (only `run_until_exit`, which blocks). So we cannot recover the true
739 // reason here; log a truthful, specific message rather than the
740 // misleading literal "unknown". If beamr later grows a public,
741 // non-blocking exit-reason query for a dead pid, read it here instead.
742 tracing::warn!(
743 connection_pid = pid,
744 ?peer_addr,
745 reason = "terminated externally (no exit reason recorded by supervisor)",
746 "connection process crashed"
747 );
748 reaped += 1;
749 }
750 }
751 reaped
752 }
753
754 fn contains(&self, pid: u64) -> bool {
755 self.records
756 .lock()
757 .is_ok_and(|records| records.contains_key(&pid))
758 }
759
760 fn active_connections(&self) -> Vec<ActiveConnection> {
761 self.records.lock().map_or_else(
762 |_| Vec::new(),
763 |records| {
764 records
765 .iter()
766 .map(|(&pid, record)| ActiveConnection {
767 pid,
768 peer_addr: record.peer_addr,
769 })
770 .collect()
771 },
772 )
773 }
774
775 fn push_control(&self, pid: u64, control: ConnectionControl) -> Result<(), ServerError> {
776 lock(&self.controls, "connection control queue")?
777 .push(QueuedConnectionControl { pid, control });
778 Ok(())
779 }
780
781 pub(super) fn pop_control(&self, pid: u64) -> Option<ConnectionControl> {
782 let mut controls = self.controls.lock().ok()?;
783 let index = controls.iter().position(|queued| queued.pid == pid)?;
784 Some(controls.remove(index).control)
785 }
786
787 fn remove_control(&self, pid: u64, control: &ConnectionControl) {
788 let Ok(mut controls) = self.controls.lock() else {
789 return;
790 };
791 let Some(index) = controls
792 .iter()
793 .position(|queued| queued.pid == pid && &queued.control == control)
794 else {
795 return;
796 };
797 controls.remove(index);
798 }
799
800 fn active_count(&self) -> usize {
801 self.records.lock().map_or(0, |records| records.len())
802 }
803
804 /// Removes the connection record for `pid` and, in the same close step, drops
805 /// every push reply slot that connection still owns so each waiting
806 /// [`PushReplyAwaiter`] wakes immediately with a disconnected error. This runs
807 /// on every close route — `finish`, `mark_crashed`, and `reap_crashed` all
808 /// remove through here — and fires regardless of whether the connection ever
809 /// registered a worker, so a plain push target is covered too.
810 fn remove(&self, pid: u64) -> Option<ConnectionRecord> {
811 self.cancel_pushes_for_connection(pid);
812 let removed = self
813 .records
814 .lock()
815 .ok()
816 .and_then(|mut records| records.remove(&pid));
817 // Decrement only when a record was actually present so a double-remove
818 // (e.g. `finish` after `reap_crashed`) cannot drive the gauge negative.
819 if removed.is_some() {
820 crate::metrics::connection_closed();
821 }
822 removed
823 }
824}
825
826/// One in-flight server-push reply slot, associating the awaiter's reply `sender`
827/// with the `pid` of the connection that owns the push. The pid lets the close
828/// path drop exactly that connection's slots; the correlation id (the map key)
829/// still drives [`ConnectionRuntime::resolve_push`] and
830/// [`ConnectionRuntime::cancel_push`].
831#[derive(Debug)]
832struct PendingPush {
833 pid: u64,
834 sender: Sender<Vec<u8>>,
835}
836
837#[derive(Debug, Clone)]
838struct ConnectionRecord {
839 peer_addr: Option<SocketAddr>,
840 /// Worker registration declared on this connection, set by `set_registration`
841 /// when a `WorkerRegister` frame is accepted. `Some` marks a connection whose
842 /// close must fire `on_worker_unregistered`.
843 registration: Option<WorkerRegistration>,
844}
845
846#[derive(Debug, Clone, PartialEq, Eq)]
847struct QueuedConnectionControl {
848 pid: u64,
849 control: ConnectionControl,
850}
851
852fn lock<'a, T>(mutex: &'a Mutex<T>, context: &str) -> Result<MutexGuard<'a, T>, ServerError> {
853 mutex.lock().map_err(|error| ServerError::ListenerAccept {
854 message: format!("{context} unavailable: {error}"),
855 })
856}