liminal_server/server/connection/services.rs
1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, OnceLock, RwLock, mpsc};
5use std::time::Instant;
6
7use haematite::{Database, DatabaseConfig, EventStore};
8use liminal::channel::{ChannelConfig, ChannelHandle, ChannelMode, ChannelSupervisor, Schema};
9use liminal::conversation::{
10 ConversationSupervisor, CrashPolicy, EchoBehaviour, ParticipantBehaviour,
11};
12use liminal::durability::bridge::block_on;
13use liminal::durability::{
14 DedupCache, DedupDecision, DurabilityError, DurableStore, EphemeralHaematiteStore,
15 HaematiteStore, ProcessingReceipt, open_ephemeral,
16};
17use liminal::protocol::{MessageEnvelope, ProtocolError, SchemaId as ProtocolSchemaId};
18
19use super::channel_registry::{
20 ChannelAccessError, ChannelBuildError, ChannelConfigField, ChannelDescriptor, ChannelOrigin,
21 ChannelRegistration, ChannelRegistryError, ChannelState, ChannelStatus, MAX_CHANNELS_KEY,
22 Registered, STATE_ACTIVE, STATE_QUIESCED, UNRECORDED_QUIESCE_REASON,
23};
24use super::conversation::{ConnectionConversation, LiminalConversationResource};
25use super::services_cluster::build_channel_cluster;
26use super::services_schema::{ChannelSchema, resolve_channel_schema, resolve_schema_bytes};
27use super::worker_front_door::WorkerFrontDoorServices;
28use crate::ServerError;
29use crate::config::types::{ClusterConfig, ServerConfig, ServiceProfile};
30use crate::server::participant::{InstalledParticipantService, ProductionParticipantHandler};
31
32pub use super::services_cluster::ChannelCluster;
33
34/// Registry of custom conversation responders, keyed by conversation subject.
35///
36/// A registered [`ParticipantBehaviour`] becomes the participant for any
37/// conversation opened on its subject; subjects with no entry fall back to the
38/// built-in [`EchoBehaviour`].
39type ResponderRegistry = HashMap<String, Arc<dyn ParticipantBehaviour>>;
40
41/// Marker for resources retained by a connection process until unsubscribe.
42pub trait SubscriptionResource: std::fmt::Debug + Send {
43 /// Releases the library subscription resource.
44 ///
45 /// # Errors
46 /// Returns [`ServerError`] when the liminal library reports an unsubscribe failure.
47 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError>;
48
49 /// Attempts to pull the next delivered envelope from the wrapped library
50 /// subscription without blocking.
51 ///
52 /// Returns `None` when the subscriber inbox is empty (or momentarily
53 /// unavailable): the connection process is the delivery pump, so a transient
54 /// empty read is simply "nothing to deliver this slice", never an error.
55 fn try_next(&mut self) -> Option<liminal::envelope::Envelope>;
56
57 /// Non-consuming availability query for the post-arm race barrier.
58 fn has_pending(&self) -> bool;
59
60 /// Whether an overflow has marked this subscription for shedding (§5). The
61 /// delivery pump sheds an overflowed subscription with a typed error frame.
62 /// Defaulted to `false`: a resource with no bounded inbox never overflows.
63 fn is_overflowed(&self) -> bool {
64 false
65 }
66}
67
68/// Library subscription resource owned by a single connection process.
69#[derive(Debug)]
70pub struct ConnectionSubscription {
71 id: u64,
72 /// Client-chosen application stream the server delivers this subscription's
73 /// messages on (echoed on `SubscribeAck`, carried on every `Deliver`). Set by
74 /// the connection process from the `Subscribe` frame before the subscription is
75 /// stored; `0` only while momentarily unset during construction.
76 stream_id: u32,
77 selected_schema: ProtocolSchemaId,
78 resource: Box<dyn SubscriptionResource>,
79}
80
81impl ConnectionSubscription {
82 /// Creates an owned subscription resource for one connection process.
83 #[must_use]
84 pub fn new(
85 id: u64,
86 selected_schema: ProtocolSchemaId,
87 resource: Box<dyn SubscriptionResource>,
88 ) -> Self {
89 Self {
90 id,
91 stream_id: 0,
92 selected_schema,
93 resource,
94 }
95 }
96
97 /// Returns the protocol subscription id.
98 #[must_use]
99 pub const fn id(&self) -> u64 {
100 self.id
101 }
102
103 /// Records the client-chosen delivery stream id for this subscription.
104 pub(super) const fn set_stream_id(&mut self, stream_id: u32) {
105 self.stream_id = stream_id;
106 }
107
108 /// Returns the client-chosen application stream id deliveries ride on.
109 #[must_use]
110 pub(super) const fn stream_id(&self) -> u32 {
111 self.stream_id
112 }
113
114 /// Returns the schema selected for this subscription stream.
115 #[must_use]
116 pub const fn selected_schema(&self) -> ProtocolSchemaId {
117 self.selected_schema
118 }
119
120 /// Attempts to pull the next delivered envelope without blocking.
121 pub(super) fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
122 self.resource.try_next()
123 }
124
125 pub(super) fn has_pending(&self) -> bool {
126 self.resource.has_pending()
127 }
128
129 /// Whether this subscription has been shed by an inbox overflow (§5).
130 pub(super) fn is_overflowed(&self) -> bool {
131 self.resource.is_overflowed()
132 }
133
134 pub(super) fn unsubscribe(self) -> Result<(), ServerError> {
135 self.resource.unsubscribe()
136 }
137}
138
139/// Outcome of a server publish.
140///
141/// Carries the assigned message id plus a genuine delivery ack (`delivered` = the
142/// message was accepted by at least one live subscriber on this publish, after any
143/// dedup-on-delivery suppression).
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub struct PublishOutcome {
146 /// Monotonic message id assigned to the accepted publish.
147 pub message_id: u64,
148 /// Whether the message was genuinely delivered to a subscriber. `false` means
149 /// the publish was accepted but reached no subscriber (empty channel) or was
150 /// a duplicate suppressed by dedup-on-delivery.
151 pub delivered: bool,
152}
153
154/// Which channel operation is asking the roster for admission.
155///
156/// `Copy`, so the hot path allocates nothing to ask. The contrast is
157/// [`ServerError::UnsupportedOperation`]'s owned `operation: String`: that one is
158/// built on a refusal path and pays for its allocation once per refusal, while
159/// this one is consulted on every publish and every subscribe frame that reaches
160/// the connection process.
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub enum ChannelOperation {
163 /// A `Publish` frame is asking.
164 Publish,
165 /// A `Subscribe` frame is asking.
166 Subscribe,
167}
168
169/// Operations that adapt wire frames to liminal library calls.
170pub trait ConnectionServices: std::fmt::Debug + Send + Sync {
171 /// Returns the complete participant service installed on this adapter.
172 ///
173 /// `None` keeps participant capability disabled even when the adapter owns a
174 /// durable store for unrelated channel traffic. The returned token is
175 /// server-sealed and atomically carries declared semantics plus durability,
176 /// making a handler-without-store activation impossible by construction.
177 fn participant_service(&self) -> Option<InstalledParticipantService> {
178 None
179 }
180
181 /// Delegates a publish request to the liminal library.
182 ///
183 /// `idempotency_key`, when `Some`, drives dedup-on-delivery: a re-publish with
184 /// the same key is delivered to subscribers at most once. The returned
185 /// [`PublishOutcome`] carries the genuine delivery ack.
186 ///
187 /// # Errors
188 /// Returns [`ServerError`] when the liminal publish operation fails.
189 fn publish(
190 &self,
191 channel: &str,
192 envelope: &MessageEnvelope,
193 idempotency_key: Option<&str>,
194 ) -> Result<PublishOutcome, ServerError>;
195
196 /// Delegates a subscribe request to the liminal library.
197 ///
198 /// `install`, when `Some`, carries the connection's §5 shared inbox byte
199 /// budget, per-inbox fairness cap, and R3 wake notifier. The implementation
200 /// MUST install it on the subscription's inbox BEFORE the registration is
201 /// published to the channel actor (i.e. before any envelope can be
202 /// delivered), so no envelope is ever admitted uncharged, past the depth
203 /// cap, or without a wake. Implementations with no real inbox (test
204 /// stand-ins, capability-scoped profiles that refuse subscribe) may ignore
205 /// it.
206 ///
207 /// # Errors
208 /// Returns [`ServerError`] when the liminal subscribe operation fails.
209 fn subscribe(
210 &self,
211 channel: &str,
212 accepted_schemas: &[ProtocolSchemaId],
213 install: Option<liminal::channel::InboxInstall>,
214 ) -> Result<ConnectionSubscription, ServerError>;
215
216 /// Delegates unsubscribe to the liminal library.
217 ///
218 /// # Errors
219 /// Returns [`ServerError`] when the liminal unsubscribe operation fails.
220 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError>;
221
222 /// Delegates conversation open to the liminal library.
223 ///
224 /// # Errors
225 /// Returns [`ServerError`] when the liminal conversation open operation fails.
226 fn open_conversation(
227 &self,
228 conversation_id: u64,
229 subject: &str,
230 ) -> Result<ConnectionConversation, ServerError>;
231
232 /// Delegates a conversation message to the liminal library.
233 ///
234 /// # Errors
235 /// Returns [`ServerError`] when the liminal conversation message operation fails.
236 fn conversation_message(
237 &self,
238 conversation: &ConnectionConversation,
239 envelope: &MessageEnvelope,
240 ) -> Result<(), ServerError>;
241
242 /// Delegates conversation close to the liminal library.
243 ///
244 /// # Errors
245 /// Returns [`ServerError`] when the liminal conversation close operation fails.
246 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError>;
247
248 /// Flushes durable channel state through the liminal library boundary.
249 ///
250 /// # Errors
251 /// Returns [`ServerError`] when the liminal channel flush operation fails.
252 fn flush_durable_state(&self) -> Result<(), ServerError>;
253
254 /// Whether this adapter backs ordinary channel and conversation operations.
255 ///
256 /// The default is `true` — the full-service adapter serves publish, subscribe,
257 /// and conversation frames, so full mode is byte-for-byte unchanged. The
258 /// capability-scoped worker front door overrides this to `false`, letting
259 /// [`super::apply`] reject the channel/conversation frames it short-circuits on
260 /// empty connection state (`Unsubscribe`, `ConversationMessage`,
261 /// `ConversationClose`) with a typed error frame instead of silently swallowing
262 /// an operation for a resource that could never have been created in this
263 /// profile. Frames that always reach a service method (`Publish`, `Subscribe`,
264 /// `ConversationOpen`) are rejected by the front door's own method bodies and do
265 /// not consult this flag.
266 fn supports_channel_operations(&self) -> bool {
267 true
268 }
269
270 /// Whether `channel` admits `operation` right now.
271 ///
272 /// Consulted by the connection process BEFORE the operation is delegated, so
273 /// a roster refusal is typed at the moment of the decision, by the component
274 /// that made it, from the value it decided on. The alternative — classifying
275 /// an opaque failure afterwards by re-reading the roster in the error arm —
276 /// cannot tell "the roster refused this" from "something else failed while
277 /// the roster happened to change", and would put a confident wrong cause on
278 /// the wire.
279 ///
280 /// It does NOT replace the adapter's own inner check. Admission here is the
281 /// caller's guard; a service method is public and callable without a frame,
282 /// so it keeps its own.
283 ///
284 /// The default ADMITS. An adapter with no roster has nothing to say here and
285 /// its refusals travel as service errors exactly as they do today; only the
286 /// roster-owning adapter overrides this. A default body is also what keeps
287 /// this addition inside "minor": a method added without one breaks every
288 /// downstream implementor of a public trait.
289 ///
290 /// # Errors
291 /// Returns [`ChannelAccessError`] when the roster refuses the operation.
292 fn admit_channel(
293 &self,
294 operation: ChannelOperation,
295 channel: &str,
296 ) -> Result<(), ChannelAccessError> {
297 let _ = (operation, channel);
298 Ok(())
299 }
300}
301
302/// Default adapter from server wire frames to liminal channel/conversation APIs.
303#[derive(Debug)]
304pub struct LiminalConnectionServices {
305 /// The channel roster, keyed by channel name.
306 ///
307 /// Behind an [`RwLock`] over `Arc` values so a reader clones one pointer out
308 /// under the guard and works with it after the guard is released: no roster
309 /// read is ever held across a library call, and an entry handed out can
310 /// outlive a concurrent mutation of the map itself.
311 channels: RwLock<HashMap<String, Arc<ConfiguredChannel>>>,
312 /// The operator-declared bound on RUNTIME-registered channels
313 /// (`limits.max_channels`), carried verbatim from config.
314 ///
315 /// `None` is "the operator declared no bound", which refuses every runtime
316 /// registration rather than admitting an unbounded one — the roster's
317 /// aggregate size is otherwise unbounded the moment a registration API
318 /// exists. Boot-configured channels are never counted against it: they are
319 /// the bound the operator already wrote, in the file they wrote it in.
320 max_channels: Option<usize>,
321 cluster: ChannelCluster,
322 durable_store: Arc<dyn DurableStore>,
323 /// Complete participant service, installed only when semantic lifecycle
324 /// handling and its durable aggregate store are both ready.
325 participant_service: Option<InstalledParticipantService>,
326 /// In-memory (haematite-backed) dedup cache for dedup-on-delivery. Keyed by
327 /// the per-message idempotency key carried on the publish frame; a duplicate
328 /// key is suppressed before fan-out so a subscriber receives it at most once.
329 /// Not persisted across restarts (13-L1 scope; durable dedup is deferred).
330 dedup: DedupCache,
331 conversation_supervisor: Arc<ConversationSupervisor>,
332 /// Registered custom conversation responders, keyed by conversation subject.
333 ///
334 /// When a conversation is opened (`open_conversation`), the subject is looked
335 /// up here: a registered [`ParticipantBehaviour`] becomes the conversation's
336 /// participant; with no registration the conversation falls back to the
337 /// built-in [`EchoBehaviour`], preserving the original echo semantics exactly.
338 /// This is the seam aion #13 plugs a remote worker responder into. Interior
339 /// mutability is required because the services are shared behind `&self`.
340 responders: Mutex<ResponderRegistry>,
341 next_message_id: AtomicU64,
342 next_subscription_id: AtomicU64,
343}
344
345impl LiminalConnectionServices {
346 /// Builds library-backed services from validated server configuration.
347 ///
348 /// Durable-mode channels are backed by a shared haematite event store so
349 /// their publishes are persisted and survive the graceful-shutdown flush;
350 /// ephemeral channels carry no store.
351 ///
352 /// Full-only: a config selecting the worker-front-door profile is rejected at
353 /// entry (before any store is built), so this constructor can never build full
354 /// services for a profile that forbids them. Profile-aware callers go through
355 /// [`build_connection_services`] instead.
356 ///
357 /// # Errors
358 /// Returns [`ServerError`] when the config selects a non-full profile or a
359 /// configured channel cannot be initialized.
360 pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
361 require_full_profile(config)?;
362 let store = ProductionSubsystems.durable_store(config.persistence_path.as_deref())?;
363 Self::from_config_with_store_via(config, store, &ProductionSubsystems)
364 }
365
366 /// Builds services over a caller-provided durable store.
367 ///
368 /// Used by tests that need to inspect persisted state through the same store
369 /// handle the durable channels write to.
370 ///
371 /// Full-only: rejects a worker-front-door profile at entry, exactly like
372 /// [`Self::from_config`].
373 ///
374 /// # Errors
375 /// Returns [`ServerError`] when the config selects a non-full profile or a
376 /// configured channel cannot be initialized.
377 pub fn from_config_with_store(
378 config: &ServerConfig,
379 durable_store: Arc<dyn DurableStore>,
380 ) -> Result<Self, ServerError> {
381 require_full_profile(config)?;
382 Self::from_config_with_store_via(config, durable_store, &ProductionSubsystems)
383 }
384
385 /// [`Self::from_config_with_store`] with the subsystem factory injected.
386 ///
387 /// The channel supervisor and conversation supervisor are constructed ONLY
388 /// through `subsystems` — there is no direct constructor call in this body —
389 /// so a factory that records as a side effect of constructing cannot have its
390 /// recording omitted (§9 D2 seam census, record-by-construction). No profile
391 /// check here: the caller (the public wrapper or the profile dispatch in
392 /// [`build_connection_services`]) has already established the full profile.
393 fn from_config_with_store_via(
394 config: &ServerConfig,
395 durable_store: Arc<dyn DurableStore>,
396 subsystems: &dyn SubsystemFactory,
397 ) -> Result<Self, ServerError> {
398 // Build ONE shared channel supervisor for the whole server. When a
399 // [cluster] section is present it is distribution-enabled, so every
400 // channel actor and subscriber shares the clustered scheduler the cluster
401 // attaches its process-group transport to (SRV-005, Constraint B).
402 let cluster = subsystems.channel_cluster(config.cluster.as_ref())?;
403 let mut channels = HashMap::new();
404 for channel in &config.channels {
405 // Resolve the channel's real JSON Schema (loaded from `schema_ref`
406 // during config validation) or the permissive empty schema when the
407 // channel declared none. The protocol schema id advertised at
408 // subscribe time is derived from the SAME schema bytes so an SDK
409 // deriving ids from schema bytes converges on it.
410 let resolved = resolve_channel_schema(channel);
411 let configured = build_configured_channel(
412 &channel.name,
413 resolved,
414 channel.durable,
415 ChannelOrigin::BootConfigured,
416 &durable_store,
417 cluster.supervisor(),
418 )
419 .map_err(|error| ServerError::ConfigValidation {
420 message: error.boot_message(&channel.name),
421 })?;
422 channels.insert(channel.name.clone(), Arc::new(configured));
423 }
424 let conversation_supervisor = subsystems.conversation_supervisor()?;
425 let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
426 // Production participant activation (LP gap closure, Part B): the
427 // deployment's [participant] section installs the ONE production
428 // semantic handler, sealed together with the same durable store the
429 // conversation logs live in, under the configured wire-frame limit.
430 // No section, no service — the capability bit stays off and the
431 // connection path is byte-identical to the pre-activation build.
432 let participant_service = config
433 .participant
434 .as_ref()
435 .map(|participant| {
436 let handler =
437 ProductionParticipantHandler::new(Arc::clone(&durable_store), *participant)
438 .map_err(|error| ServerError::ParticipantStartupRestore {
439 message: error.to_string(),
440 })?;
441 InstalledParticipantService::new(
442 Arc::new(handler),
443 Arc::clone(&durable_store),
444 participant.wire_frame_limit,
445 )
446 .map_err(|error| ServerError::ConfigValidation {
447 message: format!(
448 "participant.wire_frame_limit: {} is below the protocol's minimum \
449 complete participant frame ({error:?})",
450 participant.wire_frame_limit
451 ),
452 })
453 })
454 .transpose()?;
455 Ok(Self {
456 channels: RwLock::new(channels),
457 max_channels: config.limits.max_channels,
458 cluster,
459 durable_store,
460 participant_service,
461 dedup,
462 conversation_supervisor,
463 responders: Mutex::new(HashMap::new()),
464 next_message_id: AtomicU64::new(1),
465 next_subscription_id: AtomicU64::new(1),
466 })
467 }
468
469 /// Builds services with no configured channels.
470 ///
471 /// # Errors
472 /// Returns [`ServerError`] when the conversation supervisor scheduler cannot start.
473 pub fn empty() -> Result<Self, ServerError> {
474 let conversation_supervisor = ProductionSubsystems.conversation_supervisor()?;
475 let durable_store = build_durable_store(None)?;
476 let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
477 Ok(Self {
478 channels: RwLock::new(HashMap::new()),
479 // No config, so no declared bound: this builder serves tests and
480 // callers with no channels at all, and a registration against it
481 // refuses `CapNotConfigured` exactly as an undeclared deployment's
482 // would.
483 max_channels: None,
484 cluster: build_channel_cluster(None)?,
485 durable_store,
486 participant_service: None,
487 dedup,
488 conversation_supervisor,
489 responders: Mutex::new(HashMap::new()),
490 next_message_id: AtomicU64::new(1),
491 next_subscription_id: AtomicU64::new(1),
492 })
493 }
494
495 /// The shared channel supervisor + cluster resolver backing this service.
496 ///
497 /// The server runtime uses this to attach the cluster to the channel
498 /// supervisor's clustered scheduler (SRV-005).
499 #[must_use]
500 pub const fn channel_cluster(&self) -> &ChannelCluster {
501 &self.cluster
502 }
503
504 /// Returns the shared durable store backing this service's durable channels.
505 #[must_use]
506 pub fn durable_store(&self) -> Arc<dyn DurableStore> {
507 Arc::clone(&self.durable_store)
508 }
509
510 /// Installs a complete participant bundle in full-service supervisor tests.
511 ///
512 /// Production full services intentionally stay disabled until a concrete
513 /// lifecycle handler exists. This consuming test builder exercises the real
514 /// supervisor activation path without allowing an already-shared adapter to
515 /// change capability posture.
516 #[cfg(test)]
517 #[must_use]
518 pub(crate) fn with_participant_service(
519 mut self,
520 participant_service: InstalledParticipantService,
521 ) -> Self {
522 self.participant_service = Some(participant_service);
523 self
524 }
525
526 /// Returns the conversation supervisor backing supervised conversations.
527 ///
528 /// Tests use this to reach the underlying beamr scheduler so they can spawn
529 /// or terminate participant processes and exercise crash detection.
530 #[must_use]
531 pub fn conversation_supervisor(&self) -> Arc<ConversationSupervisor> {
532 Arc::clone(&self.conversation_supervisor)
533 }
534
535 /// Registers a custom conversation responder for a routing `subject`.
536 ///
537 /// When a conversation is later opened with this exact `subject`, its
538 /// participant runs `behaviour` instead of the built-in [`EchoBehaviour`].
539 /// The responder is spawned and supervised identically to the echo
540 /// participant — a real linked beamr process with the same crash-detection
541 /// semantics — so this exposes the responder seam without changing how
542 /// participants run. Registering a subject that already has a responder
543 /// replaces it; the previous behaviour is returned.
544 ///
545 /// This is the liminal-side seam aion #13 plugs a remote worker into: it
546 /// registers a responder that forwards each request to the worker and routes
547 /// the worker's reply back through the conversation. Subjects with no
548 /// registration keep echoing, so existing callers are unaffected.
549 ///
550 /// # Errors
551 /// Returns [`ServerError`] when the responder registry lock is poisoned.
552 pub fn register_responder(
553 &self,
554 subject: impl Into<String>,
555 behaviour: Arc<dyn ParticipantBehaviour>,
556 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
557 let mut responders = self.lock_responders()?;
558 Ok(responders.insert(subject.into(), behaviour))
559 }
560
561 /// Removes the custom responder registered for `subject`, if any.
562 ///
563 /// After removal the subject reverts to the built-in [`EchoBehaviour`] on the
564 /// next [`Self::open_conversation`]. Returns the removed behaviour when one
565 /// was registered.
566 ///
567 /// # Errors
568 /// Returns [`ServerError`] when the responder registry lock is poisoned.
569 pub fn unregister_responder(
570 &self,
571 subject: &str,
572 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
573 let mut responders = self.lock_responders()?;
574 Ok(responders.remove(subject))
575 }
576
577 /// Resolves the responder behaviour for `subject`: the registered custom
578 /// responder when present, otherwise the built-in [`EchoBehaviour`].
579 ///
580 /// This is the single routing decision behind the seam — registered-or-echo —
581 /// so the fallback is identical to the original hard-wired echo path.
582 fn responder_for(&self, subject: &str) -> Result<Arc<dyn ParticipantBehaviour>, ServerError> {
583 let responders = self.lock_responders()?;
584 Ok(responders.get(subject).map_or_else(
585 || Arc::new(EchoBehaviour) as Arc<dyn ParticipantBehaviour>,
586 Arc::clone,
587 ))
588 }
589
590 /// Locks the responder registry, mapping a poisoned lock to a [`ServerError`]
591 /// rather than panicking (the workspace denies `unwrap`/`expect`/`panic`).
592 fn lock_responders(&self) -> Result<std::sync::MutexGuard<'_, ResponderRegistry>, ServerError> {
593 self.responders
594 .lock()
595 .map_err(|_poisoned| ServerError::ListenerAccept {
596 message: "responder registry lock poisoned".to_owned(),
597 })
598 }
599
600 /// Takes the channel roster's read guard, mapping a poisoned lock to a
601 /// [`ServerError`] rather than panicking (the workspace denies
602 /// `unwrap`/`expect`/`panic`), exactly as [`Self::lock_responders`] does for
603 /// the responder registry.
604 ///
605 /// Every caller clones the `Arc` it needs out of the returned guard and drops
606 /// the guard before doing anything else: the roster lock is never held across
607 /// a call into the liminal library.
608 fn read_channels(
609 &self,
610 ) -> Result<std::sync::RwLockReadGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>, ServerError>
611 {
612 self.channels
613 .read()
614 .map_err(|_poisoned| ServerError::ListenerAccept {
615 message: "channel roster lock poisoned".to_owned(),
616 })
617 }
618
619 /// Subscribes to a configured channel and returns the raw library
620 /// subscription handle so a test can drain the subscriber inbox directly and
621 /// observe exactly which messages reached a subscriber.
622 #[cfg(test)]
623 pub(crate) fn subscribe_handle_for_test(
624 &self,
625 channel: &str,
626 ) -> Result<liminal::channel::SubscriptionHandle, ServerError> {
627 let channels = self.read_channels()?;
628 let configured =
629 channels
630 .get(channel)
631 .map(Arc::clone)
632 .ok_or_else(|| ServerError::ListenerAccept {
633 message: format!("channel '{channel}' is not configured"),
634 })?;
635 drop(channels);
636 configured
637 .handle
638 .subscribe()
639 .map_err(|error| ServerError::ListenerAccept {
640 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
641 })
642 }
643
644 /// Claims the delivery right for an idempotency key.
645 ///
646 /// Returns `Ok(true)` when this is the first publish for the key (the caller
647 /// may deliver), and `Ok(false)` when the key was already claimed/completed (a
648 /// duplicate the caller must suppress). The dedup cache is driven synchronously
649 /// over the in-memory haematite store via the durable bridge.
650 fn claim_delivery(&self, key: &str) -> Result<bool, ServerError> {
651 let decision = block_on(self.dedup.claim_or_get(key, dedup_timestamp_millis()))
652 .map_err(|error| ServerError::ListenerAccept {
653 message: format!("dedup bridge failed for key '{key}': {error}"),
654 })?
655 .map_err(|error| ServerError::ListenerAccept {
656 message: format!("dedup claim failed for key '{key}': {error}"),
657 })?;
658 Ok(matches!(decision, DedupDecision::Claimed))
659 }
660
661 /// Releases a dangling in-flight dedup claim after a failed delivery.
662 ///
663 /// Best-effort: a release failure cannot mask the original publish error, so
664 /// this returns nothing and logs at `error` level instead of surfacing. It is
665 /// never silent — the leak (a permanently suppressed key) must be observable.
666 /// `release_claim` itself never clobbers a stored receipt, so calling it on the
667 /// failure path is safe even if a concurrent completion raced ahead.
668 fn release_claim(&self, key: &str) {
669 match block_on(self.dedup.release_claim(key)) {
670 Ok(Ok(())) => {}
671 Ok(Err(error)) => {
672 tracing::error!(
673 idempotency_key = key,
674 %error,
675 "failed to release dedup claim after publish failure; key may stay suppressed"
676 );
677 }
678 Err(error) => {
679 tracing::error!(
680 idempotency_key = key,
681 %error,
682 "dedup release bridge failed after publish failure; key may stay suppressed"
683 );
684 }
685 }
686 }
687}
688
689/// The runtime channel-registration surface.
690///
691/// Inherent methods, not trait methods, and deliberately so: this is
692/// authority-moving vocabulary that belongs to the ONE adapter owning a channel
693/// roster. Putting `register`/`quiesce` on the public [`ConnectionServices`]
694/// trait would break every external implementor and would hand
695/// register/quiesce words to a profile that serves no channels at all.
696///
697/// The same seam as the existing runtime-mutation API on this type
698/// (`register_responder`/`unregister_responder`): `&self`, interior mutability,
699/// typed `Result`.
700impl LiminalConnectionServices {
701 /// Registers `spec` on the live roster.
702 ///
703 /// Idempotent when an entry of that name already has an IDENTICAL
704 /// configuration — mode, protocol schema id, and schema document, all three
705 /// — and refuses typed, naming the first differing field, otherwise. An
706 /// identical registration against a boot-configured entry answers
707 /// [`Registered::AlreadyIdentical`] and leaves its origin alone: flipping it
708 /// would make the entry lie about its restart fate and would move it into
709 /// the counted population without a channel having been created.
710 ///
711 /// The cap is consulted first, in two steps with different reach. An absent
712 /// `limits.max_channels` refuses EVERY call, identical or not, before the
713 /// roster is read at all: a deployment that has declared no bound has not
714 /// said what it admits, and unbounded-by-default is not a bound. A REACHED
715 /// cap refuses only a call that would create an entry — it gates the insert,
716 /// which is the population it bounds. An identical re-registration inserts
717 /// nothing and so is answered `AlreadyIdentical` even at a full roster;
718 /// refusing it would break idempotency at exactly the boundary a projector
719 /// re-projecting its record crosses.
720 ///
721 /// # Errors
722 /// Returns [`ChannelRegistryError`] when no cap is configured, the cap is
723 /// reached, the name exists with a different configuration, the schema bytes
724 /// do not parse or compile, durable initialization over the shared store
725 /// fails, or the roster lock is poisoned.
726 pub fn register_channel(
727 &self,
728 spec: &ChannelRegistration,
729 ) -> Result<Registered, ChannelRegistryError> {
730 // The cap is consulted before the roster is touched: an undeclared bound
731 // refuses every runtime registration, identical or not, because the
732 // deployment has not said what it admits.
733 let Some(limit) = self.max_channels else {
734 return Err(ChannelRegistryError::CapNotConfigured {
735 cap: MAX_CHANNELS_KEY,
736 });
737 };
738 // Schema resolution is pure (a JSON parse and a digest), so it runs with
739 // no lock held and its result serves BOTH the identity comparison and
740 // the construction below.
741 let resolved = resolve_schema_bytes(spec.schema_bytes.as_deref()).map_err(|error| {
742 ChannelRegistryError::SchemaRejected {
743 name: spec.name.clone(),
744 message: error.to_string(),
745 }
746 })?;
747
748 // Fast paths, under a READ lock: an already-identical registration
749 // builds nothing, and a full roster refuses before paying for a
750 // construction it would discard. Neither is the authority — the write
751 // lock below re-decides both, because the roster can move in between.
752 let (existing, registered_count) = {
753 let channels = self.read_roster()?;
754 let existing = channels.get(&spec.name).map(Arc::clone);
755 let registered_count = runtime_registered_count(&channels);
756 drop(channels);
757 (existing, registered_count)
758 };
759 if let Some(existing) = existing {
760 return compare_registration(&existing, spec, &resolved);
761 }
762 if registered_count >= limit {
763 return Err(ChannelRegistryError::CapReached {
764 cap: MAX_CHANNELS_KEY,
765 limit,
766 });
767 }
768
769 // Construction runs with NO lock held. A durable channel recovers its
770 // per-partition sequence counters from the store here, which is O(stream
771 // length) in store reads; holding the roster lock across it would put a
772 // slow store walk on every connection's publish and subscribe path.
773 let configured = build_configured_channel(
774 &spec.name,
775 resolved,
776 spec.durable,
777 ChannelOrigin::RuntimeRegistered,
778 &self.durable_store,
779 self.cluster.supervisor(),
780 )
781 .map_err(|error| error.into_registry_error(&spec.name))?;
782
783 // The authoritative decision: identity, cap, and insert under ONE write
784 // lock, so the count that admitted the entry and the insert that added
785 // it cannot be separated by a concurrent registration.
786 let mut channels = self.write_roster()?;
787 let raced = channels.get(&spec.name).map(Arc::clone);
788 let registered_count = runtime_registered_count(&channels);
789 if raced.is_none() && registered_count < limit {
790 channels.insert(spec.name.clone(), Arc::new(configured));
791 drop(channels);
792 return Ok(Registered::Created);
793 }
794 drop(channels);
795 if let Some(raced) = raced {
796 // A racer registered this name while the channel above was being
797 // built. The built entry is dropped unused: it owns no actor (the
798 // actor is spawned lazily on first use) and its durable
799 // construction only READ the store, so discarding it changes
800 // nothing an observer could see.
801 return compare_registration(&raced, spec, &schema_of(&configured));
802 }
803 Err(ChannelRegistryError::CapReached {
804 cap: MAX_CHANNELS_KEY,
805 limit,
806 })
807 }
808
809 /// Moves `name` from active to quiesced with a named `reason`. ONE-WAY.
810 ///
811 /// New publishes and new subscribes are refused afterwards, carrying the
812 /// reason. Existing subscriptions are UNTOUCHED: nothing revokes a
813 /// subscription handle, the actor's subscriber list is not walked, no EXIT
814 /// is sent, and the channel actor keeps running. Quiesce is a roster-level
815 /// admission decision, not an actor command.
816 ///
817 /// Re-quiescing under the IDENTICAL reason is `Ok(())`; a DIFFERENT reason
818 /// refuses, carrying the reason already on record.
819 ///
820 /// The return does NOT mean "no new subscriber can appear". A subscribe that
821 /// has already passed admission completes and gets its stream — the
822 /// linearisation point is the admission read, not the subscribe's
823 /// completion. A consumer that needs "nobody is attached" must observe
824 /// attachment directly.
825 ///
826 /// # Errors
827 /// Returns [`ChannelRegistryError`] when the name is not registered, is
828 /// already quiesced under a different reason, or the roster lock is
829 /// poisoned.
830 pub fn quiesce_channel(
831 &self,
832 name: &str,
833 reason: impl Into<String>,
834 ) -> Result<(), ChannelRegistryError> {
835 let reason = reason.into();
836 // A READ lock: the state machine lives on the ENTRY, not in the map, so
837 // the operation the design most wants to be safe never blocks a reader.
838 let configured = {
839 let channels = self.read_roster()?;
840 channels.get(name).map(Arc::clone).ok_or_else(|| {
841 ChannelRegistryError::NotRegistered {
842 name: name.to_owned(),
843 }
844 })?
845 };
846 configured.quiesce(name, &reason)
847 }
848
849 /// Cheap typed probe: one roster read plus one atomic load.
850 ///
851 /// Touches no actor and therefore CANNOT spawn one. Every handle accessor
852 /// that could answer a question about a channel's activity routes through
853 /// the lazy-spawn path, so a probe built on one would materialise the actor
854 /// of the idle channel it was asked about — turning a read into a side
855 /// effect. This reads the roster entry's own recorded fields and nothing
856 /// else.
857 ///
858 /// # Errors
859 /// Returns [`ChannelRegistryError::RosterUnavailable`] only.
860 pub fn channel_status(&self, name: &str) -> Result<ChannelStatus, ChannelRegistryError> {
861 let configured = {
862 let channels = self.read_roster()?;
863 channels.get(name).map(Arc::clone)
864 };
865 let Some(configured) = configured else {
866 return Ok(ChannelStatus::NotRegistered);
867 };
868 let mode = configured.handle.config().mode;
869 Ok(match configured.state() {
870 ChannelState::Active => ChannelStatus::Active {
871 origin: configured.origin,
872 mode,
873 schema: configured.protocol_schema,
874 },
875 ChannelState::Quiesced { reason } => ChannelStatus::Quiesced {
876 reason,
877 origin: configured.origin,
878 mode,
879 },
880 })
881 }
882
883 /// The whole roster: one minimal descriptor per entry, sorted by name.
884 ///
885 /// The census companion to [`Self::channel_status`]. A by-name probe answers
886 /// about a name the caller already suspects; only an enumeration can reveal
887 /// a name the caller does not know to ask about, and a verification sweep
888 /// with no population denominator is an instrument shape this estate
889 /// forbids. Touches no actor, under the same constraint as the probe.
890 ///
891 /// # Errors
892 /// Returns [`ChannelRegistryError::RosterUnavailable`] only.
893 pub fn registered_channels(&self) -> Result<Vec<ChannelDescriptor>, ChannelRegistryError> {
894 let entries: Vec<(String, Arc<ConfiguredChannel>)> = {
895 let channels = self.read_roster()?;
896 channels
897 .iter()
898 .map(|(name, configured)| (name.clone(), Arc::clone(configured)))
899 .collect()
900 };
901 let mut descriptors: Vec<ChannelDescriptor> = entries
902 .into_iter()
903 .map(|(name, configured)| ChannelDescriptor {
904 name,
905 origin: configured.origin,
906 state: configured.state(),
907 })
908 .collect();
909 descriptors.sort_by(|left, right| left.name.cmp(&right.name));
910 Ok(descriptors)
911 }
912
913 /// The roster admission funnel: the ONE place a channel operation's
914 /// permission is decided.
915 ///
916 /// Returns the admitted entry, so the caller works from the value the
917 /// decision was made on rather than reading the roster a second time and
918 /// risking a different answer. This read is the LINEARISATION POINT for the
919 /// quiesce race: everything the caller does with the returned entry happens
920 /// outside the lock and is not re-checked, which is exactly why a quiesce
921 /// that commits after this returns does not stop the operation it admitted.
922 ///
923 /// # Errors
924 /// Returns [`ChannelAccessError`] when the channel is absent, quiesced, or
925 /// the roster lock is poisoned.
926 fn admit_channel(&self, channel: &str) -> Result<Arc<ConfiguredChannel>, ChannelAccessError> {
927 let configured = {
928 let channels = self.channels.read().map_err(|_poisoned| {
929 ChannelAccessError::RosterUnavailable {
930 message: ROSTER_POISONED.to_owned(),
931 }
932 })?;
933 channels.get(channel).map(Arc::clone)
934 };
935 let configured = configured.ok_or_else(|| ChannelAccessError::NotRegistered {
936 name: channel.to_owned(),
937 })?;
938 if configured.state.load(Ordering::Acquire) == STATE_QUIESCED {
939 return Err(ChannelAccessError::Quiesced {
940 name: channel.to_owned(),
941 reason: configured.recorded_quiesce_reason(),
942 });
943 }
944 Ok(configured)
945 }
946
947 /// Takes the roster's READ guard for the registration surface, mapping a
948 /// poisoned lock to a typed [`ChannelRegistryError`].
949 ///
950 /// Separate from [`Self::read_channels`] because the two surfaces answer
951 /// different callers with different error types; recovering one from the
952 /// other would mean inspecting a message.
953 fn read_roster(
954 &self,
955 ) -> Result<
956 std::sync::RwLockReadGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>,
957 ChannelRegistryError,
958 > {
959 self.channels
960 .read()
961 .map_err(|_poisoned| ChannelRegistryError::RosterUnavailable {
962 message: ROSTER_POISONED.to_owned(),
963 })
964 }
965
966 /// Takes the roster's WRITE guard — held only for the check-and-insert that
967 /// must be atomic, never across a call into the liminal library.
968 fn write_roster(
969 &self,
970 ) -> Result<
971 std::sync::RwLockWriteGuard<'_, HashMap<String, Arc<ConfiguredChannel>>>,
972 ChannelRegistryError,
973 > {
974 self.channels
975 .write()
976 .map_err(|_poisoned| ChannelRegistryError::RosterUnavailable {
977 message: ROSTER_POISONED.to_owned(),
978 })
979 }
980}
981
982/// The diagnostic carried when the roster lock is poisoned. One string, shared
983/// by every surface that reports it, so the wording cannot drift between them.
984const ROSTER_POISONED: &str = "channel roster lock poisoned";
985
986/// How many roster entries the registration cap counts.
987///
988/// Runtime-registered entries ONLY. Boot-configured channels are the operator's
989/// own authored bound — they are in the file the operator wrote — and counting
990/// them would make one number mean two different things depending on how the
991/// deployment was configured. The origin never flips, so this population is
992/// well defined over time.
993fn runtime_registered_count(channels: &HashMap<String, Arc<ConfiguredChannel>>) -> usize {
994 channels
995 .values()
996 .filter(|configured| configured.origin == ChannelOrigin::RuntimeRegistered)
997 .count()
998}
999
1000/// The three-field identity comparison behind idempotent-if-identical
1001/// registration.
1002///
1003/// All three must match; the FIRST mismatch is reported by name. The name itself
1004/// is not compared — it is the roster key, a precondition of the comparison
1005/// rather than a member of it — and neither are the supervisor or the durable
1006/// store, which are one server-wide instance each and cannot differ between two
1007/// registrations in one process.
1008///
1009/// The schema is compared as BOTH its protocol id and its parsed document, on
1010/// purpose. The id is a 64-bit non-cryptographic digest, so the document guards
1011/// against a collision accepting a different schema as identical; and the two
1012/// fail in opposite directions, because two byte sequences that parse to the
1013/// same document but differ in whitespace produce different ids — which must
1014/// refuse, since the id is what every future subscriber negotiates.
1015///
1016/// The channel's own `Schema` is not compared: it is not `PartialEq`, and its
1017/// identifier is a fresh value per construction, so comparing it would refuse
1018/// every idempotent re-registration.
1019fn compare_registration(
1020 existing: &ConfiguredChannel,
1021 spec: &ChannelRegistration,
1022 resolved: &ChannelSchema,
1023) -> Result<Registered, ChannelRegistryError> {
1024 let requested_mode = if spec.durable {
1025 ChannelMode::Durable
1026 } else {
1027 ChannelMode::Ephemeral
1028 };
1029 let config = existing.handle.config();
1030 let mismatch = if config.mode == requested_mode {
1031 if existing.protocol_schema == resolved.protocol_id {
1032 if *config.schema.definition() == resolved.document {
1033 None
1034 } else {
1035 Some(ChannelConfigField::SchemaDocument)
1036 }
1037 } else {
1038 Some(ChannelConfigField::SchemaId)
1039 }
1040 } else {
1041 Some(ChannelConfigField::Mode)
1042 };
1043 mismatch.map_or(Ok(Registered::AlreadyIdentical), |field| {
1044 Err(ChannelRegistryError::AlreadyRegistered {
1045 name: spec.name.clone(),
1046 field,
1047 })
1048 })
1049}
1050
1051/// Recovers the resolved schema of an already-built entry, so the write-lock
1052/// re-check compares the SAME three fields against the same values the fast path
1053/// would have.
1054fn schema_of(configured: &ConfiguredChannel) -> ChannelSchema {
1055 ChannelSchema {
1056 document: configured.handle.config().schema.definition().clone(),
1057 protocol_id: configured.protocol_schema,
1058 }
1059}
1060
1061/// Builds one roster entry: the channel's validation engine, its library handle
1062/// (durable or ephemeral), and the protocol schema id advertised at subscribe
1063/// time.
1064///
1065/// This is the SOLE place a [`ConfiguredChannel`] is constructed. Keeping
1066/// construction in one function is what makes "every channel on this server is
1067/// the same kind of object" structural rather than a promise: there is no second
1068/// body a channel could be built by, so no channel can drift into a different
1069/// shape. The boot loop in [`LiminalConnectionServices::from_config_with_store_via`]
1070/// is its caller.
1071///
1072/// `resolved` is consumed because its JSON Schema document is moved into the
1073/// channel's [`Schema`]; the protocol id is carried onto the entry unchanged.
1074/// `origin` is stamped here and never written again.
1075///
1076/// The failure is typed ([`ChannelBuildError`]) rather than a [`ServerError`] so
1077/// BOTH callers can render it without inspecting a message: the boot loop maps
1078/// it back to the exact `ConfigValidation` strings it has always produced, and
1079/// `register_channel` maps it to the registry's own typed variants.
1080///
1081/// # Errors
1082/// Returns [`ChannelBuildError`] when the JSON Schema document does not compile
1083/// or durable initialization over the shared store fails.
1084fn build_configured_channel(
1085 name: &str,
1086 resolved: ChannelSchema,
1087 durable: bool,
1088 origin: ChannelOrigin,
1089 durable_store: &Arc<dyn DurableStore>,
1090 supervisor: &ChannelSupervisor,
1091) -> Result<ConfiguredChannel, ChannelBuildError> {
1092 let schema =
1093 Schema::new(resolved.document).map_err(|error| ChannelBuildError::SchemaRejected {
1094 message: error.to_string(),
1095 })?;
1096 let channel_config = if durable {
1097 ChannelConfig::new(name.to_owned(), schema, ChannelMode::Durable)
1098 } else {
1099 ChannelConfig::new(name.to_owned(), schema, ChannelMode::Ephemeral)
1100 };
1101 let handle = if durable {
1102 ChannelHandle::new_durable_with_supervisor(
1103 channel_config,
1104 Arc::clone(durable_store),
1105 supervisor.clone(),
1106 )
1107 .map_err(|error| ChannelBuildError::DurableInitFailed {
1108 message: error.to_string(),
1109 })?
1110 } else {
1111 ChannelHandle::with_supervisor(channel_config, supervisor.clone())
1112 };
1113 Ok(ConfiguredChannel {
1114 handle,
1115 protocol_schema: resolved.protocol_id,
1116 origin,
1117 state: AtomicU8::new(STATE_ACTIVE),
1118 quiesce_reason: OnceLock::new(),
1119 })
1120}
1121
1122/// Returns the current epoch-millis timestamp used as the dedup entry anchor.
1123///
1124/// A clock error before the Unix epoch yields `0`: the timestamp is only a TTL
1125/// anchor for the in-memory cache and a zero anchor never breaks the at-most-once
1126/// claim semantics, so this avoids surfacing a clock fault on the publish path.
1127fn dedup_timestamp_millis() -> u64 {
1128 use std::time::{SystemTime, UNIX_EPOCH};
1129 SystemTime::now()
1130 .duration_since(UNIX_EPOCH)
1131 .ok()
1132 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1133 .unwrap_or(0)
1134}
1135
1136/// Default shard count for an on-disk durable store.
1137///
1138/// Haematite routes keys across this many single-threaded shard actors; a small
1139/// power of two gives parallelism across cursors/streams without spawning an
1140/// actor per core. The value is fixed (haematite has no silent default) and not
1141/// yet surfaced in server config.
1142const DEFAULT_SHARD_COUNT: usize = 8;
1143
1144/// Namespace prefix for the dedup-on-delivery cache streams. Keeps delivery dedup
1145/// keys from colliding with any other haematite streams in the shared store.
1146const DELIVERY_DEDUP_NAMESPACE: &str = "liminal:delivery-dedup";
1147
1148/// Beamr-scheduler-owning subsystems the full-service construction path builds
1149/// beyond the connection supervisor's own scheduler. The §9 D2 seam census counts
1150/// these: the worker-front-door profile must construct NONE of them. Test-gated:
1151/// this is the recording vocabulary of the gate's instrument, not production state.
1152#[cfg(test)]
1153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1154pub(super) enum SchedulerSubsystem {
1155 /// The shared channel supervisor (its own beamr scheduler).
1156 ChannelSupervisor,
1157 /// The conversation supervisor (its own beamr scheduler).
1158 ConversationSupervisor,
1159 /// The haematite store's database (its shard-actor scheduler).
1160 HaematiteStore,
1161}
1162
1163/// Constructor seam for every scheduler-owning subsystem (`SchedulerSubsystem`)
1164/// the profile-aware construction path can create.
1165///
1166/// These methods are the ONLY route through which [`build_connection_services`]
1167/// and the [`LiminalConnectionServices`] config constructors reach
1168/// `build_channel_cluster`, `ConversationSupervisor::new`, and the durable-store
1169/// constructors — no direct constructor call exists in those bodies. The §9 D2
1170/// gate therefore injects a factory that records as a side effect of
1171/// constructing (the D3 store-seam ownership move applied to schedulers): a
1172/// recording cannot be omitted without also failing to construct the subsystem,
1173/// which closes the "hand-placed census call beside the constructor" gap where a
1174/// future subsystem could be built without its courtesy call.
1175pub(super) trait SubsystemFactory {
1176 /// Constructs the shared channel supervisor + cluster resolver (a beamr
1177 /// scheduler).
1178 ///
1179 /// # Errors
1180 /// Returns [`ServerError`] when the channel supervisor scheduler cannot start.
1181 fn channel_cluster(
1182 &self,
1183 cluster_config: Option<&ClusterConfig>,
1184 ) -> Result<ChannelCluster, ServerError>;
1185
1186 /// Constructs the conversation supervisor (a beamr scheduler).
1187 ///
1188 /// # Errors
1189 /// Returns [`ServerError`] when the conversation supervisor scheduler cannot
1190 /// start.
1191 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError>;
1192
1193 /// Constructs the durable store (haematite's shard-actor scheduler):
1194 /// persistent under `persistence_path`, self-owning ephemeral otherwise.
1195 ///
1196 /// # Errors
1197 /// Returns [`ServerError`] when the store cannot be opened.
1198 fn durable_store(
1199 &self,
1200 persistence_path: Option<&Path>,
1201 ) -> Result<Arc<dyn DurableStore>, ServerError>;
1202}
1203
1204/// The production factory: the real constructors, recording nothing.
1205pub(super) struct ProductionSubsystems;
1206
1207impl SubsystemFactory for ProductionSubsystems {
1208 fn channel_cluster(
1209 &self,
1210 cluster_config: Option<&ClusterConfig>,
1211 ) -> Result<ChannelCluster, ServerError> {
1212 build_channel_cluster(cluster_config)
1213 }
1214
1215 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
1216 Ok(Arc::new(ConversationSupervisor::new().map_err(
1217 |error| ServerError::ConfigValidation {
1218 message: format!("failed to start conversation supervisor: {error}"),
1219 },
1220 )?))
1221 }
1222
1223 fn durable_store(
1224 &self,
1225 persistence_path: Option<&Path>,
1226 ) -> Result<Arc<dyn DurableStore>, ServerError> {
1227 build_durable_store(persistence_path)
1228 }
1229}
1230
1231/// Test-only recording implementation of [`SubsystemFactory`] for the §9 D2
1232/// construction gate. Lives here (not in a test module's private scope) so the
1233/// supervisor-level gate test reuses the same instrument.
1234#[cfg(test)]
1235#[allow(clippy::expect_used)]
1236pub(super) mod subsystem_census {
1237 use std::path::{Path, PathBuf};
1238 use std::sync::{Arc, Mutex};
1239
1240 use liminal::conversation::ConversationSupervisor;
1241 use liminal::durability::{DurableStore, open_ephemeral_rooted};
1242
1243 use super::{
1244 ChannelCluster, DEFAULT_SHARD_COUNT, ProductionSubsystems, SchedulerSubsystem,
1245 SubsystemFactory, build_durable_store_with,
1246 };
1247 use crate::ServerError;
1248 use crate::config::types::ClusterConfig;
1249
1250 /// Records each [`SchedulerSubsystem`] AS A SIDE EFFECT of constructing it,
1251 /// then hands back the production-constructed subsystem (with ephemeral
1252 /// stores rooted in an isolated directory, so the fs half of the gate is a
1253 /// real negative assertion). Because [`SubsystemFactory`] is the only route
1254 /// the profile-aware construction path has to these constructors, a
1255 /// recording cannot be omitted without also failing to construct — the
1256 /// record-by-construction guarantee.
1257 pub struct RecordingSubsystems {
1258 census: Mutex<Vec<SchedulerSubsystem>>,
1259 ephemeral_root: PathBuf,
1260 }
1261
1262 impl RecordingSubsystems {
1263 /// A recording factory whose ephemeral stores live under `ephemeral_root`.
1264 pub fn rooted(ephemeral_root: &Path) -> Self {
1265 Self {
1266 census: Mutex::new(Vec::new()),
1267 ephemeral_root: ephemeral_root.to_path_buf(),
1268 }
1269 }
1270
1271 /// The recorded construction census, sorted for order-independent
1272 /// comparison.
1273 pub fn recorded(&self) -> Vec<SchedulerSubsystem> {
1274 let mut recorded = self
1275 .census
1276 .lock()
1277 .expect("subsystem census lock is never poisoned in tests")
1278 .clone();
1279 recorded.sort();
1280 recorded
1281 }
1282
1283 fn record(&self, subsystem: SchedulerSubsystem) {
1284 self.census
1285 .lock()
1286 .expect("subsystem census lock is never poisoned in tests")
1287 .push(subsystem);
1288 }
1289 }
1290
1291 impl SubsystemFactory for RecordingSubsystems {
1292 fn channel_cluster(
1293 &self,
1294 cluster_config: Option<&ClusterConfig>,
1295 ) -> Result<ChannelCluster, ServerError> {
1296 let cluster = ProductionSubsystems.channel_cluster(cluster_config)?;
1297 self.record(SchedulerSubsystem::ChannelSupervisor);
1298 Ok(cluster)
1299 }
1300
1301 fn conversation_supervisor(&self) -> Result<Arc<ConversationSupervisor>, ServerError> {
1302 let supervisor = ProductionSubsystems.conversation_supervisor()?;
1303 self.record(SchedulerSubsystem::ConversationSupervisor);
1304 Ok(supervisor)
1305 }
1306
1307 fn durable_store(
1308 &self,
1309 persistence_path: Option<&Path>,
1310 ) -> Result<Arc<dyn DurableStore>, ServerError> {
1311 let store = build_durable_store_with(persistence_path, || {
1312 open_ephemeral_rooted(&self.ephemeral_root, DEFAULT_SHARD_COUNT)
1313 })?;
1314 self.record(SchedulerSubsystem::HaematiteStore);
1315 Ok(store)
1316 }
1317 }
1318}
1319
1320/// Full-only constructor guard: rejects a config whose profile is not `Full`.
1321///
1322/// [`LiminalConnectionServices`]' config-based constructors call this at entry so
1323/// the full service stack can never be built for a worker-front-door config —
1324/// profile enforcement holds on every public construction path, not only the
1325/// file-loading pipeline.
1326fn require_full_profile(config: &ServerConfig) -> Result<(), ServerError> {
1327 match config.services.profile()? {
1328 ServiceProfile::Full => Ok(()),
1329 ServiceProfile::WorkerFrontDoor => Err(ServerError::ConfigValidation {
1330 message: format!(
1331 "services.profile: \"{}\" cannot construct the full LiminalConnectionServices; \
1332 build profile-selected services via build_connection_services",
1333 ServiceProfile::WORKER_FRONT_DOOR
1334 ),
1335 }),
1336 }
1337}
1338
1339/// Builds the connection-services adapter selected by `config`'s service profile.
1340///
1341/// `Full` builds [`LiminalConnectionServices`] (channels, conversations, durable
1342/// store, dedup cache) exactly as today. `WorkerFrontDoor` builds
1343/// [`WorkerFrontDoorServices`], which constructs none of that machinery. This is the
1344/// single profile-dispatch authority: [`super::supervisor::ConnectionSupervisor`]'s
1345/// config constructor and the standalone runtime's worker arm both route through it
1346/// (the runtime's full arm stays on the explicit
1347/// [`LiminalConnectionServices::from_config`] path because it also needs the shared
1348/// channel cluster, which the trait object does not expose — that path is guarded
1349/// full-only at entry).
1350///
1351/// # Errors
1352/// Returns [`ServerError`] when the selected adapter cannot be constructed, the
1353/// configured profile value is not recognised, or the worker-front-door profile is
1354/// combined with full-only config fields.
1355pub fn build_connection_services(
1356 config: &ServerConfig,
1357) -> Result<Arc<dyn ConnectionServices>, ServerError> {
1358 build_connection_services_via(config, &ProductionSubsystems)
1359}
1360
1361/// [`build_connection_services`] with the subsystem factory injected — the §9 D2
1362/// gate seam, both halves at once.
1363///
1364/// Every scheduler-owning subsystem the `Full` branch creates is constructed
1365/// through `subsystems` and nowhere else, so a recording factory observes exactly
1366/// what was built (thread half, record-by-construction); the gate's factory also
1367/// roots its ephemeral stores in an isolated directory, so "the root stays empty
1368/// on the worker branch" is a real negative assertion (fs half, the D3 pattern).
1369/// The `WorkerFrontDoor` branch never touches the factory — a regression that gave
1370/// the front door any subsystem would both record in the census and land a store
1371/// directory in the injected root.
1372///
1373/// The worker branch re-runs the cross-field checks here, not only in file-loading
1374/// validation, so a directly-constructed config cannot smuggle full-only machinery
1375/// past the profile.
1376pub(super) fn build_connection_services_via(
1377 config: &ServerConfig,
1378 subsystems: &dyn SubsystemFactory,
1379) -> Result<Arc<dyn ConnectionServices>, ServerError> {
1380 match config.services.profile()? {
1381 ServiceProfile::Full => {
1382 let store = subsystems.durable_store(config.persistence_path.as_deref())?;
1383 Ok(Arc::new(
1384 LiminalConnectionServices::from_config_with_store_via(config, store, subsystems)?,
1385 ))
1386 }
1387 ServiceProfile::WorkerFrontDoor => {
1388 let errors = crate::config::validation::worker_front_door_field_errors(config);
1389 if !errors.is_empty() {
1390 return Err(ServerError::ConfigValidation {
1391 message: errors.join("; "),
1392 });
1393 }
1394 Ok(Arc::new(WorkerFrontDoorServices::new()))
1395 }
1396 }
1397}
1398
1399/// Builds the haematite-backed durable store.
1400///
1401/// When `persistence_path` is `Some`, the database lives there and survives
1402/// process restarts: an existing database directory is reopened, a fresh one is
1403/// created. When it is `None` (no durable path configured, or the channel-free
1404/// `empty()` services used by tests), a self-owning ephemeral store is opened
1405/// instead: its temporary directory is created and removed by the store itself
1406/// (D3), so it leaves no residue once the last store handle drops. The two paths
1407/// return distinct concrete stores on purpose — only the ephemeral one carries a
1408/// directory guard; the persistent path is untouched.
1409fn build_durable_store(
1410 persistence_path: Option<&Path>,
1411) -> Result<Arc<dyn DurableStore>, ServerError> {
1412 build_durable_store_with(persistence_path, || open_ephemeral(DEFAULT_SHARD_COUNT))
1413}
1414
1415/// [`build_durable_store`] with the ephemeral factory injected.
1416///
1417/// The split exists for the D3 construction gates: the SAME branch logic runs
1418/// in production and tests, and only the factory closure differs — tests root
1419/// the ephemeral store in an isolated directory (via liminal's test-gated
1420/// rooted factory) so "no ephemeral directory was created" is a real assertion
1421/// rather than a scan of the shared system temp dir.
1422fn build_durable_store_with(
1423 persistence_path: Option<&Path>,
1424 make_ephemeral: impl FnOnce() -> Result<EphemeralHaematiteStore, DurabilityError>,
1425) -> Result<Arc<dyn DurableStore>, ServerError> {
1426 let Some(path) = persistence_path else {
1427 let store = make_ephemeral().map_err(|error| ServerError::ConfigValidation {
1428 message: format!("failed to open ephemeral durable store: {error}"),
1429 })?;
1430 return Ok(Arc::new(store));
1431 };
1432 let data_dir = path.join("durability");
1433 let database = open_or_create_database(&data_dir)?;
1434 let event_store = EventStore::new(database);
1435 Ok(Arc::new(HaematiteStore::new(Arc::new(event_store))))
1436}
1437
1438/// Opens an existing haematite database at `data_dir`, or creates one.
1439fn open_or_create_database(data_dir: &Path) -> Result<Database, ServerError> {
1440 let config_file = data_dir.join("config.json");
1441 let result = if config_file.exists() {
1442 Database::open(data_dir)
1443 } else {
1444 Database::create(DatabaseConfig {
1445 data_dir: data_dir.to_path_buf(),
1446 shard_count: DEFAULT_SHARD_COUNT,
1447 distributed: None,
1448 executor_threads: None,
1449 })
1450 };
1451 result.map_err(|error| ServerError::ConfigValidation {
1452 message: format!(
1453 "failed to open durable store at {}: {error}",
1454 data_dir.display()
1455 ),
1456 })
1457}
1458
1459impl ConnectionServices for LiminalConnectionServices {
1460 fn participant_service(&self) -> Option<InstalledParticipantService> {
1461 self.participant_service.clone()
1462 }
1463
1464 /// The roster's admission decision, exposed across the trait boundary as the
1465 /// permission alone.
1466 ///
1467 /// Two methods share the name `admit_channel` on this type: this trait
1468 /// method, and the private inherent funnel it delegates to. They are the
1469 /// same decision at two different boundaries — the funnel returns the ENTRY
1470 /// the decision was made on, because its in-crate callers go on to publish
1471 /// or subscribe through it, and this one returns `()`, because a caller
1472 /// outside the crate is asking whether it MAY, not for the thing itself.
1473 /// Handing the entry out here would make [`ConfiguredChannel`] public
1474 /// surface and hand a frame-level caller a channel handle it has no business
1475 /// holding. Rust resolves an inherent method ahead of a trait method of the
1476 /// same name, so the funnel's existing callers — and the `Self::` call below
1477 /// — reach the funnel, not this method; the trait form is reached only
1478 /// through a `dyn ConnectionServices`, which is exactly the caller it exists
1479 /// for. `an_admitted_operation_the_service_then_refuses_stays_undifferentiated`
1480 /// is the instrument that would catch this delegation turning into a
1481 /// recursion.
1482 ///
1483 /// `operation` is unused in v1 and the parameter is still right: absence
1484 /// refuses both operations, and quiesce refuses new publishes and new
1485 /// subscribes alike, so the two answers are equal today and not equal by
1486 /// definition. A signature that could not see what it was deciding about
1487 /// would have to break the day they part.
1488 fn admit_channel(
1489 &self,
1490 _operation: ChannelOperation,
1491 channel: &str,
1492 ) -> Result<(), ChannelAccessError> {
1493 Self::admit_channel(self, channel)?;
1494 Ok(())
1495 }
1496
1497 fn publish(
1498 &self,
1499 channel: &str,
1500 envelope: &MessageEnvelope,
1501 idempotency_key: Option<&str>,
1502 ) -> Result<PublishOutcome, ServerError> {
1503 // The roster read holds its guard only long enough to clone the entry's
1504 // `Arc` out; everything below — the dedup bridge and the publish into the
1505 // channel actor — runs with no roster lock held.
1506 //
1507 // This inner admission STAYS even once the connection process consults
1508 // the roster ahead of delegating: this method is public and callable
1509 // without going through a frame at all, and a guard that only exists in
1510 // the caller is not a guard.
1511 let configured = self
1512 .admit_channel(channel)
1513 .map_err(|error| access_to_server_error(&error))?;
1514
1515 // Dedup-on-delivery: a publish carrying an idempotency key is delivered to
1516 // subscribers AT MOST ONCE across re-publishes of the same key. Only a
1517 // fresh `Claimed` decision proceeds to fan-out; a `Completed`/`InFlight`
1518 // decision is a duplicate and is suppressed (no second delivery), which is
1519 // the at-most-once guarantee the aion outbox relies on.
1520 if let Some(key) = idempotency_key {
1521 if !self.claim_delivery(key)? {
1522 // A dedup-suppressed re-publish is still an accepted publish (it is
1523 // assigned a message id), but it reaches no subscriber, so it counts
1524 // toward publishes and not deliveries.
1525 crate::metrics::publish_accepted();
1526 return Ok(PublishOutcome {
1527 message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
1528 delivered: false,
1529 });
1530 }
1531 }
1532
1533 let delivery = configured.handle.publish_with_delivery(
1534 &envelope.payload,
1535 liminal::envelope::PublisherId::default(),
1536 None,
1537 );
1538 let delivery = match delivery {
1539 Ok(delivery) => delivery,
1540 Err(error) => {
1541 // The claim above appended an `InFlight` entry but the delivery
1542 // failed before `complete_receipt` could run. Release the claim so
1543 // the key is re-claimable; otherwise every re-publish would see
1544 // `InFlight` and be suppressed forever. Best-effort: surface the
1545 // ORIGINAL publish error regardless, but never swallow a release
1546 // failure silently (it leaves the leak intact).
1547 if let Some(key) = idempotency_key {
1548 self.release_claim(key);
1549 }
1550 return Err(ServerError::ListenerAccept {
1551 message: format!("liminal publish failed for channel '{channel}': {error}"),
1552 });
1553 }
1554 };
1555
1556 // Record the dedup completion AFTER a successful claimed delivery so the
1557 // claim is not left dangling `InFlight` (which would wrongly defer every
1558 // future duplicate). The receipt body is empty: the dedup contract here
1559 // only needs presence, not a stored result.
1560 if let Some(key) = idempotency_key {
1561 block_on(
1562 self.dedup
1563 .complete_receipt(key, ProcessingReceipt::new(Vec::new())),
1564 )
1565 .map_err(|error| ServerError::ListenerAccept {
1566 message: format!("dedup receipt bridge failed for key '{key}': {error}"),
1567 })?
1568 .map_err(|error| ServerError::ListenerAccept {
1569 message: format!("dedup receipt write failed for key '{key}': {error}"),
1570 })?;
1571 }
1572
1573 // Record the accepted publish and its genuine subscriber deliveries. The
1574 // delivered count (0 for an empty channel) is the same signal the delivery
1575 // ack is derived from.
1576 crate::metrics::publish_accepted();
1577 let delivered_count = u64::try_from(delivery.delivered_count()).unwrap_or(u64::MAX);
1578 crate::metrics::deliveries_recorded(delivered_count);
1579
1580 Ok(PublishOutcome {
1581 message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
1582 delivered: delivery.is_delivered(),
1583 })
1584 }
1585
1586 fn subscribe(
1587 &self,
1588 channel: &str,
1589 accepted_schemas: &[ProtocolSchemaId],
1590 install: Option<liminal::channel::InboxInstall>,
1591 ) -> Result<ConnectionSubscription, ServerError> {
1592 // As in `publish`: the admission funnel clones the entry's `Arc` out
1593 // under the read guard and the guard is released before schema
1594 // negotiation or the actor round-trip below. That release is what makes
1595 // this the linearisation point — a quiesce landing after it does not
1596 // stop the subscription this call is already building.
1597 let configured = self
1598 .admit_channel(channel)
1599 .map_err(|error| access_to_server_error(&error))?;
1600 let selected_schema = if accepted_schemas.is_empty() {
1601 configured.protocol_schema
1602 } else {
1603 liminal::protocol::negotiate_schema(configured.protocol_schema, accepted_schemas)
1604 .map_err(|error| server_error_from_protocol(&error))?
1605 };
1606 // `subscribe_with_install` installs the §5 budget/fairness cap and the R3
1607 // wake notifier on the inbox at construction — strictly before the
1608 // registration is published to the channel actor — so there is no window
1609 // in which a publish can land uncharged or without a wake.
1610 let subscription = install
1611 .map_or_else(
1612 || configured.handle.subscribe(),
1613 |install| configured.handle.subscribe_with_install(install),
1614 )
1615 .map_err(|error| ServerError::ListenerAccept {
1616 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
1617 })?;
1618 let id = self.next_subscription_id.fetch_add(1, Ordering::Relaxed);
1619 Ok(ConnectionSubscription::new(
1620 id,
1621 selected_schema,
1622 Box::new(LiminalSubscriptionResource { subscription }),
1623 ))
1624 }
1625
1626 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError> {
1627 subscription.unsubscribe()
1628 }
1629
1630 fn open_conversation(
1631 &self,
1632 conversation_id: u64,
1633 subject: &str,
1634 ) -> Result<ConnectionConversation, ServerError> {
1635 // Spawn a REAL participant process (a beamr `NativeHandler` running the
1636 // resolved responder behaviour) on the conversation supervisor's
1637 // scheduler, and a supervised conversation actor linked to it. The actor
1638 // FORWARDS each conversation message to the participant, which genuinely
1639 // processes it and delivers a reply back. The actor traps the
1640 // participant's EXIT (a beamr process link), so killing it fires a
1641 // structural, microsecond-scale crash signal.
1642 //
1643 // The responder is chosen by `subject`: a custom responder registered via
1644 // `register_responder` for this subject, or the built-in `EchoBehaviour`
1645 // when none is registered. Either way it runs as the SAME supervised,
1646 // linked participant process — the seam changes WHO responds, not HOW the
1647 // participant is spawned or supervised.
1648 let behaviour = self.responder_for(subject)?;
1649 let (actor, participant) = self
1650 .conversation_supervisor
1651 .spawn_with_participant(behaviour, None, ChannelMode::Ephemeral, CrashPolicy::Fail)
1652 .map_err(|error| ServerError::ListenerAccept {
1653 message: format!(
1654 "failed to spawn supervised conversation {conversation_id} ('{subject}'): {error}"
1655 ),
1656 })?;
1657
1658 // Drive boot to completion so the beamr link to the participant exists
1659 // before any message is forwarded (link-before-forward), mirroring the
1660 // ROUTING-004 dispatch pattern.
1661 actor.pid().map_err(|error| ServerError::ListenerAccept {
1662 message: format!(
1663 "failed to boot supervised conversation {conversation_id} ('{subject}'): {error}"
1664 ),
1665 })?;
1666
1667 // Register the structural EXIT notifier BEFORE returning, so a crash that
1668 // fires the instant a message reaches the participant is never missed.
1669 // The notifier is woken by the actor's trapped-EXIT handler (event
1670 // driven), and a crash that already landed is replayed immediately.
1671 let (exit_tx, exit_rx) = mpsc::sync_channel::<Instant>(1);
1672 actor
1673 .notify_on_participant_exit(participant, exit_tx)
1674 .map_err(|error| ServerError::ListenerAccept {
1675 message: format!(
1676 "failed to arm crash detection for conversation {conversation_id}: {error}"
1677 ),
1678 })?;
1679
1680 Ok(ConnectionConversation::new(Box::new(
1681 LiminalConversationResource::new(actor, participant, exit_rx),
1682 )))
1683 }
1684
1685 fn conversation_message(
1686 &self,
1687 conversation: &ConnectionConversation,
1688 envelope: &MessageEnvelope,
1689 ) -> Result<(), ServerError> {
1690 conversation.message(envelope)
1691 }
1692
1693 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError> {
1694 conversation.close()
1695 }
1696
1697 fn flush_durable_state(&self) -> Result<(), ServerError> {
1698 // Clone the roster out under the read guard and drop the guard before
1699 // flushing: a shutdown flush is a durable write per entry, and it must
1700 // never run with the roster lock held.
1701 let entries: Vec<(String, Arc<ConfiguredChannel>)> = {
1702 let channels = self.read_channels()?;
1703 channels
1704 .iter()
1705 .map(|(channel_name, configured)| (channel_name.clone(), Arc::clone(configured)))
1706 .collect()
1707 };
1708 for (channel_name, configured) in entries {
1709 if configured.handle.config().mode == ChannelMode::Durable {
1710 configured
1711 .handle
1712 .flush()
1713 .map_err(|error| ServerError::ShutdownFlush {
1714 message: format!(
1715 "failed to flush durable channel '{channel_name}': {error}"
1716 ),
1717 })?;
1718 }
1719 }
1720 Ok(())
1721 }
1722}
1723
1724/// One roster entry: a channel's library handle, the protocol schema id
1725/// advertised for it, where it came from, and its admission state.
1726#[derive(Debug)]
1727pub(super) struct ConfiguredChannel {
1728 handle: ChannelHandle,
1729 protocol_schema: ProtocolSchemaId,
1730 /// Boot-configured or runtime-registered. Written once at construction and
1731 /// NEVER flipped: it is the only field that predicts what a restart does to
1732 /// this entry, and it defines the population the registration cap counts.
1733 origin: ChannelOrigin,
1734 /// [`STATE_ACTIVE`] or [`STATE_QUIESCED`]. Written at most once, by one
1735 /// `compare_exchange`, so the transition is one-way and exactly one caller
1736 /// can perform it.
1737 state: AtomicU8,
1738 /// The quiesce cause, set STRICTLY BEFORE `state` flips so any reader that
1739 /// observes [`STATE_QUIESCED`] can read it. Written once (the `OnceLock`
1740 /// enforces that structurally), which is why a re-quiesce under a different
1741 /// reason must refuse rather than silently keep or replace one of them.
1742 quiesce_reason: OnceLock<String>,
1743}
1744
1745impl ConfiguredChannel {
1746 /// The entry's admission state as a value.
1747 ///
1748 /// One `Acquire` load and, when quiesced, one read of the already-written
1749 /// reason. Touches no actor: the whole point of recording state on the entry
1750 /// is that observing it cannot spawn the thing being observed.
1751 fn state(&self) -> ChannelState {
1752 if self.state.load(Ordering::Acquire) == STATE_QUIESCED {
1753 ChannelState::Quiesced {
1754 reason: self.recorded_quiesce_reason(),
1755 }
1756 } else {
1757 ChannelState::Active
1758 }
1759 }
1760
1761 /// The recorded quiesce reason, for a caller that has already observed
1762 /// [`STATE_QUIESCED`] with an `Acquire` load.
1763 fn recorded_quiesce_reason(&self) -> String {
1764 self.quiesce_reason
1765 .get()
1766 .map_or_else(|| UNRECORDED_QUIESCE_REASON.to_owned(), Clone::clone)
1767 }
1768
1769 /// Moves this entry from active to quiesced, recording `reason` first.
1770 ///
1771 /// The reason is written to the `OnceLock` BEFORE the `Release`
1772 /// `compare_exchange` that flips the state, so no reader can observe
1773 /// `QUIESCED` without being able to read why. Re-quiescing under the
1774 /// IDENTICAL reason is `Ok(())` — the caller's intent already holds — and a
1775 /// DIFFERENT reason refuses, because the recorded one cannot be replaced
1776 /// without losing a cause and cannot be kept without lying to the caller.
1777 fn quiesce(&self, name: &str, reason: &str) -> Result<(), ChannelRegistryError> {
1778 // Whoever wins the `OnceLock` writes the cause on record; every other
1779 // caller — a re-quiesce or a concurrent racer — is judged against THAT
1780 // reason, never against whether it happened to perform the flip itself.
1781 // Reporting success for having won the CAS would tell a racer whose
1782 // reason lost that its reason took effect.
1783 let recorded = match self.quiesce_reason.set(reason.to_owned()) {
1784 Ok(()) => reason.to_owned(),
1785 Err(_rejected) => self.recorded_quiesce_reason(),
1786 };
1787 // The reason is now readable, so the flip may become visible. The CAS
1788 // result is deliberately unused: the state is `QUIESCED` afterwards
1789 // whether this call or a racer performed the transition, and the answer
1790 // to the caller is governed by the recorded reason above.
1791 let _flipped_here = self.state.compare_exchange(
1792 STATE_ACTIVE,
1793 STATE_QUIESCED,
1794 Ordering::Release,
1795 Ordering::Acquire,
1796 );
1797 if recorded == reason {
1798 return Ok(());
1799 }
1800 Err(ChannelRegistryError::AlreadyQuiesced {
1801 name: name.to_owned(),
1802 reason: recorded,
1803 })
1804 }
1805}
1806
1807#[derive(Debug)]
1808struct LiminalSubscriptionResource {
1809 subscription: liminal::channel::SubscriptionHandle,
1810}
1811
1812impl SubscriptionResource for LiminalSubscriptionResource {
1813 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError> {
1814 drop(self.subscription);
1815 Ok(())
1816 }
1817
1818 fn is_overflowed(&self) -> bool {
1819 self.subscription.is_overflowed()
1820 }
1821
1822 fn has_pending(&self) -> bool {
1823 self.subscription.has_pending()
1824 }
1825
1826 fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
1827 match self.subscription.try_next() {
1828 Ok(envelope) => envelope,
1829 Err(error) => {
1830 // A poisoned inbox lock is PERMANENT, not transient: once poisoned it
1831 // stays poisoned, so every future `try_next` also returns `Err` and this
1832 // subscription goes silent for the rest of its life — no further
1833 // deliveries, not "held for the next slice". Poisoning requires a panic
1834 // while the lock is held, which the workspace lints forbid
1835 // (no unwrap/expect/panic), so this is an accepted low-probability
1836 // failure rather than a recoverable one. We keep the connection alive (a
1837 // single permanently-silent subscription is less harmful than tearing
1838 // down every other subscription and stream the connection multiplexes)
1839 // but log loudly so the silence is diagnosable. The log cannot storm:
1840 // it can only fire after the one panic that poisoned the lock.
1841 tracing::error!(
1842 %error,
1843 "subscription inbox lock is poisoned; this subscription is now \
1844 permanently silent and will deliver no further messages"
1845 );
1846 None
1847 }
1848 }
1849 }
1850}
1851
1852/// Renders an admission refusal as the service error the trait's callers expect.
1853///
1854/// The two refusals that already existed keep their EXACT bytes: an absent
1855/// channel is still `channel '<name>' is not configured` and a poisoned roster
1856/// is still `channel roster lock poisoned`, so nothing that reads these messages
1857/// today sees a change. Discrimination lives in the reason code
1858/// ([`ChannelAccessError::reason_code`]), which the connection process carries to
1859/// the wire — this rendering is the degraded path for a caller that reaches the
1860/// service directly and has no code to carry.
1861///
1862/// `Quiesced` has no predecessor string to preserve; it carries the refusal's own
1863/// message, reason included.
1864///
1865/// The connection process renders its admission refusals through this SAME
1866/// function rather than through [`ChannelAccessError`]'s own `Display`. Two
1867/// consequences, both wanted. The bytes on the wire for an absent channel stay
1868/// exactly what they have always been, which is the whole of the semver promise
1869/// this lane makes. And an operation refused AT admission and one refused after
1870/// admission by the service produce the identical message, differing only in the
1871/// reason code — so the code really is the only discriminator, instead of the
1872/// message quietly becoming a second one.
1873pub(super) fn access_to_server_error(error: &ChannelAccessError) -> ServerError {
1874 let message = match error {
1875 ChannelAccessError::NotRegistered { name } => {
1876 format!("channel '{name}' is not configured")
1877 }
1878 ChannelAccessError::Quiesced { .. } => error.to_string(),
1879 ChannelAccessError::RosterUnavailable { message } => message.clone(),
1880 };
1881 ServerError::ListenerAccept { message }
1882}
1883
1884pub(super) fn server_error_from_protocol(error: &ProtocolError) -> ServerError {
1885 ServerError::ListenerAccept {
1886 message: format!("protocol operation failed: {error}"),
1887 }
1888}
1889
1890/// The three pinned registration tests. A CHILD module of `services` because
1891/// test 2 must hold the exact entry the admission funnel handed out.
1892#[cfg(test)]
1893#[path = "services_registry_tests.rs"]
1894mod services_registry_tests;
1895
1896#[cfg(test)]
1897#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1898mod durable_store_tests {
1899 use liminal::durability::open_ephemeral_rooted;
1900
1901 use super::subsystem_census::RecordingSubsystems;
1902 use super::{
1903 ConnectionServices, DEFAULT_SHARD_COUNT, LiminalConnectionServices, SchedulerSubsystem,
1904 build_connection_services, build_connection_services_via, build_durable_store_with,
1905 };
1906 use crate::ServerError;
1907 use crate::config::types::{LimitsConfig, ServerConfig, ServicesConfig};
1908
1909 /// Counts directory entries under `root`, for the empty/one-dir assertions
1910 /// on an injected ephemeral root.
1911 fn entry_count(root: &std::path::Path) -> usize {
1912 std::fs::read_dir(root)
1913 .expect("ephemeral root is readable")
1914 .count()
1915 }
1916
1917 /// A minimal channel-free config with the given service `profile`. No channels,
1918 /// routing, persistence, or cluster — the shape both profiles accept (the full
1919 /// profile simply builds an empty channel set; the worker-front-door profile
1920 /// requires exactly this shape).
1921 fn config_with_profile(profile: &str) -> ServerConfig {
1922 ServerConfig {
1923 listen_address: "127.0.0.1:0".parse().expect("valid socket addr"),
1924 health_listen_address: "127.0.0.1:1".parse().expect("valid socket addr"),
1925 drain_timeout_ms: 30_000,
1926 channels: Vec::new(),
1927 routing_rules: Vec::new(),
1928 persistence_path: None,
1929 cluster: None,
1930 auth: None,
1931 services: ServicesConfig {
1932 profile: profile.to_owned(),
1933 },
1934 limits: LimitsConfig::default(),
1935 participant: None,
1936 websocket: None,
1937 }
1938 }
1939
1940 /// §9 D2 front-door construction gate (fs half): building the worker-front-door
1941 /// services creates NO haematite store and NO temp dir, while the full profile
1942 /// over an equally-rooted factory DOES create exactly one store directory.
1943 ///
1944 /// The injected root is the only place an ephemeral store directory can appear
1945 /// (the recording factory roots its stores there), so "the root stays empty on
1946 /// the front-door branch" is a real negative assertion: a regression that gave
1947 /// the front door a store would land its directory here and fail this test. The
1948 /// full-profile arm is the positive control proving the seam genuinely
1949 /// constructs a store when the profile asks for one.
1950 #[test]
1951 fn worker_front_door_builds_no_store_and_no_temp_dir() {
1952 let front_door_root = tempfile::tempdir().expect("test can create an ephemeral root");
1953 let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
1954
1955 let front_door_subsystems = RecordingSubsystems::rooted(front_door_root.path());
1956 let front_door: std::sync::Arc<dyn ConnectionServices> = build_connection_services_via(
1957 &config_with_profile("worker-front-door"),
1958 &front_door_subsystems,
1959 )
1960 .expect("worker-front-door services build");
1961 assert!(
1962 !front_door.supports_channel_operations(),
1963 "the worker front door serves no channel operations"
1964 );
1965 assert_eq!(
1966 entry_count(front_door_root.path()),
1967 0,
1968 "the worker front door creates no ephemeral store directory (no haematite, no temp dir)"
1969 );
1970
1971 let full_subsystems = RecordingSubsystems::rooted(full_root.path());
1972 let full = build_connection_services_via(&config_with_profile("full"), &full_subsystems)
1973 .expect("full services build");
1974 assert!(
1975 full.supports_channel_operations(),
1976 "full mode serves channel operations"
1977 );
1978 assert_eq!(
1979 entry_count(full_root.path()),
1980 1,
1981 "full mode with no persistence path builds exactly one ephemeral store directory"
1982 );
1983
1984 drop(front_door);
1985 drop(full);
1986 }
1987
1988 /// §9 D2 front-door construction gate (thread half — record-by-construction
1989 /// census): the worker profile constructs NO channel-supervisor,
1990 /// conversation-supervisor, or haematite scheduler, while the SAME instrument
1991 /// over the full profile records all three — the positive control proving the
1992 /// census detects the extra schedulers, so an empty census on the worker branch
1993 /// is a real observation, not a decoration.
1994 ///
1995 /// The instrument's boundary: recording happens INSIDE the [`SubsystemFactory`]
1996 /// methods that are the profile-aware path's only route to these constructors,
1997 /// so a recording cannot be silently omitted — a future subsystem added to this
1998 /// path either goes through the factory (and is recorded by construction) or
1999 /// bypasses it, which is a code-review-visible structural violation of the
2000 /// factory seam, not a silently-missing side call. The connection supervisor's
2001 /// own scheduler is the shared baseline of both profiles and is asserted at the
2002 /// supervisor level (`supervisor::tests`); an OS-level thread census upgrades
2003 /// this when the beamr composition lane's scheduler-inventory API (currently on
2004 /// their branch, not yet consumable from liminal) lands.
2005 #[test]
2006 fn worker_profile_census_is_empty_and_full_profile_records_all_schedulers() {
2007 let worker_root = tempfile::tempdir().expect("test can create an ephemeral root");
2008 let full_root = tempfile::tempdir().expect("test can create an ephemeral root");
2009
2010 let worker_subsystems = RecordingSubsystems::rooted(worker_root.path());
2011 let front_door = build_connection_services_via(
2012 &config_with_profile("worker-front-door"),
2013 &worker_subsystems,
2014 )
2015 .expect("worker-front-door services build");
2016 assert_eq!(
2017 worker_subsystems.recorded(),
2018 Vec::<SchedulerSubsystem>::new(),
2019 "the worker front door constructs no scheduler-owning subsystem"
2020 );
2021
2022 let full_subsystems = RecordingSubsystems::rooted(full_root.path());
2023 let full = build_connection_services_via(&config_with_profile("full"), &full_subsystems)
2024 .expect("full services build");
2025 assert_eq!(
2026 full_subsystems.recorded(),
2027 vec![
2028 SchedulerSubsystem::ChannelSupervisor,
2029 SchedulerSubsystem::ConversationSupervisor,
2030 SchedulerSubsystem::HaematiteStore,
2031 ],
2032 "the full profile constructs every scheduler-owning subsystem, once each — \
2033 the positive control proving the census instrument detects them"
2034 );
2035
2036 drop(front_door);
2037 drop(full);
2038 }
2039
2040 /// MAJOR-1 regression: the full-only constructors reject a worker-front-door
2041 /// config with a typed `ConfigValidation` error AT ENTRY — no full service can
2042 /// be created through any public config-based constructor under that profile.
2043 #[test]
2044 fn full_only_constructors_reject_worker_profile() {
2045 let config = config_with_profile("worker-front-door");
2046
2047 let from_config = LiminalConnectionServices::from_config(&config);
2048 assert!(
2049 matches!(from_config, Err(ServerError::ConfigValidation { .. })),
2050 "from_config must reject a worker-front-door profile with ConfigValidation, got {from_config:?}"
2051 );
2052
2053 let root = tempfile::tempdir().expect("test can create an ephemeral root");
2054 let store = open_ephemeral_rooted(root.path(), DEFAULT_SHARD_COUNT)
2055 .expect("test store for the rejection check builds");
2056 let from_config_with_store =
2057 LiminalConnectionServices::from_config_with_store(&config, std::sync::Arc::new(store));
2058 assert!(
2059 matches!(
2060 from_config_with_store,
2061 Err(ServerError::ConfigValidation { .. })
2062 ),
2063 "from_config_with_store must reject a worker-front-door profile with ConfigValidation"
2064 );
2065 }
2066
2067 /// MAJOR-1 regression: the profile-aware factory itself re-runs the
2068 /// worker-front-door cross-field checks, so a directly-constructed config (one
2069 /// that never passed file-loading validation) combining the worker profile with
2070 /// full-only machinery is refused with the same typed `ConfigValidation` errors.
2071 #[test]
2072 fn build_connection_services_rejects_worker_profile_with_full_only_fields() {
2073 let mut config = config_with_profile("worker-front-door");
2074 config.channels = vec![crate::config::types::ChannelDef {
2075 name: "orders".to_owned(),
2076 schema_ref: None,
2077 durable: false,
2078 loaded_schema: None,
2079 }];
2080 config.persistence_path = Some(std::path::PathBuf::from("/tmp"));
2081
2082 let result = build_connection_services(&config);
2083 let Err(ServerError::ConfigValidation { message }) = result else {
2084 panic!("expected ConfigValidation for worker profile with full-only fields");
2085 };
2086 assert!(message.contains("builds no channels"), "got: {message}");
2087 assert!(
2088 message.contains("builds no durable store"),
2089 "got: {message}"
2090 );
2091 }
2092
2093 /// §9 D3 construction gate (persistent half): requesting a *persistent* store
2094 /// creates its database under the configured path and NO ephemeral directory.
2095 ///
2096 /// Exercises `build_durable_store_with` — the same branch logic production
2097 /// runs — with only the ephemeral factory swapped to root in an isolated
2098 /// directory. That root is where any ephemeral directory would have to
2099 /// appear, so "the root stays empty" is a real negative assertion — a
2100 /// regression that constructs an ephemeral store on the persistent branch
2101 /// lands its directory here and fails this test.
2102 #[test]
2103 fn persistent_store_uses_configured_path_and_creates_no_temp_dir() {
2104 let home = tempfile::tempdir().expect("test can create a temp dir");
2105 let ephemeral_root = tempfile::tempdir().expect("test can create an ephemeral root");
2106
2107 let store = build_durable_store_with(Some(home.path()), || {
2108 open_ephemeral_rooted(ephemeral_root.path(), DEFAULT_SHARD_COUNT)
2109 })
2110 .expect("persistent store builds");
2111
2112 assert!(
2113 home.path().join("durability").join("config.json").exists(),
2114 "the persistent database is created under the configured path"
2115 );
2116 assert_eq!(
2117 entry_count(ephemeral_root.path()),
2118 0,
2119 "the persistent branch creates no ephemeral guard directory"
2120 );
2121
2122 drop(store);
2123 }
2124
2125 /// Pins the wiring seam: the ephemeral (`None`) branch of the shared build
2126 /// logic goes through the guarded constructor — exactly one directory
2127 /// appears under the injected root while the store lives, and zero residue
2128 /// remains after the last handle drops.
2129 #[test]
2130 fn ephemeral_store_directory_is_owned_through_the_build_seam() {
2131 let ephemeral_root = tempfile::tempdir().expect("test can create an ephemeral root");
2132
2133 let store = build_durable_store_with(None, || {
2134 open_ephemeral_rooted(ephemeral_root.path(), DEFAULT_SHARD_COUNT)
2135 })
2136 .expect("ephemeral store builds");
2137
2138 assert_eq!(
2139 entry_count(ephemeral_root.path()),
2140 1,
2141 "the ephemeral branch creates exactly one guard directory"
2142 );
2143
2144 drop(store);
2145
2146 assert_eq!(
2147 entry_count(ephemeral_root.path()),
2148 0,
2149 "dropping the last store handle removes the guard directory — zero residue"
2150 );
2151 }
2152}