liminal_server/server/connection/services.rs
1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, mpsc};
5use std::time::Instant;
6
7use haematite::{Database, DatabaseConfig, EventStore};
8use liminal::channel::{ChannelConfig, ChannelHandle, ChannelMode, Schema};
9use liminal::conversation::{
10 ConversationSupervisor, CrashPolicy, EchoBehaviour, ParticipantBehaviour,
11};
12use liminal::durability::bridge::block_on;
13use liminal::durability::{
14 DedupCache, DedupDecision, DurableStore, HaematiteStore, ProcessingReceipt,
15};
16use liminal::protocol::{MessageEnvelope, ProtocolError, SchemaId as ProtocolSchemaId};
17
18use super::conversation::{ConnectionConversation, LiminalConversationResource};
19use super::services_cluster::build_channel_cluster;
20use super::services_schema::resolve_channel_schema;
21use crate::ServerError;
22use crate::config::types::ServerConfig;
23
24pub use super::services_cluster::ChannelCluster;
25
26/// Registry of custom conversation responders, keyed by conversation subject.
27///
28/// A registered [`ParticipantBehaviour`] becomes the participant for any
29/// conversation opened on its subject; subjects with no entry fall back to the
30/// built-in [`EchoBehaviour`].
31type ResponderRegistry = HashMap<String, Arc<dyn ParticipantBehaviour>>;
32
33/// Marker for resources retained by a connection process until unsubscribe.
34pub trait SubscriptionResource: std::fmt::Debug + Send {
35 /// Releases the library subscription resource.
36 ///
37 /// # Errors
38 /// Returns [`ServerError`] when the liminal library reports an unsubscribe failure.
39 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError>;
40
41 /// Attempts to pull the next delivered envelope from the wrapped library
42 /// subscription without blocking.
43 ///
44 /// Returns `None` when the subscriber inbox is empty (or momentarily
45 /// unavailable): the connection process is the delivery pump, so a transient
46 /// empty read is simply "nothing to deliver this slice", never an error.
47 fn try_next(&mut self) -> Option<liminal::envelope::Envelope>;
48}
49
50/// Library subscription resource owned by a single connection process.
51#[derive(Debug)]
52pub struct ConnectionSubscription {
53 id: u64,
54 /// Client-chosen application stream the server delivers this subscription's
55 /// messages on (echoed on `SubscribeAck`, carried on every `Deliver`). Set by
56 /// the connection process from the `Subscribe` frame before the subscription is
57 /// stored; `0` only while momentarily unset during construction.
58 stream_id: u32,
59 selected_schema: ProtocolSchemaId,
60 resource: Box<dyn SubscriptionResource>,
61}
62
63impl ConnectionSubscription {
64 /// Creates an owned subscription resource for one connection process.
65 #[must_use]
66 pub fn new(
67 id: u64,
68 selected_schema: ProtocolSchemaId,
69 resource: Box<dyn SubscriptionResource>,
70 ) -> Self {
71 Self {
72 id,
73 stream_id: 0,
74 selected_schema,
75 resource,
76 }
77 }
78
79 /// Returns the protocol subscription id.
80 #[must_use]
81 pub const fn id(&self) -> u64 {
82 self.id
83 }
84
85 /// Records the client-chosen delivery stream id for this subscription.
86 pub(super) const fn set_stream_id(&mut self, stream_id: u32) {
87 self.stream_id = stream_id;
88 }
89
90 /// Returns the client-chosen application stream id deliveries ride on.
91 #[must_use]
92 pub(super) const fn stream_id(&self) -> u32 {
93 self.stream_id
94 }
95
96 /// Returns the schema selected for this subscription stream.
97 #[must_use]
98 pub const fn selected_schema(&self) -> ProtocolSchemaId {
99 self.selected_schema
100 }
101
102 /// Attempts to pull the next delivered envelope without blocking.
103 pub(super) fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
104 self.resource.try_next()
105 }
106
107 pub(super) fn unsubscribe(self) -> Result<(), ServerError> {
108 self.resource.unsubscribe()
109 }
110}
111
112/// Outcome of a server publish.
113///
114/// Carries the assigned message id plus a genuine delivery ack (`delivered` = the
115/// message was accepted by at least one live subscriber on this publish, after any
116/// dedup-on-delivery suppression).
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub struct PublishOutcome {
119 /// Monotonic message id assigned to the accepted publish.
120 pub message_id: u64,
121 /// Whether the message was genuinely delivered to a subscriber. `false` means
122 /// the publish was accepted but reached no subscriber (empty channel) or was
123 /// a duplicate suppressed by dedup-on-delivery.
124 pub delivered: bool,
125}
126
127/// Operations that adapt wire frames to liminal library calls.
128pub trait ConnectionServices: std::fmt::Debug + Send + Sync {
129 /// Delegates a publish request to the liminal library.
130 ///
131 /// `idempotency_key`, when `Some`, drives dedup-on-delivery: a re-publish with
132 /// the same key is delivered to subscribers at most once. The returned
133 /// [`PublishOutcome`] carries the genuine delivery ack.
134 ///
135 /// # Errors
136 /// Returns [`ServerError`] when the liminal publish operation fails.
137 fn publish(
138 &self,
139 channel: &str,
140 envelope: &MessageEnvelope,
141 idempotency_key: Option<&str>,
142 ) -> Result<PublishOutcome, ServerError>;
143
144 /// Delegates a subscribe request to the liminal library.
145 ///
146 /// # Errors
147 /// Returns [`ServerError`] when the liminal subscribe operation fails.
148 fn subscribe(
149 &self,
150 channel: &str,
151 accepted_schemas: &[ProtocolSchemaId],
152 ) -> Result<ConnectionSubscription, ServerError>;
153
154 /// Delegates unsubscribe to the liminal library.
155 ///
156 /// # Errors
157 /// Returns [`ServerError`] when the liminal unsubscribe operation fails.
158 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError>;
159
160 /// Delegates conversation open to the liminal library.
161 ///
162 /// # Errors
163 /// Returns [`ServerError`] when the liminal conversation open operation fails.
164 fn open_conversation(
165 &self,
166 conversation_id: u64,
167 subject: &str,
168 ) -> Result<ConnectionConversation, ServerError>;
169
170 /// Delegates a conversation message to the liminal library.
171 ///
172 /// # Errors
173 /// Returns [`ServerError`] when the liminal conversation message operation fails.
174 fn conversation_message(
175 &self,
176 conversation: &ConnectionConversation,
177 envelope: &MessageEnvelope,
178 ) -> Result<(), ServerError>;
179
180 /// Delegates conversation close to the liminal library.
181 ///
182 /// # Errors
183 /// Returns [`ServerError`] when the liminal conversation close operation fails.
184 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError>;
185
186 /// Flushes durable channel state through the liminal library boundary.
187 ///
188 /// # Errors
189 /// Returns [`ServerError`] when the liminal channel flush operation fails.
190 fn flush_durable_state(&self) -> Result<(), ServerError>;
191}
192
193/// Default adapter from server wire frames to liminal channel/conversation APIs.
194#[derive(Debug)]
195pub struct LiminalConnectionServices {
196 channels: HashMap<String, ConfiguredChannel>,
197 cluster: ChannelCluster,
198 durable_store: Arc<dyn DurableStore>,
199 /// In-memory (haematite-backed) dedup cache for dedup-on-delivery. Keyed by
200 /// the per-message idempotency key carried on the publish frame; a duplicate
201 /// key is suppressed before fan-out so a subscriber receives it at most once.
202 /// Not persisted across restarts (13-L1 scope; durable dedup is deferred).
203 dedup: DedupCache,
204 conversation_supervisor: Arc<ConversationSupervisor>,
205 /// Registered custom conversation responders, keyed by conversation subject.
206 ///
207 /// When a conversation is opened (`open_conversation`), the subject is looked
208 /// up here: a registered [`ParticipantBehaviour`] becomes the conversation's
209 /// participant; with no registration the conversation falls back to the
210 /// built-in [`EchoBehaviour`], preserving the original echo semantics exactly.
211 /// This is the seam aion #13 plugs a remote worker responder into. Interior
212 /// mutability is required because the services are shared behind `&self`.
213 responders: Mutex<ResponderRegistry>,
214 next_message_id: AtomicU64,
215 next_subscription_id: AtomicU64,
216}
217
218impl LiminalConnectionServices {
219 /// Builds library-backed services from validated server configuration.
220 ///
221 /// Durable-mode channels are backed by a shared haematite event store so
222 /// their publishes are persisted and survive the graceful-shutdown flush;
223 /// ephemeral channels carry no store.
224 ///
225 /// # Errors
226 /// Returns [`ServerError`] when a configured channel cannot be initialized.
227 pub fn from_config(config: &ServerConfig) -> Result<Self, ServerError> {
228 let store = build_durable_store(config.persistence_path.as_deref())?;
229 Self::from_config_with_store(config, store)
230 }
231
232 /// Builds services over a caller-provided durable store.
233 ///
234 /// Used by tests that need to inspect persisted state through the same store
235 /// handle the durable channels write to.
236 ///
237 /// # Errors
238 /// Returns [`ServerError`] when a configured channel cannot be initialized.
239 pub fn from_config_with_store(
240 config: &ServerConfig,
241 durable_store: Arc<dyn DurableStore>,
242 ) -> Result<Self, ServerError> {
243 // Build ONE shared channel supervisor for the whole server. When a
244 // [cluster] section is present it is distribution-enabled, so every
245 // channel actor and subscriber shares the clustered scheduler the cluster
246 // attaches its process-group transport to (SRV-005, Constraint B).
247 let cluster = build_channel_cluster(config.cluster.as_ref())?;
248 let mut channels = HashMap::new();
249 for channel in &config.channels {
250 // Resolve the channel's real JSON Schema (loaded from `schema_ref`
251 // during config validation) or the permissive empty schema when the
252 // channel declared none. The protocol schema id advertised at
253 // subscribe time is derived from the SAME schema bytes so an SDK
254 // deriving ids from schema bytes converges on it.
255 let resolved = resolve_channel_schema(channel);
256 let schema =
257 Schema::new(resolved.document).map_err(|error| ServerError::ConfigValidation {
258 message: format!("failed to initialize channel '{}': {error}", channel.name),
259 })?;
260 let channel_config = if channel.durable {
261 ChannelConfig::new(channel.name.clone(), schema, ChannelMode::Durable)
262 } else {
263 ChannelConfig::new(channel.name.clone(), schema, ChannelMode::Ephemeral)
264 };
265 let handle = if channel.durable {
266 ChannelHandle::new_durable_with_supervisor(
267 channel_config,
268 Arc::clone(&durable_store),
269 cluster.supervisor().clone(),
270 )
271 .map_err(|error| ServerError::ConfigValidation {
272 message: format!(
273 "failed to initialize durable channel '{}': {error}",
274 channel.name
275 ),
276 })?
277 } else {
278 ChannelHandle::with_supervisor(channel_config, cluster.supervisor().clone())
279 };
280 channels.insert(
281 channel.name.clone(),
282 ConfiguredChannel {
283 handle,
284 protocol_schema: resolved.protocol_id,
285 },
286 );
287 }
288 let conversation_supervisor = Arc::new(ConversationSupervisor::new().map_err(|error| {
289 ServerError::ConfigValidation {
290 message: format!("failed to start conversation supervisor: {error}"),
291 }
292 })?);
293 let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
294 Ok(Self {
295 channels,
296 cluster,
297 durable_store,
298 dedup,
299 conversation_supervisor,
300 responders: Mutex::new(HashMap::new()),
301 next_message_id: AtomicU64::new(1),
302 next_subscription_id: AtomicU64::new(1),
303 })
304 }
305
306 /// Builds services with no configured channels.
307 ///
308 /// # Errors
309 /// Returns [`ServerError`] when the conversation supervisor scheduler cannot start.
310 pub fn empty() -> Result<Self, ServerError> {
311 let conversation_supervisor = Arc::new(ConversationSupervisor::new().map_err(|error| {
312 ServerError::ConfigValidation {
313 message: format!("failed to start conversation supervisor: {error}"),
314 }
315 })?);
316 let durable_store = build_durable_store(None)?;
317 let dedup = DedupCache::new(Arc::clone(&durable_store), DELIVERY_DEDUP_NAMESPACE);
318 Ok(Self {
319 channels: HashMap::new(),
320 cluster: build_channel_cluster(None)?,
321 durable_store,
322 dedup,
323 conversation_supervisor,
324 responders: Mutex::new(HashMap::new()),
325 next_message_id: AtomicU64::new(1),
326 next_subscription_id: AtomicU64::new(1),
327 })
328 }
329
330 /// The shared channel supervisor + cluster resolver backing this service.
331 ///
332 /// The server runtime uses this to attach the cluster to the channel
333 /// supervisor's clustered scheduler (SRV-005).
334 #[must_use]
335 pub const fn channel_cluster(&self) -> &ChannelCluster {
336 &self.cluster
337 }
338
339 /// Returns the shared durable store backing this service's durable channels.
340 #[must_use]
341 pub fn durable_store(&self) -> Arc<dyn DurableStore> {
342 Arc::clone(&self.durable_store)
343 }
344
345 /// Returns the conversation supervisor backing supervised conversations.
346 ///
347 /// Tests use this to reach the underlying beamr scheduler so they can spawn
348 /// or terminate participant processes and exercise crash detection.
349 #[must_use]
350 pub fn conversation_supervisor(&self) -> Arc<ConversationSupervisor> {
351 Arc::clone(&self.conversation_supervisor)
352 }
353
354 /// Registers a custom conversation responder for a routing `subject`.
355 ///
356 /// When a conversation is later opened with this exact `subject`, its
357 /// participant runs `behaviour` instead of the built-in [`EchoBehaviour`].
358 /// The responder is spawned and supervised identically to the echo
359 /// participant — a real linked beamr process with the same crash-detection
360 /// semantics — so this exposes the responder seam without changing how
361 /// participants run. Registering a subject that already has a responder
362 /// replaces it; the previous behaviour is returned.
363 ///
364 /// This is the liminal-side seam aion #13 plugs a remote worker into: it
365 /// registers a responder that forwards each request to the worker and routes
366 /// the worker's reply back through the conversation. Subjects with no
367 /// registration keep echoing, so existing callers are unaffected.
368 ///
369 /// # Errors
370 /// Returns [`ServerError`] when the responder registry lock is poisoned.
371 pub fn register_responder(
372 &self,
373 subject: impl Into<String>,
374 behaviour: Arc<dyn ParticipantBehaviour>,
375 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
376 let mut responders = self.lock_responders()?;
377 Ok(responders.insert(subject.into(), behaviour))
378 }
379
380 /// Removes the custom responder registered for `subject`, if any.
381 ///
382 /// After removal the subject reverts to the built-in [`EchoBehaviour`] on the
383 /// next [`Self::open_conversation`]. Returns the removed behaviour when one
384 /// was registered.
385 ///
386 /// # Errors
387 /// Returns [`ServerError`] when the responder registry lock is poisoned.
388 pub fn unregister_responder(
389 &self,
390 subject: &str,
391 ) -> Result<Option<Arc<dyn ParticipantBehaviour>>, ServerError> {
392 let mut responders = self.lock_responders()?;
393 Ok(responders.remove(subject))
394 }
395
396 /// Resolves the responder behaviour for `subject`: the registered custom
397 /// responder when present, otherwise the built-in [`EchoBehaviour`].
398 ///
399 /// This is the single routing decision behind the seam — registered-or-echo —
400 /// so the fallback is identical to the original hard-wired echo path.
401 fn responder_for(&self, subject: &str) -> Result<Arc<dyn ParticipantBehaviour>, ServerError> {
402 let responders = self.lock_responders()?;
403 Ok(responders.get(subject).map_or_else(
404 || Arc::new(EchoBehaviour) as Arc<dyn ParticipantBehaviour>,
405 Arc::clone,
406 ))
407 }
408
409 /// Locks the responder registry, mapping a poisoned lock to a [`ServerError`]
410 /// rather than panicking (the workspace denies `unwrap`/`expect`/`panic`).
411 fn lock_responders(&self) -> Result<std::sync::MutexGuard<'_, ResponderRegistry>, ServerError> {
412 self.responders
413 .lock()
414 .map_err(|_poisoned| ServerError::ListenerAccept {
415 message: "responder registry lock poisoned".to_owned(),
416 })
417 }
418
419 /// Subscribes to a configured channel and returns the raw library
420 /// subscription handle so a test can drain the subscriber inbox directly and
421 /// observe exactly which messages reached a subscriber.
422 #[cfg(test)]
423 pub(crate) fn subscribe_handle_for_test(
424 &self,
425 channel: &str,
426 ) -> Result<liminal::channel::SubscriptionHandle, ServerError> {
427 let configured = self
428 .channels
429 .get(channel)
430 .ok_or_else(|| ServerError::ListenerAccept {
431 message: format!("channel '{channel}' is not configured"),
432 })?;
433 configured
434 .handle
435 .subscribe()
436 .map_err(|error| ServerError::ListenerAccept {
437 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
438 })
439 }
440
441 /// Claims the delivery right for an idempotency key.
442 ///
443 /// Returns `Ok(true)` when this is the first publish for the key (the caller
444 /// may deliver), and `Ok(false)` when the key was already claimed/completed (a
445 /// duplicate the caller must suppress). The dedup cache is driven synchronously
446 /// over the in-memory haematite store via the durable bridge.
447 fn claim_delivery(&self, key: &str) -> Result<bool, ServerError> {
448 let decision = block_on(self.dedup.claim_or_get(key, dedup_timestamp_millis()))
449 .map_err(|error| ServerError::ListenerAccept {
450 message: format!("dedup bridge failed for key '{key}': {error}"),
451 })?
452 .map_err(|error| ServerError::ListenerAccept {
453 message: format!("dedup claim failed for key '{key}': {error}"),
454 })?;
455 Ok(matches!(decision, DedupDecision::Claimed))
456 }
457
458 /// Releases a dangling in-flight dedup claim after a failed delivery.
459 ///
460 /// Best-effort: a release failure cannot mask the original publish error, so
461 /// this returns nothing and logs at `error` level instead of surfacing. It is
462 /// never silent — the leak (a permanently suppressed key) must be observable.
463 /// `release_claim` itself never clobbers a stored receipt, so calling it on the
464 /// failure path is safe even if a concurrent completion raced ahead.
465 fn release_claim(&self, key: &str) {
466 match block_on(self.dedup.release_claim(key)) {
467 Ok(Ok(())) => {}
468 Ok(Err(error)) => {
469 tracing::error!(
470 idempotency_key = key,
471 %error,
472 "failed to release dedup claim after publish failure; key may stay suppressed"
473 );
474 }
475 Err(error) => {
476 tracing::error!(
477 idempotency_key = key,
478 %error,
479 "dedup release bridge failed after publish failure; key may stay suppressed"
480 );
481 }
482 }
483 }
484}
485
486/// Returns the current epoch-millis timestamp used as the dedup entry anchor.
487///
488/// A clock error before the Unix epoch yields `0`: the timestamp is only a TTL
489/// anchor for the in-memory cache and a zero anchor never breaks the at-most-once
490/// claim semantics, so this avoids surfacing a clock fault on the publish path.
491fn dedup_timestamp_millis() -> u64 {
492 use std::time::{SystemTime, UNIX_EPOCH};
493 SystemTime::now()
494 .duration_since(UNIX_EPOCH)
495 .ok()
496 .and_then(|duration| u64::try_from(duration.as_millis()).ok())
497 .unwrap_or(0)
498}
499
500/// Default shard count for an on-disk durable store.
501///
502/// Haematite routes keys across this many single-threaded shard actors; a small
503/// power of two gives parallelism across cursors/streams without spawning an
504/// actor per core. The value is fixed (haematite has no silent default) and not
505/// yet surfaced in server config.
506const DEFAULT_SHARD_COUNT: usize = 8;
507
508/// Namespace prefix for the dedup-on-delivery cache streams. Keeps delivery dedup
509/// keys from colliding with any other haematite streams in the shared store.
510const DELIVERY_DEDUP_NAMESPACE: &str = "liminal:delivery-dedup";
511
512/// Builds the on-disk haematite-backed durable store.
513///
514/// When `persistence_path` is `Some`, the database lives there and survives
515/// process restarts: an existing database directory is reopened, a fresh one is
516/// created. When it is `None` (no durable path configured, or the channel-free
517/// `empty()` services used by tests), an ephemeral per-instance directory under
518/// the system temp dir is created instead — it still persists to disk for the
519/// lifetime of the process, but is not a stable restart location.
520fn build_durable_store(
521 persistence_path: Option<&Path>,
522) -> Result<Arc<dyn DurableStore>, ServerError> {
523 let data_dir = persistence_path.map_or_else(ephemeral_data_dir, |path| path.join("durability"));
524 let database = open_or_create_database(&data_dir)?;
525 let event_store = EventStore::new(database);
526 Ok(Arc::new(HaematiteStore::new(Arc::new(event_store))))
527}
528
529/// Opens an existing haematite database at `data_dir`, or creates one.
530fn open_or_create_database(data_dir: &Path) -> Result<Database, ServerError> {
531 let config_file = data_dir.join("config.json");
532 let result = if config_file.exists() {
533 Database::open(data_dir)
534 } else {
535 Database::create(DatabaseConfig {
536 data_dir: data_dir.to_path_buf(),
537 shard_count: DEFAULT_SHARD_COUNT,
538 sweep_interval: None,
539 distributed: None,
540 })
541 };
542 result.map_err(|error| ServerError::ConfigValidation {
543 message: format!(
544 "failed to open durable store at {}: {error}",
545 data_dir.display()
546 ),
547 })
548}
549
550/// Produces a unique on-disk directory under the system temp dir.
551///
552/// The pid plus a monotonic counter keep concurrent servers and parallel tests
553/// from sharing a database directory.
554fn ephemeral_data_dir() -> PathBuf {
555 static COUNTER: AtomicU64 = AtomicU64::new(0);
556 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
557 std::env::temp_dir().join(format!(
558 "liminal-durability-{}-{unique}",
559 std::process::id()
560 ))
561}
562
563impl ConnectionServices for LiminalConnectionServices {
564 fn publish(
565 &self,
566 channel: &str,
567 envelope: &MessageEnvelope,
568 idempotency_key: Option<&str>,
569 ) -> Result<PublishOutcome, ServerError> {
570 let handle = self
571 .channels
572 .get(channel)
573 .map(|configured| configured.handle.clone())
574 .ok_or_else(|| ServerError::ListenerAccept {
575 message: format!("channel '{channel}' is not configured"),
576 })?;
577
578 // Dedup-on-delivery: a publish carrying an idempotency key is delivered to
579 // subscribers AT MOST ONCE across re-publishes of the same key. Only a
580 // fresh `Claimed` decision proceeds to fan-out; a `Completed`/`InFlight`
581 // decision is a duplicate and is suppressed (no second delivery), which is
582 // the at-most-once guarantee the aion outbox relies on.
583 if let Some(key) = idempotency_key {
584 if !self.claim_delivery(key)? {
585 // A dedup-suppressed re-publish is still an accepted publish (it is
586 // assigned a message id), but it reaches no subscriber, so it counts
587 // toward publishes and not deliveries.
588 crate::metrics::publish_accepted();
589 return Ok(PublishOutcome {
590 message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
591 delivered: false,
592 });
593 }
594 }
595
596 let delivery = handle.publish_with_delivery(
597 &envelope.payload,
598 liminal::envelope::PublisherId::default(),
599 None,
600 );
601 let delivery = match delivery {
602 Ok(delivery) => delivery,
603 Err(error) => {
604 // The claim above appended an `InFlight` entry but the delivery
605 // failed before `complete_receipt` could run. Release the claim so
606 // the key is re-claimable; otherwise every re-publish would see
607 // `InFlight` and be suppressed forever. Best-effort: surface the
608 // ORIGINAL publish error regardless, but never swallow a release
609 // failure silently (it leaves the leak intact).
610 if let Some(key) = idempotency_key {
611 self.release_claim(key);
612 }
613 return Err(ServerError::ListenerAccept {
614 message: format!("liminal publish failed for channel '{channel}': {error}"),
615 });
616 }
617 };
618
619 // Record the dedup completion AFTER a successful claimed delivery so the
620 // claim is not left dangling `InFlight` (which would wrongly defer every
621 // future duplicate). The receipt body is empty: the dedup contract here
622 // only needs presence, not a stored result.
623 if let Some(key) = idempotency_key {
624 block_on(
625 self.dedup
626 .complete_receipt(key, ProcessingReceipt::new(Vec::new())),
627 )
628 .map_err(|error| ServerError::ListenerAccept {
629 message: format!("dedup receipt bridge failed for key '{key}': {error}"),
630 })?
631 .map_err(|error| ServerError::ListenerAccept {
632 message: format!("dedup receipt write failed for key '{key}': {error}"),
633 })?;
634 }
635
636 // Record the accepted publish and its genuine subscriber deliveries. The
637 // delivered count (0 for an empty channel) is the same signal the delivery
638 // ack is derived from.
639 crate::metrics::publish_accepted();
640 let delivered_count = u64::try_from(delivery.delivered_count()).unwrap_or(u64::MAX);
641 crate::metrics::deliveries_recorded(delivered_count);
642
643 Ok(PublishOutcome {
644 message_id: self.next_message_id.fetch_add(1, Ordering::Relaxed),
645 delivered: delivery.is_delivered(),
646 })
647 }
648
649 fn subscribe(
650 &self,
651 channel: &str,
652 accepted_schemas: &[ProtocolSchemaId],
653 ) -> Result<ConnectionSubscription, ServerError> {
654 let configured = self
655 .channels
656 .get(channel)
657 .ok_or_else(|| ServerError::ListenerAccept {
658 message: format!("channel '{channel}' is not configured"),
659 })?;
660 let selected_schema = if accepted_schemas.is_empty() {
661 configured.protocol_schema
662 } else {
663 liminal::protocol::negotiate_schema(configured.protocol_schema, accepted_schemas)
664 .map_err(|error| server_error_from_protocol(&error))?
665 };
666 let subscription =
667 configured
668 .handle
669 .subscribe()
670 .map_err(|error| ServerError::ListenerAccept {
671 message: format!("liminal subscribe failed for channel '{channel}': {error}"),
672 })?;
673 let id = self.next_subscription_id.fetch_add(1, Ordering::Relaxed);
674 Ok(ConnectionSubscription::new(
675 id,
676 selected_schema,
677 Box::new(LiminalSubscriptionResource { subscription }),
678 ))
679 }
680
681 fn unsubscribe(&self, subscription: ConnectionSubscription) -> Result<(), ServerError> {
682 subscription.unsubscribe()
683 }
684
685 fn open_conversation(
686 &self,
687 conversation_id: u64,
688 subject: &str,
689 ) -> Result<ConnectionConversation, ServerError> {
690 // Spawn a REAL participant process (a beamr `NativeHandler` running the
691 // resolved responder behaviour) on the conversation supervisor's
692 // scheduler, and a supervised conversation actor linked to it. The actor
693 // FORWARDS each conversation message to the participant, which genuinely
694 // processes it and delivers a reply back. The actor traps the
695 // participant's EXIT (a beamr process link), so killing it fires a
696 // structural, microsecond-scale crash signal.
697 //
698 // The responder is chosen by `subject`: a custom responder registered via
699 // `register_responder` for this subject, or the built-in `EchoBehaviour`
700 // when none is registered. Either way it runs as the SAME supervised,
701 // linked participant process — the seam changes WHO responds, not HOW the
702 // participant is spawned or supervised.
703 let behaviour = self.responder_for(subject)?;
704 let (actor, participant) = self
705 .conversation_supervisor
706 .spawn_with_participant(behaviour, None, ChannelMode::Ephemeral, CrashPolicy::Fail)
707 .map_err(|error| ServerError::ListenerAccept {
708 message: format!(
709 "failed to spawn supervised conversation {conversation_id} ('{subject}'): {error}"
710 ),
711 })?;
712
713 // Drive boot to completion so the beamr link to the participant exists
714 // before any message is forwarded (link-before-forward), mirroring the
715 // ROUTING-004 dispatch pattern.
716 actor.pid().map_err(|error| ServerError::ListenerAccept {
717 message: format!(
718 "failed to boot supervised conversation {conversation_id} ('{subject}'): {error}"
719 ),
720 })?;
721
722 // Register the structural EXIT notifier BEFORE returning, so a crash that
723 // fires the instant a message reaches the participant is never missed.
724 // The notifier is woken by the actor's trapped-EXIT handler (event
725 // driven), and a crash that already landed is replayed immediately.
726 let (exit_tx, exit_rx) = mpsc::sync_channel::<Instant>(1);
727 actor
728 .notify_on_participant_exit(participant, exit_tx)
729 .map_err(|error| ServerError::ListenerAccept {
730 message: format!(
731 "failed to arm crash detection for conversation {conversation_id}: {error}"
732 ),
733 })?;
734
735 Ok(ConnectionConversation::new(Box::new(
736 LiminalConversationResource::new(actor, participant, exit_rx),
737 )))
738 }
739
740 fn conversation_message(
741 &self,
742 conversation: &ConnectionConversation,
743 envelope: &MessageEnvelope,
744 ) -> Result<(), ServerError> {
745 conversation.message(envelope)
746 }
747
748 fn close_conversation(&self, conversation: ConnectionConversation) -> Result<(), ServerError> {
749 conversation.close()
750 }
751
752 fn flush_durable_state(&self) -> Result<(), ServerError> {
753 for (channel_name, configured) in &self.channels {
754 if configured.handle.config().mode == ChannelMode::Durable {
755 configured
756 .handle
757 .flush()
758 .map_err(|error| ServerError::ShutdownFlush {
759 message: format!(
760 "failed to flush durable channel '{channel_name}': {error}"
761 ),
762 })?;
763 }
764 }
765 Ok(())
766 }
767}
768
769#[derive(Debug)]
770struct ConfiguredChannel {
771 handle: ChannelHandle,
772 protocol_schema: ProtocolSchemaId,
773}
774
775#[derive(Debug)]
776struct LiminalSubscriptionResource {
777 subscription: liminal::channel::SubscriptionHandle,
778}
779
780impl SubscriptionResource for LiminalSubscriptionResource {
781 fn unsubscribe(self: Box<Self>) -> Result<(), ServerError> {
782 drop(self.subscription);
783 Ok(())
784 }
785
786 fn try_next(&mut self) -> Option<liminal::envelope::Envelope> {
787 match self.subscription.try_next() {
788 Ok(envelope) => envelope,
789 Err(error) => {
790 // A poisoned inbox lock is PERMANENT, not transient: once poisoned it
791 // stays poisoned, so every future `try_next` also returns `Err` and this
792 // subscription goes silent for the rest of its life — no further
793 // deliveries, not "held for the next slice". Poisoning requires a panic
794 // while the lock is held, which the workspace lints forbid
795 // (no unwrap/expect/panic), so this is an accepted low-probability
796 // failure rather than a recoverable one. We keep the connection alive (a
797 // single permanently-silent subscription is less harmful than tearing
798 // down every other subscription and stream the connection multiplexes)
799 // but log loudly so the silence is diagnosable. The log cannot storm:
800 // it can only fire after the one panic that poisoned the lock.
801 tracing::error!(
802 %error,
803 "subscription inbox lock is poisoned; this subscription is now \
804 permanently silent and will deliver no further messages"
805 );
806 None
807 }
808 }
809 }
810}
811
812pub(super) fn server_error_from_protocol(error: &ProtocolError) -> ServerError {
813 ServerError::ListenerAccept {
814 message: format!("protocol operation failed: {error}"),
815 }
816}