liminal/channel/types.rs
1//! Public channel surface: [`ChannelConfig`], [`ChannelMode`], and the
2//! cloneable [`ChannelHandle`] that drives a REAL supervised beamr channel
3//! actor (LIM-002).
4//!
5//! The handle is a thin, synchronous-looking facade over the process-backed
6//! actor: every operation enqueues a typed command onto the actor's mailbox and
7//! blocks on a per-command reply (the haematite `ShardHandle` pattern). It owns
8//! no subscriber state and performs no fan-out itself — the actor process does.
9//!
10//! `ChannelHandle::new` stays infallible (existing call-sites depend on it): the
11//! actor is spawned lazily on first use and the spawn result is memoised, so a
12//! scheduler failure surfaces as a `LiminalError` from the first operation
13//! rather than as a panic.
14
15use std::sync::atomic::AtomicU32;
16use std::sync::{Arc, Mutex, OnceLock};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use serde_json::Value;
20
21use crate::causal::CausalContext;
22use crate::channel::actor::{ChannelActorCore, predicate_from};
23use crate::channel::observer::ClusterObserver;
24use crate::channel::schema::{Schema, SchemaId, SchemaValidationError};
25use crate::channel::subscription::{InboxInstall, SubscriptionHandle, SubscriptionPredicate};
26use crate::channel::supervisor::{ChannelSupervisor, shared_supervisor};
27use crate::durability::bridge::block_on;
28use crate::durability::{DurableChannel, DurableStore, MessageEnvelope, recover_durable_channel};
29use crate::envelope::{Envelope, PublisherId};
30use crate::error::LiminalError;
31
32/// Single-partition count used to back a flat runtime channel with durable storage.
33const RUNTIME_DURABLE_PARTITIONS: usize = 1;
34
35/// Genuine delivery ack returned by [`ChannelHandle::publish_with_delivery`].
36///
37/// Distinct from backpressure: it reports whether a published message was
38/// actually received by a subscriber, not whether it was admitted to the bus.
39#[derive(Clone, Copy, Debug, PartialEq, Eq)]
40pub struct ChannelDelivery {
41 delivered_count: usize,
42}
43
44impl ChannelDelivery {
45 /// Number of local subscribers the message was genuinely delivered to.
46 #[must_use]
47 pub const fn delivered_count(&self) -> usize {
48 self.delivered_count
49 }
50
51 /// Whether the message was accepted by at least one subscriber.
52 #[must_use]
53 pub const fn is_delivered(&self) -> bool {
54 self.delivered_count > 0
55 }
56}
57
58/// Defines whether a channel is memory-only or durable across restarts.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum ChannelMode {
61 /// In-memory channel mode with no persistence overhead.
62 Ephemeral,
63 /// Durable channel mode reserved for future haematite-backed storage.
64 Durable,
65}
66
67/// Compatibility alias for the channel-owned schema definition.
68pub type SchemaRef = Schema;
69
70/// Required configuration for creating a typed channel.
71#[derive(Clone, Debug)]
72pub struct ChannelConfig {
73 /// Explicit channel name.
74 pub name: String,
75 /// Explicit schema for validating published payloads.
76 pub schema: Schema,
77 /// Explicit durability mode for the channel.
78 pub mode: ChannelMode,
79}
80
81impl ChannelConfig {
82 /// Creates channel configuration from its required fields.
83 #[must_use]
84 pub const fn new(name: String, schema: Schema, mode: ChannelMode) -> Self {
85 Self { name, schema, mode }
86 }
87}
88
89/// A lazily-spawned, supervised channel actor shared by every clone of a handle.
90///
91/// `supervisor` is stored as a `Result` so [`ChannelHandle::new`] can stay
92/// infallible: a scheduler-start failure is captured here and surfaced as a
93/// `LiminalError` the first time the actor is actually used.
94struct ChannelActorState {
95 supervisor: Result<ChannelSupervisor, String>,
96 core: OnceLock<Result<Arc<ChannelActorCore>, String>>,
97 restarts: AtomicU32,
98}
99
100impl ChannelActorState {
101 const fn new(supervisor: Result<ChannelSupervisor, String>) -> Self {
102 Self {
103 supervisor,
104 core: OnceLock::new(),
105 restarts: AtomicU32::new(0),
106 }
107 }
108
109 fn supervisor(&self) -> Result<&ChannelSupervisor, LiminalError> {
110 self.supervisor
111 .as_ref()
112 .map_err(|message| LiminalError::PublishFailed {
113 message: format!("channel supervisor unavailable: {message}"),
114 })
115 }
116
117 /// The installed cluster observer, if this channel runs on a clustered
118 /// supervisor (SRV-005). Returns `None` for non-clustered channels.
119 fn observer(&self) -> Option<Arc<dyn ClusterObserver>> {
120 self.supervisor
121 .as_ref()
122 .ok()
123 .and_then(|supervisor| supervisor.observer().cloned())
124 }
125
126 /// Returns the live actor core, spawning it (and any restart) on demand.
127 fn core(&self, schema: &Schema) -> Result<Arc<ChannelActorCore>, LiminalError> {
128 let supervisor = self.supervisor()?;
129 let stored = self.core.get_or_init(|| {
130 supervisor
131 .spawn_channel(schema.clone())
132 .map_err(|error| error.to_string())
133 });
134 let core = stored
135 .as_ref()
136 .map_err(|message| LiminalError::PublishFailed {
137 message: format!("channel actor unavailable: {message}"),
138 })?;
139 // Restart on a dead pid (R4) before returning the core for use.
140 supervisor.ensure_running(core, &self.restarts)?;
141 Ok(Arc::clone(core))
142 }
143}
144
145impl std::fmt::Debug for ChannelActorState {
146 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 formatter
148 .debug_struct("ChannelActorState")
149 .field("supervisor", &self.supervisor)
150 .finish_non_exhaustive()
151 }
152}
153
154/// Cloneable handle for interacting with a channel actor process.
155#[derive(Clone, Debug)]
156pub struct ChannelHandle {
157 config: ChannelConfig,
158 actor: Arc<ChannelActorState>,
159 durable: Option<Arc<Mutex<DurableChannel>>>,
160}
161
162impl ChannelHandle {
163 /// Creates an ephemeral handle backed by a real supervised channel actor on
164 /// the shared default supervisor.
165 ///
166 /// The actor process is spawned lazily on first use; a scheduler failure is
167 /// surfaced as a [`LiminalError`] from the first operation, not a panic.
168 #[must_use]
169 pub fn new(config: ChannelConfig) -> Self {
170 let supervisor = shared_supervisor().map_err(|error| error.to_string());
171 Self {
172 config,
173 actor: Arc::new(ChannelActorState::new(supervisor)),
174 durable: None,
175 }
176 }
177
178 /// Creates an ephemeral handle bound to an explicit `supervisor` (isolation
179 /// for the registry and tests).
180 #[must_use]
181 pub fn with_supervisor(config: ChannelConfig, supervisor: ChannelSupervisor) -> Self {
182 Self {
183 config,
184 actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
185 durable: None,
186 }
187 }
188
189 /// Creates a durable handle that persists every accepted publish to `store`
190 /// before fanning it out to subscribers.
191 ///
192 /// Construction reads the store: every partition's next-sequence counter is
193 /// recovered from the existing stream head, so a handle rebuilt over a
194 /// previously used store resumes appending where the log left off instead
195 /// of conflicting at sequence zero. A fresh store recovers to zero, which is
196 /// identical to cold start. This makes construction O(stream length) in
197 /// store reads and adds store read errors to the failure modes below.
198 ///
199 /// # Errors
200 ///
201 /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
202 /// initialized over `store`, including when recovering the per-partition
203 /// sequence counters from the store fails.
204 pub fn new_durable(
205 config: ChannelConfig,
206 store: Arc<dyn DurableStore>,
207 ) -> Result<Self, LiminalError> {
208 let durable = recover_durable(&config.name, store)?;
209 let supervisor = shared_supervisor()?;
210 Ok(Self {
211 config,
212 actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
213 durable: Some(Arc::new(Mutex::new(durable))),
214 })
215 }
216
217 /// Creates a durable handle bound to an explicit `supervisor`.
218 ///
219 /// Used by the standalone server so every channel — durable or ephemeral —
220 /// shares ONE (optionally clustered) supervisor and thus one scheduler, which
221 /// is the precondition for cross-node delivery (SRV-005): a subscriber pid
222 /// joined to a channel's distributed process group must live on the same
223 /// scheduler that owns the distribution links.
224 ///
225 /// Like [`Self::new_durable`], construction recovers each partition's
226 /// next-sequence counter from the store, so restarting over an existing
227 /// persistence path resumes the log instead of conflicting at sequence zero.
228 ///
229 /// # Errors
230 ///
231 /// Returns [`LiminalError::PublishFailed`] when the durable channel cannot be
232 /// initialized over `store`, including when recovering the per-partition
233 /// sequence counters from the store fails.
234 pub fn new_durable_with_supervisor(
235 config: ChannelConfig,
236 store: Arc<dyn DurableStore>,
237 supervisor: ChannelSupervisor,
238 ) -> Result<Self, LiminalError> {
239 let durable = recover_durable(&config.name, store)?;
240 Ok(Self {
241 config,
242 actor: Arc::new(ChannelActorState::new(Ok(supervisor))),
243 durable: Some(Arc::new(Mutex::new(durable))),
244 })
245 }
246
247 /// Returns the channel configuration used to create this handle.
248 #[must_use]
249 pub const fn config(&self) -> &ChannelConfig {
250 &self.config
251 }
252
253 /// Publishes a payload to the channel with the default publisher identity.
254 ///
255 /// # Errors
256 ///
257 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
258 pub fn publish<Payload>(&self, payload: Payload) -> Result<(), LiminalError>
259 where
260 Payload: AsRef<[u8]>,
261 {
262 self.publish_with_context(payload, PublisherId::default(), None)
263 }
264
265 /// Publishes a payload with an explicit publisher identity.
266 ///
267 /// # Errors
268 ///
269 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
270 pub fn publish_from<Payload>(
271 &self,
272 publisher_id: impl Into<PublisherId>,
273 payload: Payload,
274 ) -> Result<(), LiminalError>
275 where
276 Payload: AsRef<[u8]>,
277 {
278 self.publish_with_context(payload, publisher_id.into(), None)
279 }
280
281 /// Publishes a payload with explicit publisher and causal metadata.
282 ///
283 /// # Errors
284 ///
285 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
286 pub fn publish_with_context<Payload>(
287 &self,
288 payload: Payload,
289 publisher_id: PublisherId,
290 causal_context: Option<CausalContext>,
291 ) -> Result<(), LiminalError>
292 where
293 Payload: AsRef<[u8]>,
294 {
295 self.publish_with_delivery(payload, publisher_id, causal_context)
296 .map(|_delivery| ())
297 }
298
299 /// Publishes a payload and reports a genuine delivery ack.
300 ///
301 /// Returns a [`ChannelDelivery`] whose `delivered_count` is the number of
302 /// local subscribers the message was actually delivered to. A caller that
303 /// needs to know the message was ACCEPTED by a subscriber (not merely
304 /// buffered/published) inspects [`ChannelDelivery::is_delivered`]. This is the
305 /// channel-library half of the 13-L1 delivery-ack signal; the publish-without-
306 /// delivery methods stay unchanged for existing callers.
307 ///
308 /// # Errors
309 ///
310 /// Returns a [`LiminalError`] when the channel cannot accept the payload or the schema rejects it.
311 pub fn publish_with_delivery<Payload>(
312 &self,
313 payload: Payload,
314 publisher_id: PublisherId,
315 causal_context: Option<CausalContext>,
316 ) -> Result<ChannelDelivery, LiminalError>
317 where
318 Payload: AsRef<[u8]>,
319 {
320 // Durable channels persist the message to the store BEFORE acknowledging
321 // the publish (and before fanning out): a published message that was not
322 // durably recorded would be lost on shutdown, which CN7 forbids.
323 if let Some(durable) = self.durable.as_ref() {
324 self.persist_durable(durable, payload.as_ref(), &publisher_id)?;
325 }
326 let core = self.core()?;
327 let outcome = core.publish(payload.as_ref().to_vec(), publisher_id, causal_context)?;
328 // SRV-005: hand the normalised envelope to the cluster observer so it can
329 // fan the message out to remote subscribers. Local fan-out already
330 // happened inside `core.publish`; this is purely the cross-node leg and
331 // is a no-op when no observer is installed (non-clustered channels).
332 if let Some(observer) = self.actor.observer() {
333 observer.on_publish(&self.config.name, &outcome.envelope);
334 }
335 Ok(ChannelDelivery {
336 delivered_count: outcome.delivered_count,
337 })
338 }
339
340 fn persist_durable(
341 &self,
342 durable: &Arc<Mutex<DurableChannel>>,
343 payload: &[u8],
344 publisher_id: &PublisherId,
345 ) -> Result<(), LiminalError> {
346 let envelope = MessageEnvelope {
347 payload: payload.to_vec(),
348 causal_context: None,
349 timestamp: now_millis(),
350 publisher_id: publisher_id.as_str().to_owned(),
351 idempotency_key: None,
352 };
353 let publish_result = {
354 let mut channel = durable
355 .lock()
356 .map_err(|error| LiminalError::PublishFailed {
357 message: format!("durable channel state unavailable: {error}"),
358 })?;
359 block_on(channel.publish(&envelope))
360 };
361 publish_result
362 .map_err(|error| LiminalError::PublishFailed {
363 message: format!(
364 "durable publish bridge for channel '{}' failed: {error}",
365 self.config.name
366 ),
367 })?
368 .map_err(|error| LiminalError::PublishFailed {
369 message: format!(
370 "durable publish to channel '{}' failed: {error}",
371 self.config.name
372 ),
373 })?;
374 Ok(())
375 }
376
377 /// Returns the schema version currently owned by the channel actor.
378 ///
379 /// # Errors
380 ///
381 /// Returns a [`LiminalError`] when the channel actor cannot be read.
382 pub fn current_schema_id(&self) -> Result<SchemaId, LiminalError> {
383 self.core()?.schema_id()
384 }
385
386 /// Evolves the channel schema by adding a defaulted field without disconnecting subscribers.
387 ///
388 /// # Errors
389 ///
390 /// Returns [`SchemaValidationError`] when the schema cannot be evolved.
391 pub fn evolve_schema_add_field(
392 &self,
393 name: impl Into<String>,
394 field_schema: Value,
395 default: Value,
396 ) -> Result<SchemaId, SchemaValidationError> {
397 let core = self
398 .core()
399 .map_err(|error| SchemaValidationError::InvalidSchema {
400 message: error.to_string(),
401 })?;
402 core.evolve(name.into(), field_schema, default)
403 }
404
405 /// Subscribes to the channel, receiving every published message.
406 ///
407 /// # Errors
408 ///
409 /// Returns a [`LiminalError`] when a subscription cannot be created.
410 pub fn subscribe(&self) -> Result<SubscriptionHandle, LiminalError> {
411 self.subscribe_inner(None, None)
412 }
413
414 /// Subscribes with a server-connection [`InboxInstall`]: the §5 shared byte
415 /// budget, per-inbox fairness cap, and R3 wake notifier are installed on the
416 /// inbox AT CONSTRUCTION — strictly before the registration is published to
417 /// the channel actor — so no envelope can be admitted uncharged, past the
418 /// depth cap, or without a wake (the pre-install window is structurally
419 /// closed, not merely narrowed).
420 ///
421 /// # Errors
422 ///
423 /// Returns a [`LiminalError`] when a subscription cannot be created.
424 pub fn subscribe_with_install(
425 &self,
426 install: InboxInstall,
427 ) -> Result<SubscriptionHandle, LiminalError> {
428 self.subscribe_inner(None, Some(install))
429 }
430
431 /// Subscribes with a delivery predicate: only messages for which `predicate`
432 /// returns `true` are delivered to this subscriber. The predicate is owned
433 /// and evaluated by the actor process (R3).
434 ///
435 /// # Clustering
436 ///
437 /// The predicate filters **local-node publishes only**. Under clustering
438 /// (SRV-005), messages published on a remote node are delivered to this
439 /// subscriber *ungated* — the predicate is a non-serializable closure and is
440 /// not propagated across the wire, so remote nodes cannot evaluate it. If you
441 /// need filtering to hold for cross-node traffic, filter again on receipt
442 /// rather than relying on this predicate alone.
443 ///
444 /// # Errors
445 ///
446 /// Returns a [`LiminalError`] when a subscription cannot be created.
447 pub fn subscribe_filtered<F>(&self, predicate: F) -> Result<SubscriptionHandle, LiminalError>
448 where
449 F: Fn(&Envelope) -> bool + Send + Sync + 'static,
450 {
451 self.subscribe_inner(Some(predicate_from(predicate)), None)
452 }
453
454 fn subscribe_inner(
455 &self,
456 predicate: Option<SubscriptionPredicate>,
457 install: Option<InboxInstall>,
458 ) -> Result<SubscriptionHandle, LiminalError> {
459 let core = self.core()?;
460 let (handle, registration) =
461 SubscriptionHandle::spawn(core.scheduler(), predicate, install)?;
462 let pid = registration.pid();
463 core.subscribe(registration)?;
464 // SRV-005: tell the cluster a local subscriber joined this channel so it
465 // can advertise the subscription to peers via its process group.
466 if let Some(observer) = self.actor.observer() {
467 observer.on_subscribe(&self.config.name, pid);
468 }
469 Ok(handle)
470 }
471
472 /// Unsubscribes the subscriber owning `subscription` by its process pid.
473 ///
474 /// # Errors
475 ///
476 /// Returns a [`LiminalError`] when the unsubscribe command fails.
477 pub fn unsubscribe(&self, subscription: &SubscriptionHandle) -> Result<(), LiminalError> {
478 let pid = subscription.pid();
479 self.core()?.unsubscribe(pid)?;
480 // SRV-005: tell the cluster the local subscriber left so it can withdraw
481 // the subscription from its process group.
482 if let Some(observer) = self.actor.observer() {
483 observer.on_unsubscribe(&self.config.name, pid);
484 }
485 Ok(())
486 }
487
488 /// Flushes buffered durable channel state to the backing store before shutdown.
489 ///
490 /// # Errors
491 ///
492 /// Returns a [`LiminalError`] when the channel actor cannot be inspected or
493 /// when the durable store flush fails.
494 pub fn flush(&self) -> Result<(), LiminalError> {
495 // Confirm the actor is reachable (and restart it if needed) before flush.
496 drop(self.core()?);
497 let Some(durable) = self.durable.as_ref() else {
498 return Ok(());
499 };
500 let flush_result = {
501 let channel = durable
502 .lock()
503 .map_err(|error| LiminalError::PublishFailed {
504 message: format!("durable channel state unavailable: {error}"),
505 })?;
506 block_on(channel.flush_store())
507 };
508 flush_result
509 .map_err(|error| LiminalError::PublishFailed {
510 message: format!(
511 "durable flush bridge for channel '{}' failed: {error}",
512 self.config.name
513 ),
514 })?
515 .map_err(|error| LiminalError::PublishFailed {
516 message: format!(
517 "durable flush for channel '{}' failed: {error}",
518 self.config.name
519 ),
520 })?;
521 Ok(())
522 }
523
524 /// Returns the number of currently-active subscribers on the channel actor.
525 ///
526 /// # Errors
527 ///
528 /// Returns a [`LiminalError`] when the actor cannot service the query.
529 pub fn subscriber_count(&self) -> Result<usize, LiminalError> {
530 Ok(self.core()?.list_subscribers()?.len())
531 }
532
533 /// Closes the channel gracefully, stopping the actor process.
534 ///
535 /// # Errors
536 ///
537 /// Returns a [`LiminalError`] when the channel cannot be shut down.
538 pub fn close(&self) -> Result<(), LiminalError> {
539 self.core()?.close()
540 }
541
542 /// Whether this channel's actor has been spawned — **without spawning it**.
543 ///
544 /// This is the one accessor on the handle that observes the lazy-spawn
545 /// state instead of consuming it. Every other accessor
546 /// ([`subscriber_count`](Self::subscriber_count),
547 /// [`close`](Self::close)) routes through the private `core()`, which
548 /// spawns the actor on demand, so reading "is there an actor?" through any
549 /// of them destroys the very property being read. A handle that spawns
550 /// lazily has a genuine, observable idle/live state, and a consumer that
551 /// needs to know it — a registry reporting which of its channels are inert,
552 /// a cost accountant bounding what mere configuration costs — otherwise has
553 /// to choose between not knowing and changing the answer by asking.
554 ///
555 /// It reads the memoised spawn slot and nothing else: no supervisor call,
556 /// no schema clone, no `ensure_running`. That last exclusion is the point
557 /// of the restriction — `ensure_running` RESTARTS a dead actor, so an
558 /// implementation routed through it would repair the state rather than
559 /// report it.
560 ///
561 /// # What `false` and `true` mean
562 ///
563 /// The slot holds `Result<Arc<ChannelActorCore>, String>`: a spawn that
564 /// failed is memoised as `Err` and, because the slot is one-shot, is never
565 /// retried — every later operation on this handle fails with that stored
566 /// message. So a failed spawn ATTEMPT leaves no actor and can never acquire
567 /// one, and this method answers `false` for it, exactly as it does for a
568 /// channel nobody has touched. `false` therefore means "no actor core
569 /// exists" and deliberately does not distinguish "never attempted" from
570 /// "attempted and permanently failed" — neither has an actor.
571 ///
572 /// `true` means the actor core exists, not that its process is currently
573 /// alive: an actor whose pid has died stays `true` until the next real
574 /// operation restarts it through the supervisor. Liveness is a stronger
575 /// question than this method asks, and answering it would require running
576 /// the repair path this method exists to avoid.
577 #[must_use]
578 pub fn is_actor_spawned(&self) -> bool {
579 self.actor.core.get().is_some_and(Result::is_ok)
580 }
581
582 fn core(&self) -> Result<Arc<ChannelActorCore>, LiminalError> {
583 self.actor.core(&self.config.schema)
584 }
585
586 /// The channel actor's current beamr pid, ensuring it is running first.
587 /// Test-only: lets restart tests crash the exact actor process.
588 #[cfg(test)]
589 pub(crate) fn actor_pid(&self) -> Result<u64, LiminalError> {
590 let core = self.core()?;
591 core.current_pid()?
592 .ok_or_else(|| LiminalError::DeliveryFailed {
593 message: "channel actor has no live pid".to_owned(),
594 })
595 }
596
597 /// The scheduler the channel actor and its subscribers run on (test-only).
598 #[cfg(test)]
599 pub(crate) fn scheduler(&self) -> Result<Arc<beamr::scheduler::Scheduler>, LiminalError> {
600 Ok(Arc::clone(self.core()?.scheduler()))
601 }
602}
603
604/// Reconstructs a durable channel over `store`, deriving each partition's next
605/// sequence from the persisted log so a restart over an existing stream appends at
606/// the tail rather than colliding with the occupied head (H2 / ledger G1). On a
607/// fresh store, recovery yields zeroed sequences — identical to a first-boot
608/// channel — so this is the single durable-construction path for both cold start
609/// and restart.
610///
611/// The async recovery is driven to completion through the synchronous
612/// [`block_on`] bridge, exactly as the durable publish/flush paths do: the
613/// haematite store completes on its first poll, so no executor is needed.
614fn recover_durable(
615 channel_name: &str,
616 store: Arc<dyn DurableStore>,
617) -> Result<DurableChannel, LiminalError> {
618 block_on(recover_durable_channel(
619 channel_name.to_owned(),
620 RUNTIME_DURABLE_PARTITIONS,
621 store,
622 ))
623 .map_err(|error| LiminalError::PublishFailed {
624 message: format!("durable recovery bridge for channel '{channel_name}' failed: {error}"),
625 })?
626 .map_err(|error| LiminalError::PublishFailed {
627 message: format!("failed to recover durable channel '{channel_name}': {error}"),
628 })
629}
630
631/// Returns the current epoch milliseconds, saturating to zero before the epoch.
632fn now_millis() -> u64 {
633 SystemTime::now()
634 .duration_since(UNIX_EPOCH)
635 .map_or(0, |duration| {
636 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
637 })
638}