1mod events;
4
5pub use events::{
6 PluginEventEndpointConfig, PluginEventEndpointStats, PluginEventEnvelope, PluginEventOverflow,
7 PluginEventPayloadEncoding, PluginEventPublishError, PluginEventPublishReport,
8 PluginEventPublisherStats, PluginEventReceiveError, PluginEventRouteError, PluginEventRouter,
9 PluginEventRouterStats, PluginEventSelector, PluginEventSubscribeError,
10 PluginEventSubscription, PluginEventTopic, PluginEventTryReceiveError, PluginEvents,
11};
12
13use std::any::{Any, TypeId};
14use std::collections::{BTreeSet, HashMap, HashSet};
15use std::future::Future;
16use std::panic::AssertUnwindSafe;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, Mutex, OnceLock, Weak};
19use std::time::Duration;
20
21use futures::FutureExt;
22use portable_atomic::AtomicU64;
23use thiserror::Error;
24use wacore::iq::spec::IqSpec;
25use wacore::runtime::{
26 BoxFuture, Runtime, ShutdownNotifier, ShutdownSignal, Spawnable, timeout as runtime_timeout,
27 wait_for_shutdown,
28};
29use wacore::sync_marker::MaybeSendSync;
30use wacore::types::events::{EventHandler, EventInterest, EventKind, Subscription};
31use wacore_binary::Jid;
32use waproto::whatsapp::Message;
33
34use crate::Client;
35use crate::client::{ClientLifecycle, ConnectionScope, ConnectionScopeState, RawNodeLease};
36use crate::request::IqError;
37use crate::send::{SendError, SendResult};
38
39const CAP_CORE_EVENTS: u64 = 1 << 0;
40const CAP_TASKS: u64 = 1 << 1;
41const CAP_MESSAGING: u64 = 1 << 2;
42const CAP_IQ: u64 = 1 << 3;
43const CAP_PLUGIN_EVENTS: u64 = 1 << 4;
44const DEFAULT_PLUGIN_INSTALL_TIMEOUT: Duration = Duration::from_secs(30);
45const DEFAULT_PLUGIN_CALLBACK_TIMEOUT: Duration = Duration::from_secs(5);
46const DEFAULT_PLUGIN_TASK_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50#[non_exhaustive]
51pub enum PluginCapability {
52 CoreEvents,
53 Tasks,
54 Messaging,
55 Iq,
56 PluginEvents,
57}
58
59impl PluginCapability {
60 pub const fn identifier(self) -> &'static str {
61 match self {
62 Self::CoreEvents => "events.core.observe",
63 Self::Tasks => "tasks.spawn",
64 Self::Messaging => "messaging.send",
65 Self::Iq => "iq.execute",
66 Self::PluginEvents => "events.plugin.publish",
67 }
68 }
69
70 const fn bit(self) -> u64 {
71 match self {
72 Self::CoreEvents => CAP_CORE_EVENTS,
73 Self::Tasks => CAP_TASKS,
74 Self::Messaging => CAP_MESSAGING,
75 Self::Iq => CAP_IQ,
76 Self::PluginEvents => CAP_PLUGIN_EVENTS,
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
83pub struct PluginCapabilities(u64);
84
85impl PluginCapabilities {
86 pub const NONE: Self = Self(0);
87
88 pub const fn with(self, capability: PluginCapability) -> Self {
89 Self(self.0 | capability.bit())
90 }
91
92 pub const fn contains(self, capability: PluginCapability) -> bool {
93 self.0 & capability.bit() != 0
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct PluginHostConfig {
100 install_timeout: Duration,
101 callback_timeout: Duration,
102 task_drain_timeout: Duration,
103}
104
105impl PluginHostConfig {
106 pub const fn new() -> Self {
107 Self {
108 install_timeout: DEFAULT_PLUGIN_INSTALL_TIMEOUT,
109 callback_timeout: DEFAULT_PLUGIN_CALLBACK_TIMEOUT,
110 task_drain_timeout: DEFAULT_PLUGIN_TASK_DRAIN_TIMEOUT,
111 }
112 }
113
114 pub const fn with_install_timeout(mut self, timeout: Duration) -> Self {
116 self.install_timeout = timeout;
117 self
118 }
119
120 pub const fn with_callback_timeout(mut self, timeout: Duration) -> Self {
122 self.callback_timeout = timeout;
123 self
124 }
125
126 pub const fn with_task_drain_timeout(mut self, timeout: Duration) -> Self {
128 self.task_drain_timeout = timeout;
129 self
130 }
131
132 pub const fn install_timeout(self) -> Duration {
133 self.install_timeout
134 }
135
136 pub const fn callback_timeout(self) -> Duration {
137 self.callback_timeout
138 }
139
140 pub const fn task_drain_timeout(self) -> Duration {
141 self.task_drain_timeout
142 }
143}
144
145impl Default for PluginHostConfig {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153#[non_exhaustive]
154pub struct PluginManifest {
155 id: String,
156 version: String,
157 dependencies: Vec<String>,
158 capabilities: PluginCapabilities,
159}
160
161impl PluginManifest {
162 pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
163 Self {
164 id: id.into(),
165 version: version.into(),
166 dependencies: Vec::new(),
167 capabilities: PluginCapabilities::NONE,
168 }
169 }
170
171 pub fn with_dependency(mut self, plugin_id: impl Into<String>) -> Self {
172 self.dependencies.push(plugin_id.into());
173 self
174 }
175
176 pub const fn with_capability(mut self, capability: PluginCapability) -> Self {
177 self.capabilities = self.capabilities.with(capability);
178 self
179 }
180
181 pub fn id(&self) -> &str {
182 &self.id
183 }
184
185 pub fn version(&self) -> &str {
186 &self.version
187 }
188
189 pub fn dependencies(&self) -> &[String] {
190 &self.dependencies
191 }
192
193 pub const fn capabilities(&self) -> PluginCapabilities {
194 self.capabilities
195 }
196}
197
198pub type PluginFuture<'a, T> = BoxFuture<'a, T>;
200
201pub trait ClientPlugin: MaybeSendSync + 'static {
205 type Api: MaybeSendSync + 'static;
206
207 fn manifest(&self) -> PluginManifest;
208
209 fn install(&self, context: PluginContext) -> PluginFuture<'_, anyhow::Result<Arc<Self::Api>>>;
210
211 fn on_ready(&self, _scope: PluginConnectionScope) -> PluginFuture<'_, anyhow::Result<()>> {
212 Box::pin(async { Ok(()) })
213 }
214
215 fn on_closed(&self, _scope: PluginConnectionScope) -> PluginFuture<'_, anyhow::Result<()>> {
216 Box::pin(async { Ok(()) })
217 }
218
219 fn shutdown(&self) -> PluginFuture<'_, anyhow::Result<()>> {
221 Box::pin(async { Ok(()) })
222 }
223}
224
225pub trait UntypedClientPlugin: MaybeSendSync + 'static {
231 fn manifest(&self) -> PluginManifest;
232
233 fn install(&self, context: PluginContext) -> PluginFuture<'_, anyhow::Result<()>>;
234
235 fn on_ready(&self, _scope: PluginConnectionScope) -> PluginFuture<'_, anyhow::Result<()>> {
236 Box::pin(async { Ok(()) })
237 }
238
239 fn on_closed(&self, _scope: PluginConnectionScope) -> PluginFuture<'_, anyhow::Result<()>> {
240 Box::pin(async { Ok(()) })
241 }
242
243 fn shutdown(&self) -> PluginFuture<'_, anyhow::Result<()>> {
244 Box::pin(async { Ok(()) })
245 }
246}
247
248#[derive(Debug, Error)]
250#[non_exhaustive]
251pub enum PluginPlanError {
252 #[error("plugin {plugin_type} panicked while producing its manifest")]
253 ManifestPanicked { plugin_type: &'static str },
254 #[error("invalid plugin id `{id}`")]
255 InvalidId { id: String },
256 #[error("plugin `{plugin_id}` has an invalid version `{version}`")]
257 InvalidVersion { plugin_id: String, version: String },
258 #[error("plugin id `{id}` is registered more than once")]
259 DuplicateId { id: String },
260 #[error("plugin marker type `{plugin_type}` is registered more than once")]
261 DuplicateType { plugin_type: &'static str },
262 #[error("plugin `{plugin_id}` lists dependency `{dependency}` more than once")]
263 DuplicateDependency {
264 plugin_id: String,
265 dependency: String,
266 },
267 #[error("plugin `{plugin_id}` requires missing plugin `{dependency}`")]
268 MissingDependency {
269 plugin_id: String,
270 dependency: String,
271 },
272 #[error("plugin dependency cycle involves: {plugins:?}")]
273 DependencyCycle { plugins: Vec<String> },
274}
275
276#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
278#[non_exhaustive]
279pub enum PluginResourceError {
280 #[error("the client is no longer available")]
281 ClientUnavailable,
282 #[error("the plugin host has not started yet")]
283 NotActive,
284 #[error("the plugin scope is shutting down")]
285 ShuttingDown,
286 #[error("the plugin task capacity is exhausted")]
287 TaskCapacityExceeded,
288}
289
290#[derive(Debug, Error)]
291#[non_exhaustive]
292pub enum PluginMessagingError {
293 #[error("{0}")]
294 Resource(#[from] PluginResourceError),
295 #[error("{0}")]
296 Send(#[from] SendError),
297}
298
299#[derive(Debug, Error)]
300#[non_exhaustive]
301pub enum PluginIqError {
302 #[error("{0}")]
303 Resource(#[from] PluginResourceError),
304 #[error("{0}")]
305 Iq(#[from] IqError),
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310#[non_exhaustive]
311pub enum PluginState {
312 Installing,
313 Active,
314 ShuttingDown,
315 Stopped,
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321#[non_exhaustive]
322pub enum PluginHealth {
323 Healthy,
324 Degraded,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq)]
329#[non_exhaustive]
330pub struct PluginStats {
331 pub plugin_id: String,
332 pub state: PluginState,
333 pub health: PluginHealth,
334 pub callbacks_completed: u64,
336 pub callback_failures: u64,
338 pub callback_timeouts: u64,
339 pub task_drain_timeouts: u64,
340 pub task_panics: u64,
342 pub core_events_delivered: u64,
344 pub core_event_panics: u64,
346 pub resource_teardown_panics: u64,
347 pub install_tasks: u64,
348 pub connection_tasks: u64,
349 pub connection_generations: u64,
350 pub core_event_subscriptions: u64,
351 pub events: Option<PluginEventPublisherStats>,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356#[non_exhaustive]
357pub struct PluginHostStats {
358 pub terminal: bool,
359 pub health: PluginHealth,
360 pub upstream_callback_failures: u64,
361 pub upstream_callback_timeouts: u64,
362 pub plugins: Vec<PluginStats>,
363 pub event_router: Option<PluginEventRouterStats>,
364}
365
366struct PluginResources {
367 active: AtomicBool,
368 closed: AtomicBool,
369 activation: ShutdownNotifier,
370 shutdown: ShutdownNotifier,
371 install_tasks: Arc<TaskTracker>,
372 connection_tasks: Mutex<ConnectionTaskRegistry>,
373 subscriptions: Mutex<Vec<Weak<PluginCoreEventSubscriptionInner>>>,
374 teardown_panics: AtomicU64,
375}
376
377#[derive(Default)]
378struct ConnectionTaskRegistry {
379 closed: bool,
380 trackers: HashMap<u64, Arc<TaskTracker>>,
381}
382
383#[derive(Default)]
384struct TaskTrackerState {
385 active: usize,
386 closed: bool,
387}
388
389struct TaskTracker {
390 state: Mutex<TaskTrackerState>,
391 idle: ShutdownNotifier,
392}
393
394impl TaskTracker {
395 fn new() -> Arc<Self> {
396 Arc::new(Self {
397 state: Mutex::new(TaskTrackerState::default()),
398 idle: ShutdownNotifier::new(),
399 })
400 }
401
402 fn closed() -> Arc<Self> {
403 let tracker = Self::new();
404 tracker.close();
405 tracker
406 }
407
408 fn register(self: &Arc<Self>) -> Result<TaskLease, PluginResourceError> {
409 let mut state = self
410 .state
411 .lock()
412 .unwrap_or_else(|poisoned| poisoned.into_inner());
413 if state.closed {
414 return Err(PluginResourceError::ShuttingDown);
415 }
416 state.active = state
417 .active
418 .checked_add(1)
419 .ok_or(PluginResourceError::TaskCapacityExceeded)?;
420 Ok(TaskLease {
421 tracker: Arc::clone(self),
422 })
423 }
424
425 fn close(&self) {
426 let idle = {
427 let mut state = self
428 .state
429 .lock()
430 .unwrap_or_else(|poisoned| poisoned.into_inner());
431 state.closed = true;
432 state.active == 0
433 };
434 if idle {
435 self.idle.notify();
436 }
437 }
438
439 fn completion_signal(&self) -> ShutdownSignal {
440 self.idle.subscribe()
441 }
442
443 fn active(&self) -> usize {
444 self.state
445 .lock()
446 .unwrap_or_else(|poisoned| poisoned.into_inner())
447 .active
448 }
449}
450
451struct TaskLease {
452 tracker: Arc<TaskTracker>,
453}
454
455impl Drop for TaskLease {
456 fn drop(&mut self) {
457 let idle = {
458 let mut state = self
459 .tracker
460 .state
461 .lock()
462 .unwrap_or_else(|poisoned| poisoned.into_inner());
463 state.active = state.active.saturating_sub(1);
464 state.closed && state.active == 0
465 };
466 if idle {
467 self.tracker.idle.notify();
468 }
469 }
470}
471
472struct PluginCoreEventSubscriptionState {
473 subscription: Option<Subscription>,
474 raw_node_lease: Option<RawNodeLease>,
475 interest: EventInterest,
476}
477
478struct PluginCoreEventSubscriptionInner {
479 client: Weak<Client>,
480 resources: Weak<PluginResources>,
481 plugin_id: Arc<str>,
482 state: Mutex<PluginCoreEventSubscriptionState>,
483}
484
485impl PluginCoreEventSubscriptionInner {
486 fn is_active(&self) -> bool {
487 self.state
488 .lock()
489 .unwrap_or_else(|poisoned| poisoned.into_inner())
490 .subscription
491 .is_some()
492 }
493
494 fn update_interest(&self, interest: EventInterest) -> Result<bool, PluginResourceError> {
495 let resources = self
496 .resources
497 .upgrade()
498 .ok_or(PluginResourceError::ShuttingDown)?;
499 if resources.closed.load(Ordering::Acquire) {
500 return Err(PluginResourceError::ShuttingDown);
501 }
502
503 let mut state = self
504 .state
505 .lock()
506 .unwrap_or_else(|poisoned| poisoned.into_inner());
507 let wants_raw_node = interest.wants(EventKind::RawNode);
508 let acquired_raw_node_lease = if wants_raw_node && state.raw_node_lease.is_none() {
509 Some(
510 self.client
511 .upgrade()
512 .ok_or(PluginResourceError::ClientUnavailable)?
513 .acquire_raw_node_forwarding(),
514 )
515 } else {
516 None
517 };
518 let Some(subscription) = state.subscription.as_ref() else {
519 return Ok(false);
520 };
521 if !subscription.update_interest(interest) {
522 drop(state);
523 drop(acquired_raw_node_lease);
524 self.close();
525 return Ok(false);
526 }
527
528 state.interest = interest;
529 if let Some(lease) = acquired_raw_node_lease {
530 state.raw_node_lease = Some(lease);
531 }
532 let retired_raw_node_lease = (!wants_raw_node)
533 .then(|| state.raw_node_lease.take())
534 .flatten();
535 drop(state);
536 drop(retired_raw_node_lease);
537 Ok(true)
538 }
539
540 fn close(&self) -> bool {
541 let resources = self.resources.upgrade();
542 let registration = {
543 let mut state = self
544 .state
545 .lock()
546 .unwrap_or_else(|poisoned| poisoned.into_inner());
547 (state.subscription.take(), state.raw_node_lease.take())
548 };
549 let active = registration.0.is_some();
550 if std::panic::catch_unwind(AssertUnwindSafe(|| drop(registration))).is_err() {
551 if let Some(resources) = &resources {
552 resources.teardown_panics.fetch_add(1, Ordering::Relaxed);
553 }
554 log::warn!(
555 "Plugin `{}` core-event subscription panicked while closing",
556 self.plugin_id
557 );
558 }
559 if let Some(resources) = resources {
560 resources.forget_subscription(self);
561 }
562 active
563 }
564}
565
566#[must_use = "dropping the token immediately unregisters the plugin event handler"]
571pub struct PluginCoreEventSubscription {
572 inner: Arc<PluginCoreEventSubscriptionInner>,
573}
574
575impl PluginCoreEventSubscription {
576 pub fn interest(&self) -> EventInterest {
577 self.inner
578 .state
579 .lock()
580 .unwrap_or_else(|poisoned| poisoned.into_inner())
581 .interest
582 }
583
584 pub fn update_interest(&self, interest: EventInterest) -> Result<bool, PluginResourceError> {
586 self.inner.update_interest(interest)
587 }
588
589 pub fn is_active(&self) -> bool {
590 self.inner.is_active()
591 }
592
593 pub fn unsubscribe(&self) -> bool {
595 self.inner.close()
596 }
597}
598
599impl Drop for PluginCoreEventSubscription {
600 fn drop(&mut self) {
601 self.inner.close();
602 }
603}
604
605impl PluginResources {
606 fn new() -> Arc<Self> {
607 Arc::new(Self {
608 active: AtomicBool::new(false),
609 closed: AtomicBool::new(false),
610 activation: ShutdownNotifier::new(),
611 shutdown: ShutdownNotifier::new(),
612 install_tasks: TaskTracker::new(),
613 connection_tasks: Mutex::new(ConnectionTaskRegistry::default()),
614 subscriptions: Mutex::new(Vec::new()),
615 teardown_panics: AtomicU64::new(0),
616 })
617 }
618
619 #[cfg(test)]
620 fn activate(&self) {
621 self.prepare_activation();
622 self.publish_activation();
623 }
624
625 fn prepare_activation(&self) {
626 if self.closed.load(Ordering::Acquire) {
627 return;
628 }
629 self.active.store(true, Ordering::Release);
630 }
631
632 fn publish_activation(&self) {
633 if self.active.load(Ordering::Acquire) && !self.closed.load(Ordering::Acquire) {
634 self.activation.notify();
635 }
636 }
637
638 fn ensure_active(&self) -> Result<(), PluginResourceError> {
639 if self.closed.load(Ordering::Acquire) {
640 Err(PluginResourceError::ShuttingDown)
641 } else if !self.active.load(Ordering::Acquire) {
642 Err(PluginResourceError::NotActive)
643 } else {
644 Ok(())
645 }
646 }
647
648 fn retain_subscription(
649 &self,
650 client: Weak<Client>,
651 resources: Weak<PluginResources>,
652 plugin_id: Arc<str>,
653 interest: EventInterest,
654 subscription: Subscription,
655 raw_node_lease: Option<RawNodeLease>,
656 ) -> Result<PluginCoreEventSubscription, PluginResourceError> {
657 let registration = Arc::new(PluginCoreEventSubscriptionInner {
658 client,
659 resources,
660 plugin_id,
661 state: Mutex::new(PluginCoreEventSubscriptionState {
662 subscription: Some(subscription),
663 raw_node_lease,
664 interest,
665 }),
666 });
667 let rejected = {
668 let mut subscriptions = self
669 .subscriptions
670 .lock()
671 .unwrap_or_else(|poisoned| poisoned.into_inner());
672 if self.closed.load(Ordering::Acquire) {
673 true
674 } else {
675 subscriptions.retain(|subscription| {
676 subscription
677 .upgrade()
678 .is_some_and(|subscription| subscription.is_active())
679 });
680 subscriptions.push(Arc::downgrade(®istration));
681 false
682 }
683 };
684 if rejected {
685 registration.close();
686 Err(PluginResourceError::ShuttingDown)
687 } else {
688 Ok(PluginCoreEventSubscription {
689 inner: registration,
690 })
691 }
692 }
693
694 fn forget_subscription(&self, subscription: &PluginCoreEventSubscriptionInner) {
695 let subscription_ptr = std::ptr::from_ref(subscription);
696 self.subscriptions
697 .lock()
698 .unwrap_or_else(|poisoned| poisoned.into_inner())
699 .retain(|candidate| {
700 candidate.strong_count() != 0 && !std::ptr::eq(candidate.as_ptr(), subscription_ptr)
701 });
702 }
703
704 fn connection_task_tracker(&self, generation: u64) -> (Arc<TaskTracker>, bool) {
705 let mut registry = self
706 .connection_tasks
707 .lock()
708 .unwrap_or_else(|poisoned| poisoned.into_inner());
709 if registry.closed {
710 return (TaskTracker::closed(), false);
711 }
712 match registry.trackers.entry(generation) {
713 std::collections::hash_map::Entry::Occupied(entry) => (Arc::clone(entry.get()), false),
714 std::collections::hash_map::Entry::Vacant(entry) => {
715 let tracker = TaskTracker::new();
716 entry.insert(Arc::clone(&tracker));
717 (tracker, true)
718 }
719 }
720 }
721
722 fn retire_connection_tasks_on_cancel(
723 self: &Arc<Self>,
724 runtime: &Arc<dyn Runtime>,
725 generation: u64,
726 tracker: Arc<TaskTracker>,
727 cancellation: ShutdownSignal,
728 ) {
729 let resources = Arc::downgrade(self);
731 runtime
732 .spawn(Box::pin(async move {
733 wait_for_shutdown(&cancellation).await;
734 tracker.close();
735 wait_for_shutdown(&tracker.completion_signal()).await;
736 if let Some(resources) = resources.upgrade() {
737 resources.forget_connection_tasks(generation, &tracker);
738 }
739 }))
740 .detach();
741 }
742
743 fn close_connection_tasks(&self, generation: u64) -> Arc<TaskTracker> {
744 let tracker = self
745 .connection_tasks
746 .lock()
747 .unwrap_or_else(|poisoned| poisoned.into_inner())
748 .trackers
749 .get(&generation)
750 .cloned()
751 .unwrap_or_else(TaskTracker::closed);
752 tracker.close();
753 tracker
754 }
755
756 fn forget_connection_tasks(&self, generation: u64, tracker: &Arc<TaskTracker>) {
757 let mut registry = self
758 .connection_tasks
759 .lock()
760 .unwrap_or_else(|poisoned| poisoned.into_inner());
761 if registry
762 .trackers
763 .get(&generation)
764 .is_some_and(|current| Arc::ptr_eq(current, tracker))
765 {
766 registry.trackers.remove(&generation);
767 }
768 }
769
770 fn task_completion_signals(&self) -> Vec<ShutdownSignal> {
771 let mut signals = vec![self.install_tasks.completion_signal()];
772 signals.extend(
773 self.connection_tasks
774 .lock()
775 .unwrap_or_else(|poisoned| poisoned.into_inner())
776 .trackers
777 .values()
778 .map(|tracker| tracker.completion_signal()),
779 );
780 signals
781 }
782
783 fn close(&self) {
784 if self.closed.swap(true, Ordering::AcqRel) {
785 return;
786 }
787 self.install_tasks.close();
788 let connection_trackers = {
789 let mut registry = self
790 .connection_tasks
791 .lock()
792 .unwrap_or_else(|poisoned| poisoned.into_inner());
793 registry.closed = true;
794 registry.trackers.values().cloned().collect::<Vec<_>>()
795 };
796 for tracker in connection_trackers {
797 tracker.close();
798 }
799 self.shutdown.notify();
800 let subscriptions = {
801 let mut subscriptions = self
802 .subscriptions
803 .lock()
804 .unwrap_or_else(|poisoned| poisoned.into_inner());
805 std::mem::take(&mut *subscriptions)
806 };
807 for subscription in subscriptions {
808 if let Some(subscription) = subscription.upgrade() {
809 subscription.close();
810 }
811 }
812 }
813}
814
815fn close_plugin_resources(plugin_id: &str, resources: &PluginResources) {
816 if std::panic::catch_unwind(AssertUnwindSafe(|| resources.close())).is_err() {
817 resources.teardown_panics.fetch_add(1, Ordering::Relaxed);
818 log::warn!("Plugin `{plugin_id}` resource closure panicked");
819 }
820}
821
822impl PluginResources {
823 fn stats(&self) -> PluginResourceStats {
824 let (connection_generations, connection_trackers) = {
825 let registry = self
826 .connection_tasks
827 .lock()
828 .unwrap_or_else(|poisoned| poisoned.into_inner());
829 (
830 registry.trackers.len(),
831 registry.trackers.values().cloned().collect::<Vec<_>>(),
832 )
833 };
834 let connection_tasks = connection_trackers.iter().fold(0usize, |total, tracker| {
835 total.saturating_add(tracker.active())
836 });
837 PluginResourceStats {
838 active: self.active.load(Ordering::Acquire),
839 closed: self.closed.load(Ordering::Acquire),
840 install_tasks: self.install_tasks.active(),
841 connection_tasks,
842 connection_generations,
843 core_event_subscriptions: self
844 .subscriptions
845 .lock()
846 .unwrap_or_else(|poisoned| poisoned.into_inner())
847 .iter()
848 .filter_map(Weak::upgrade)
849 .filter(|subscription| subscription.is_active())
850 .count(),
851 teardown_panics: self.teardown_panics.load(Ordering::Relaxed),
852 }
853 }
854}
855
856#[derive(Default)]
857struct PluginResourceStats {
858 active: bool,
859 closed: bool,
860 install_tasks: usize,
861 connection_tasks: usize,
862 connection_generations: usize,
863 core_event_subscriptions: usize,
864 teardown_panics: u64,
865}
866
867struct PluginDiagnostics {
868 resources: Mutex<Weak<PluginResources>>,
869 callbacks_completed: AtomicU64,
870 callback_failures: AtomicU64,
871 callback_timeouts: AtomicU64,
872 task_drain_timeouts: AtomicU64,
873 task_panics: AtomicU64,
874 core_events_delivered: AtomicU64,
875 core_event_panics: AtomicU64,
876 shutdown_complete: AtomicBool,
877}
878
879impl PluginDiagnostics {
880 fn new() -> Arc<Self> {
881 Arc::new(Self {
882 resources: Mutex::new(Weak::new()),
883 callbacks_completed: AtomicU64::new(0),
884 callback_failures: AtomicU64::new(0),
885 callback_timeouts: AtomicU64::new(0),
886 task_drain_timeouts: AtomicU64::new(0),
887 task_panics: AtomicU64::new(0),
888 core_events_delivered: AtomicU64::new(0),
889 core_event_panics: AtomicU64::new(0),
890 shutdown_complete: AtomicBool::new(false),
891 })
892 }
893
894 fn attach_resources(&self, resources: &Arc<PluginResources>) {
895 *self
896 .resources
897 .lock()
898 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Arc::downgrade(resources);
899 }
900
901 fn record_callback(&self, result: &Result<(), PluginCallbackError>) {
902 match result {
903 Ok(()) => {
904 self.callbacks_completed.fetch_add(1, Ordering::Relaxed);
905 }
906 Err(PluginCallbackError::Timeout { .. }) => {
907 self.callback_timeouts.fetch_add(1, Ordering::Relaxed);
908 }
909 Err(PluginCallbackError::TimeoutCancellationPanic { .. }) => {
910 self.callback_timeouts.fetch_add(1, Ordering::Relaxed);
911 self.callback_failures.fetch_add(1, Ordering::Relaxed);
912 }
913 Err(PluginCallbackError::Callback(_)) => {
914 self.callback_failures.fetch_add(1, Ordering::Relaxed);
915 }
916 }
917 }
918
919 fn record_task_drain(&self, result: &Result<(), PluginTaskDrainError>) {
920 if matches!(result, Err(PluginTaskDrainError::Timeout { .. })) {
921 self.task_drain_timeouts.fetch_add(1, Ordering::Relaxed);
922 }
923 }
924
925 fn mark_stopped(&self) {
926 self.shutdown_complete.store(true, Ordering::Release);
927 }
928
929 fn snapshot(
930 &self,
931 plugin_id: &str,
932 terminal: bool,
933 events: Option<PluginEventPublisherStats>,
934 ) -> PluginStats {
935 let resources = self
936 .resources
937 .lock()
938 .unwrap_or_else(|poisoned| poisoned.into_inner())
939 .upgrade()
940 .map(|resources| resources.stats())
941 .unwrap_or_default();
942 let callbacks_completed = self.callbacks_completed.load(Ordering::Relaxed);
943 let callback_failures = self.callback_failures.load(Ordering::Relaxed);
944 let callback_timeouts = self.callback_timeouts.load(Ordering::Relaxed);
945 let task_drain_timeouts = self.task_drain_timeouts.load(Ordering::Relaxed);
946 let task_panics = self.task_panics.load(Ordering::Relaxed);
947 let core_events_delivered = self.core_events_delivered.load(Ordering::Relaxed);
948 let core_event_panics = self.core_event_panics.load(Ordering::Relaxed);
949 let state = if self.shutdown_complete.load(Ordering::Acquire) {
950 PluginState::Stopped
951 } else if resources.closed || terminal {
952 PluginState::ShuttingDown
953 } else if resources.active {
954 PluginState::Active
955 } else {
956 PluginState::Installing
957 };
958 let event_degraded = events
959 .as_ref()
960 .is_some_and(|events| events.publish_failures > 0 || events.dropped > 0);
961 let health = if callback_failures > 0
962 || callback_timeouts > 0
963 || task_drain_timeouts > 0
964 || task_panics > 0
965 || core_event_panics > 0
966 || resources.teardown_panics > 0
967 || event_degraded
968 {
969 PluginHealth::Degraded
970 } else {
971 PluginHealth::Healthy
972 };
973 PluginStats {
974 plugin_id: plugin_id.to_string(),
975 state,
976 health,
977 callbacks_completed,
978 callback_failures,
979 callback_timeouts,
980 task_drain_timeouts,
981 task_panics,
982 core_events_delivered,
983 core_event_panics,
984 resource_teardown_panics: resources.teardown_panics,
985 install_tasks: u64::try_from(resources.install_tasks).unwrap_or(u64::MAX),
986 connection_tasks: u64::try_from(resources.connection_tasks).unwrap_or(u64::MAX),
987 connection_generations: u64::try_from(resources.connection_generations)
988 .unwrap_or(u64::MAX),
989 core_event_subscriptions: u64::try_from(resources.core_event_subscriptions)
990 .unwrap_or(u64::MAX),
991 events,
992 }
993 }
994}
995
996#[derive(Clone)]
999pub struct PluginTasks {
1000 runtime: Arc<dyn Runtime>,
1001 resources: Arc<PluginResources>,
1002 diagnostics: Arc<PluginDiagnostics>,
1003 plugin_id: Arc<str>,
1004}
1005
1006impl PluginTasks {
1007 pub fn spawn<F>(&self, future: F) -> Result<(), PluginResourceError>
1008 where
1009 F: Future<Output = ()> + Spawnable,
1010 {
1011 self.spawn_with_mode(future, PluginTaskShutdown::Abort)
1012 }
1013
1014 pub fn spawn_cooperative<F>(&self, future: F) -> Result<(), PluginResourceError>
1017 where
1018 F: Future<Output = ()> + Spawnable,
1019 {
1020 self.spawn_with_mode(future, PluginTaskShutdown::Cooperative)
1021 }
1022
1023 fn spawn_with_mode<F>(
1024 &self,
1025 future: F,
1026 shutdown: PluginTaskShutdown,
1027 ) -> Result<(), PluginResourceError>
1028 where
1029 F: Future<Output = ()> + Spawnable,
1030 {
1031 if self.resources.closed.load(Ordering::Acquire) {
1032 return Err(PluginResourceError::ShuttingDown);
1033 }
1034 let lease = self.resources.install_tasks.register()?;
1035 spawn_after_activation(
1036 &self.runtime,
1037 Arc::clone(&self.resources),
1038 Arc::clone(&self.diagnostics),
1039 Arc::clone(&self.plugin_id),
1040 lease,
1041 future,
1042 shutdown,
1043 );
1044 Ok(())
1045 }
1046
1047 pub fn shutdown_signal(&self) -> ShutdownSignal {
1048 self.resources.shutdown.subscribe()
1049 }
1050
1051 pub async fn sleep(&self, duration: Duration) -> Result<(), PluginResourceError> {
1053 self.resources.ensure_active()?;
1054 let shutdown = self.resources.shutdown.subscribe();
1055 let cancelled = Box::pin(wait_for_shutdown(&shutdown));
1056 match futures::future::select(cancelled, self.runtime.sleep(duration)).await {
1057 futures::future::Either::Left(_) => Err(PluginResourceError::ShuttingDown),
1058 futures::future::Either::Right(_) => self.resources.ensure_active(),
1059 }
1060 }
1061}
1062
1063#[derive(Clone)]
1066pub struct PluginCoreEvents {
1067 client: Weak<Client>,
1068 resources: Arc<PluginResources>,
1069 plugin_id: Arc<str>,
1070 diagnostics: Arc<PluginDiagnostics>,
1071}
1072
1073struct PluginCoreEventHandler {
1074 plugin_id: Arc<str>,
1075 inner: Option<Arc<dyn EventHandler>>,
1076 resources: Weak<PluginResources>,
1077 diagnostics: Arc<PluginDiagnostics>,
1078}
1079
1080impl EventHandler for PluginCoreEventHandler {
1081 fn handle_event(&self, event: Arc<wacore::types::events::Event>) {
1082 let Some(resources) = self.resources.upgrade() else {
1083 return;
1084 };
1085 if resources.ensure_active().is_err() {
1086 return;
1087 }
1088 let Some(inner) = &self.inner else {
1089 return;
1090 };
1091 if std::panic::catch_unwind(AssertUnwindSafe(|| inner.handle_event(event))).is_err() {
1092 self.diagnostics
1093 .core_event_panics
1094 .fetch_add(1, Ordering::Relaxed);
1095 log::warn!("Plugin `{}` core-event handler panicked", self.plugin_id);
1096 } else {
1097 self.diagnostics
1098 .core_events_delivered
1099 .fetch_add(1, Ordering::Relaxed);
1100 }
1101 }
1102}
1103
1104impl Drop for PluginCoreEventHandler {
1105 fn drop(&mut self) {
1106 if let Some(inner) = self.inner.take()
1107 && std::panic::catch_unwind(AssertUnwindSafe(|| drop(inner))).is_err()
1108 {
1109 if let Some(resources) = self.resources.upgrade() {
1110 resources.teardown_panics.fetch_add(1, Ordering::Relaxed);
1111 }
1112 log::warn!(
1113 "Plugin `{}` core-event handler panicked while being dropped",
1114 self.plugin_id
1115 );
1116 }
1117 }
1118}
1119
1120impl PluginCoreEvents {
1121 pub fn subscribe(
1122 &self,
1123 interest: EventInterest,
1124 handler: Arc<dyn EventHandler>,
1125 ) -> Result<PluginCoreEventSubscription, PluginResourceError> {
1126 let client = self
1127 .client
1128 .upgrade()
1129 .ok_or(PluginResourceError::ClientUnavailable)?;
1130 let raw_node_lease = interest
1131 .wants(EventKind::RawNode)
1132 .then(|| client.acquire_raw_node_forwarding());
1133 let handler = Arc::new(PluginCoreEventHandler {
1134 plugin_id: Arc::clone(&self.plugin_id),
1135 inner: Some(handler),
1136 resources: Arc::downgrade(&self.resources),
1137 diagnostics: Arc::clone(&self.diagnostics),
1138 });
1139 let subscription = client.subscribe(interest, handler);
1140 self.resources.retain_subscription(
1141 self.client.clone(),
1142 Arc::downgrade(&self.resources),
1143 Arc::clone(&self.plugin_id),
1144 interest,
1145 subscription,
1146 raw_node_lease,
1147 )
1148 }
1149}
1150
1151#[derive(Clone)]
1153pub struct PluginMessaging {
1154 client: Weak<Client>,
1155 resources: Arc<PluginResources>,
1156}
1157
1158impl PluginMessaging {
1159 pub async fn send_message(
1160 &self,
1161 to: Jid,
1162 message: Message,
1163 ) -> Result<SendResult, PluginMessagingError> {
1164 self.resources.ensure_active()?;
1165 let client = self
1166 .client
1167 .upgrade()
1168 .ok_or(PluginResourceError::ClientUnavailable)?;
1169 Ok(client.send_message(to, message).await?)
1170 }
1171
1172 pub async fn send_text(
1173 &self,
1174 to: Jid,
1175 text: String,
1176 ) -> Result<SendResult, PluginMessagingError> {
1177 self.resources.ensure_active()?;
1178 let client = self
1179 .client
1180 .upgrade()
1181 .ok_or(PluginResourceError::ClientUnavailable)?;
1182 Ok(client.send_text(to, text).await?)
1183 }
1184}
1185
1186#[derive(Clone)]
1188pub struct PluginIq {
1189 client: Weak<Client>,
1190 resources: Arc<PluginResources>,
1191}
1192
1193impl PluginIq {
1194 pub async fn execute<S>(&self, spec: S) -> Result<S::Response, PluginIqError>
1195 where
1196 S: IqSpec,
1197 {
1198 self.resources.ensure_active()?;
1199 let client = self
1200 .client
1201 .upgrade()
1202 .ok_or(PluginResourceError::ClientUnavailable)?;
1203 Ok(client.execute(spec).await?)
1204 }
1205}
1206
1207pub struct PluginContext {
1209 plugin_id: String,
1210 dependencies: HashMap<TypeId, WeakErasedApi>,
1211 core_events: Option<PluginCoreEvents>,
1212 tasks: Option<PluginTasks>,
1213 messaging: Option<PluginMessaging>,
1214 iq: Option<PluginIq>,
1215 plugin_events: Option<PluginEvents>,
1216}
1217
1218impl PluginContext {
1219 pub fn plugin_id(&self) -> &str {
1220 &self.plugin_id
1221 }
1222
1223 pub fn plugin<P: ClientPlugin>(&self) -> Option<Arc<P::Api>> {
1226 let api = self.dependencies.get(&TypeId::of::<P>())?.upgrade()?;
1227 downcast_api::<P::Api>(&api)
1228 }
1229
1230 pub fn core_events(&self) -> Option<&PluginCoreEvents> {
1231 self.core_events.as_ref()
1232 }
1233
1234 pub fn tasks(&self) -> Option<&PluginTasks> {
1235 self.tasks.as_ref()
1236 }
1237
1238 pub fn messaging(&self) -> Option<&PluginMessaging> {
1239 self.messaging.as_ref()
1240 }
1241
1242 pub fn iq(&self) -> Option<&PluginIq> {
1243 self.iq.as_ref()
1244 }
1245
1246 pub fn plugin_events(&self) -> Option<&PluginEvents> {
1247 self.plugin_events.as_ref()
1248 }
1249}
1250
1251#[derive(Clone)]
1253pub struct PluginConnectionScope {
1254 scope: ConnectionScope,
1255 tasks: Option<PluginConnectionTasks>,
1256}
1257
1258impl PluginConnectionScope {
1259 pub fn generation(&self) -> u64 {
1260 self.scope.generation()
1261 }
1262
1263 pub fn state(&self) -> ConnectionScopeState {
1264 self.scope.state()
1265 }
1266
1267 pub fn is_cancelled(&self) -> bool {
1268 self.scope.is_cancelled()
1269 }
1270
1271 pub fn cancellation_signal(&self) -> ShutdownSignal {
1272 self.scope.cancellation_signal()
1273 }
1274
1275 pub fn tasks(&self) -> Option<&PluginConnectionTasks> {
1276 self.tasks.as_ref()
1277 }
1278}
1279
1280#[derive(Clone)]
1282pub struct PluginConnectionTasks {
1283 runtime: Arc<dyn Runtime>,
1284 scope: ConnectionScope,
1285 tracker: Arc<TaskTracker>,
1286 diagnostics: Arc<PluginDiagnostics>,
1287 plugin_id: Arc<str>,
1288}
1289
1290impl PluginConnectionTasks {
1291 pub fn spawn<F>(&self, future: F) -> Result<(), PluginResourceError>
1292 where
1293 F: Future<Output = ()> + Spawnable,
1294 {
1295 self.spawn_with_mode(future, PluginTaskShutdown::Abort)
1296 }
1297
1298 pub fn spawn_cooperative<F>(&self, future: F) -> Result<(), PluginResourceError>
1301 where
1302 F: Future<Output = ()> + Spawnable,
1303 {
1304 self.spawn_with_mode(future, PluginTaskShutdown::Cooperative)
1305 }
1306
1307 fn spawn_with_mode<F>(
1308 &self,
1309 future: F,
1310 shutdown: PluginTaskShutdown,
1311 ) -> Result<(), PluginResourceError>
1312 where
1313 F: Future<Output = ()> + Spawnable,
1314 {
1315 if self.scope.is_cancelled() {
1316 return Err(PluginResourceError::ShuttingDown);
1317 }
1318 let lease = self.tracker.register()?;
1319 spawn_until_cancelled(
1320 &self.runtime,
1321 self.scope.cancellation_signal(),
1322 Arc::clone(&self.diagnostics),
1323 Arc::clone(&self.plugin_id),
1324 lease,
1325 future,
1326 shutdown,
1327 );
1328 Ok(())
1329 }
1330
1331 pub fn cancellation_signal(&self) -> ShutdownSignal {
1332 self.scope.cancellation_signal()
1333 }
1334
1335 pub async fn sleep(&self, duration: Duration) -> Result<(), PluginResourceError> {
1337 if self.scope.is_cancelled() {
1338 return Err(PluginResourceError::ShuttingDown);
1339 }
1340 let cancellation = self.scope.cancellation_signal();
1341 let cancelled = Box::pin(wait_for_shutdown(&cancellation));
1342 match futures::future::select(cancelled, self.runtime.sleep(duration)).await {
1343 futures::future::Either::Left(_) => Err(PluginResourceError::ShuttingDown),
1344 futures::future::Either::Right(_) if self.scope.is_cancelled() => {
1345 Err(PluginResourceError::ShuttingDown)
1346 }
1347 futures::future::Either::Right(_) => Ok(()),
1348 }
1349 }
1350}
1351
1352struct GuardedPluginTask<F: Future<Output = ()>> {
1353 future: Option<std::pin::Pin<Box<F>>>,
1354 diagnostics: Arc<PluginDiagnostics>,
1355 plugin_id: Arc<str>,
1356 failure_recorded: bool,
1357}
1358
1359impl<F> GuardedPluginTask<F>
1360where
1361 F: Future<Output = ()>,
1362{
1363 fn new(future: F, diagnostics: Arc<PluginDiagnostics>, plugin_id: Arc<str>) -> Self {
1364 Self {
1365 future: Some(Box::pin(future)),
1366 diagnostics,
1367 plugin_id,
1368 failure_recorded: false,
1369 }
1370 }
1371
1372 fn record_panic(&mut self, stage: &str) {
1373 if !self.failure_recorded {
1374 self.failure_recorded = true;
1375 self.diagnostics.task_panics.fetch_add(1, Ordering::Relaxed);
1376 }
1377 log::warn!("Plugin `{}` task panicked {stage}", self.plugin_id);
1378 }
1379
1380 fn drop_future(&mut self) -> bool {
1381 let future = self.future.take();
1382 std::panic::catch_unwind(AssertUnwindSafe(|| drop(future))).is_err()
1383 }
1384}
1385
1386impl<F> Unpin for GuardedPluginTask<F> where F: Future<Output = ()> {}
1387
1388impl<F> Future for GuardedPluginTask<F>
1389where
1390 F: Future<Output = ()>,
1391{
1392 type Output = ();
1393
1394 fn poll(
1395 self: std::pin::Pin<&mut Self>,
1396 context: &mut std::task::Context<'_>,
1397 ) -> std::task::Poll<Self::Output> {
1398 let this = self.get_mut();
1399 let Some(future) = this.future.as_mut() else {
1400 return std::task::Poll::Ready(());
1401 };
1402 let result = std::panic::catch_unwind(AssertUnwindSafe(|| future.as_mut().poll(context)));
1403 match result {
1404 Ok(std::task::Poll::Pending) => std::task::Poll::Pending,
1405 Ok(std::task::Poll::Ready(())) => {
1406 if this.drop_future() {
1407 this.record_panic("after completion");
1408 }
1409 std::task::Poll::Ready(())
1410 }
1411 Err(_) => {
1412 this.record_panic("while running");
1413 if this.drop_future() {
1414 this.record_panic("while cleaning up after failure");
1415 }
1416 std::task::Poll::Ready(())
1417 }
1418 }
1419 }
1420}
1421
1422impl<F: Future<Output = ()>> Drop for GuardedPluginTask<F> {
1423 fn drop(&mut self) {
1424 if self.drop_future() {
1425 self.record_panic("while being cancelled");
1426 }
1427 }
1428}
1429
1430#[derive(Clone, Copy)]
1431enum PluginTaskShutdown {
1432 Abort,
1433 Cooperative,
1434}
1435
1436fn spawn_until_cancelled<F>(
1437 runtime: &Arc<dyn Runtime>,
1438 cancellation: ShutdownSignal,
1439 diagnostics: Arc<PluginDiagnostics>,
1440 plugin_id: Arc<str>,
1441 lease: TaskLease,
1442 future: F,
1443 shutdown: PluginTaskShutdown,
1444) where
1445 F: Future<Output = ()> + Spawnable,
1446{
1447 let work = GuardedPluginTask::new(future, diagnostics, plugin_id);
1448 runtime
1449 .spawn(Box::pin(async move {
1450 let _lease = lease;
1451 let work = Box::pin(work);
1452 match shutdown {
1453 PluginTaskShutdown::Abort => {
1454 let cancelled = Box::pin(wait_for_shutdown(&cancellation));
1455 let _ = futures::future::select(cancelled, work).await;
1456 }
1457 PluginTaskShutdown::Cooperative => work.await,
1458 }
1459 }))
1460 .detach();
1461}
1462
1463fn spawn_after_activation<F>(
1464 runtime: &Arc<dyn Runtime>,
1465 resources: Arc<PluginResources>,
1466 diagnostics: Arc<PluginDiagnostics>,
1467 plugin_id: Arc<str>,
1468 lease: TaskLease,
1469 future: F,
1470 shutdown: PluginTaskShutdown,
1471) where
1472 F: Future<Output = ()> + Spawnable,
1473{
1474 let activation = resources.activation.subscribe();
1475 let cancellation = resources.shutdown.subscribe();
1476 let work = GuardedPluginTask::new(future, diagnostics, plugin_id);
1477 runtime
1478 .spawn(Box::pin(async move {
1479 let _lease = lease;
1480 let cancelled = Box::pin(wait_for_shutdown(&cancellation));
1481 let activated = Box::pin(wait_for_shutdown(&activation));
1482 if matches!(
1483 futures::future::select(cancelled, activated).await,
1484 futures::future::Either::Left(_)
1485 ) {
1486 return;
1487 }
1488 if resources.closed.load(Ordering::Acquire) {
1489 return;
1490 }
1491 let work = Box::pin(work);
1492 match shutdown {
1493 PluginTaskShutdown::Abort => {
1494 let cancelled = Box::pin(wait_for_shutdown(&cancellation));
1495 let _ = futures::future::select(cancelled, work).await;
1496 }
1497 PluginTaskShutdown::Cooperative => work.await,
1498 }
1499 }))
1500 .detach();
1501}
1502
1503trait ErasedApiValue: MaybeSendSync {
1504 fn as_any(&self) -> &dyn Any;
1505}
1506
1507struct TypedApi<T>(Arc<T>);
1508
1509impl<T: MaybeSendSync + 'static> ErasedApiValue for TypedApi<T> {
1510 fn as_any(&self) -> &dyn Any {
1511 self
1512 }
1513}
1514
1515type ErasedApi = Arc<dyn ErasedApiValue>;
1516type WeakErasedApi = Weak<dyn ErasedApiValue>;
1517
1518#[derive(Default)]
1519struct ApiRegistry {
1520 values: Mutex<HashMap<TypeId, ErasedApi>>,
1521}
1522
1523impl ApiRegistry {
1524 fn insert(&self, marker: TypeId, api: ErasedApi) {
1525 self.values
1526 .lock()
1527 .unwrap_or_else(|poisoned| poisoned.into_inner())
1528 .insert(marker, api);
1529 }
1530
1531 fn snapshot(&self) -> HashMap<TypeId, ErasedApi> {
1532 self.values
1533 .lock()
1534 .unwrap_or_else(|poisoned| poisoned.into_inner())
1535 .clone()
1536 }
1537
1538 fn dependency_view(&self, markers: &[TypeId]) -> HashMap<TypeId, WeakErasedApi> {
1539 let values = self
1540 .values
1541 .lock()
1542 .unwrap_or_else(|poisoned| poisoned.into_inner());
1543 markers
1544 .iter()
1545 .filter_map(|marker| values.get(marker).map(|api| (*marker, Arc::downgrade(api))))
1546 .collect()
1547 }
1548}
1549
1550fn downcast_api<T: MaybeSendSync + 'static>(api: &ErasedApi) -> Option<Arc<T>> {
1551 api.as_any()
1552 .downcast_ref::<TypedApi<T>>()
1553 .map(|typed| typed.0.clone())
1554}
1555
1556trait ErasedClientPlugin: MaybeSendSync {
1557 fn marker_type_id(&self) -> Option<TypeId>;
1558 fn marker_type_name(&self) -> &'static str;
1559 fn manifest(&self) -> PluginManifest;
1560 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Option<ErasedApi>>>;
1561 fn on_ready(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>>;
1562 fn on_closed(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>>;
1563 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>>;
1564}
1565
1566struct PluginAdapter<P>(Arc<P>);
1567
1568impl<P: ClientPlugin> ErasedClientPlugin for PluginAdapter<P> {
1569 fn marker_type_id(&self) -> Option<TypeId> {
1570 Some(TypeId::of::<P>())
1571 }
1572
1573 fn marker_type_name(&self) -> &'static str {
1574 std::any::type_name::<P>()
1575 }
1576
1577 fn manifest(&self) -> PluginManifest {
1578 self.0.manifest()
1579 }
1580
1581 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Option<ErasedApi>>> {
1582 Box::pin(async move {
1583 let api = self.0.install(context).await?;
1584 Ok(Some(Arc::new(TypedApi(api)) as ErasedApi))
1585 })
1586 }
1587
1588 fn on_ready(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
1589 self.0.on_ready(scope)
1590 }
1591
1592 fn on_closed(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
1593 self.0.on_closed(scope)
1594 }
1595
1596 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
1597 self.0.shutdown()
1598 }
1599}
1600
1601struct UntypedPluginAdapter<P: ?Sized>(Arc<P>);
1602
1603impl<P: UntypedClientPlugin + ?Sized> ErasedClientPlugin for UntypedPluginAdapter<P> {
1604 fn marker_type_id(&self) -> Option<TypeId> {
1605 None
1606 }
1607
1608 fn marker_type_name(&self) -> &'static str {
1609 std::any::type_name::<P>()
1610 }
1611
1612 fn manifest(&self) -> PluginManifest {
1613 self.0.manifest()
1614 }
1615
1616 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Option<ErasedApi>>> {
1617 Box::pin(async move {
1618 self.0.install(context).await?;
1619 Ok(None)
1620 })
1621 }
1622
1623 fn on_ready(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
1624 self.0.on_ready(scope)
1625 }
1626
1627 fn on_closed(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
1628 self.0.on_closed(scope)
1629 }
1630
1631 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
1632 self.0.shutdown()
1633 }
1634}
1635
1636pub(crate) struct PluginRegistration {
1637 plugin: Arc<dyn ErasedClientPlugin>,
1638}
1639
1640impl PluginRegistration {
1641 pub(crate) fn new<P: ClientPlugin>(plugin: P) -> Self {
1642 Self::new_arc(Arc::new(plugin))
1643 }
1644
1645 pub(crate) fn new_arc<P: ClientPlugin>(plugin: Arc<P>) -> Self {
1646 Self {
1647 plugin: Arc::new(PluginAdapter(plugin)),
1648 }
1649 }
1650
1651 pub(crate) fn new_untyped<P: UntypedClientPlugin>(plugin: P) -> Self {
1652 Self::new_untyped_arc(Arc::new(plugin))
1653 }
1654
1655 pub(crate) fn new_untyped_arc<P: UntypedClientPlugin + ?Sized>(plugin: Arc<P>) -> Self {
1656 Self {
1657 plugin: Arc::new(UntypedPluginAdapter(plugin)),
1658 }
1659 }
1660}
1661
1662struct PlannedPlugin {
1663 plugin: Arc<dyn ErasedClientPlugin>,
1664 manifest: PluginManifest,
1665 dependency_markers: Vec<TypeId>,
1666}
1667
1668pub(crate) struct PluginPlan {
1669 ordered: Vec<PlannedPlugin>,
1670}
1671
1672impl PluginPlan {
1673 pub(crate) fn prepare(
1674 registrations: Vec<PluginRegistration>,
1675 ) -> Result<Option<Self>, PluginPlanError> {
1676 if registrations.is_empty() {
1677 return Ok(None);
1678 }
1679
1680 let mut plugins = Vec::with_capacity(registrations.len());
1681 let mut ids = HashMap::with_capacity(registrations.len());
1682 let mut marker_types = HashSet::with_capacity(registrations.len());
1683
1684 for registration in registrations {
1685 let plugin = registration.plugin;
1686 if let Some(marker) = plugin.marker_type_id()
1687 && !marker_types.insert(marker)
1688 {
1689 return Err(PluginPlanError::DuplicateType {
1690 plugin_type: plugin.marker_type_name(),
1691 });
1692 }
1693 let manifest = std::panic::catch_unwind(AssertUnwindSafe(|| plugin.manifest()))
1694 .map_err(|_| PluginPlanError::ManifestPanicked {
1695 plugin_type: plugin.marker_type_name(),
1696 })?;
1697 validate_manifest(&manifest)?;
1698 let index = plugins.len();
1699 if ids.insert(manifest.id.clone(), index).is_some() {
1700 return Err(PluginPlanError::DuplicateId {
1701 id: manifest.id.clone(),
1702 });
1703 }
1704 plugins.push(PlannedPlugin {
1705 plugin,
1706 manifest,
1707 dependency_markers: Vec::new(),
1708 });
1709 }
1710
1711 let mut indegree = vec![0usize; plugins.len()];
1712 let mut dependents = vec![Vec::new(); plugins.len()];
1713 let mut dependency_markers = vec![Vec::new(); plugins.len()];
1714 for (plugin_index, planned) in plugins.iter().enumerate() {
1715 let mut seen = HashSet::with_capacity(planned.manifest.dependencies.len());
1716 for dependency in &planned.manifest.dependencies {
1717 if !seen.insert(dependency) {
1718 return Err(PluginPlanError::DuplicateDependency {
1719 plugin_id: planned.manifest.id.clone(),
1720 dependency: dependency.clone(),
1721 });
1722 }
1723 let Some(&dependency_index) = ids.get(dependency) else {
1724 return Err(PluginPlanError::MissingDependency {
1725 plugin_id: planned.manifest.id.clone(),
1726 dependency: dependency.clone(),
1727 });
1728 };
1729 indegree[plugin_index] += 1;
1730 dependents[dependency_index].push(plugin_index);
1731 if let Some(marker) = plugins[dependency_index].plugin.marker_type_id() {
1732 dependency_markers[plugin_index].push(marker);
1733 }
1734 }
1735 }
1736 for (planned, markers) in plugins.iter_mut().zip(dependency_markers) {
1737 planned.dependency_markers = markers;
1738 }
1739
1740 let mut ready = indegree
1741 .iter()
1742 .enumerate()
1743 .filter_map(|(index, count)| (*count == 0).then_some(index))
1744 .collect::<BTreeSet<_>>();
1745 let mut order = Vec::with_capacity(plugins.len());
1746 while let Some(index) = ready.pop_first() {
1747 order.push(index);
1748 for &dependent in &dependents[index] {
1749 indegree[dependent] -= 1;
1750 if indegree[dependent] == 0 {
1751 ready.insert(dependent);
1752 }
1753 }
1754 }
1755
1756 if order.len() != plugins.len() {
1757 let cycle = indegree
1758 .iter()
1759 .enumerate()
1760 .filter(|(_, count)| **count > 0)
1761 .map(|(index, _)| plugins[index].manifest.id.clone())
1762 .collect();
1763 return Err(PluginPlanError::DependencyCycle { plugins: cycle });
1764 }
1765
1766 let mut slots = plugins.into_iter().map(Some).collect::<Vec<_>>();
1767 let ordered = order
1768 .into_iter()
1769 .filter_map(|index| slots[index].take())
1770 .collect();
1771 Ok(Some(Self { ordered }))
1772 }
1773}
1774
1775fn validate_manifest(manifest: &PluginManifest) -> Result<(), PluginPlanError> {
1776 if !valid_plugin_id(&manifest.id) {
1777 return Err(PluginPlanError::InvalidId {
1778 id: manifest.id.clone(),
1779 });
1780 }
1781 if manifest.version.is_empty()
1782 || manifest.version.len() > 64
1783 || !manifest.version.bytes().all(|byte| byte.is_ascii_graphic())
1784 {
1785 return Err(PluginPlanError::InvalidVersion {
1786 plugin_id: manifest.id.clone(),
1787 version: manifest.version.clone(),
1788 });
1789 }
1790 Ok(())
1791}
1792
1793fn valid_plugin_id(id: &str) -> bool {
1794 if id.is_empty() || id.len() > 128 {
1795 return false;
1796 }
1797 let mut previous_separator = true;
1798 for byte in id.bytes() {
1799 let separator = matches!(byte, b'.' | b'-' | b'_');
1800 if separator {
1801 if previous_separator {
1802 return false;
1803 }
1804 } else if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() {
1805 return false;
1806 }
1807 previous_separator = separator;
1808 }
1809 !previous_separator && id.as_bytes()[0].is_ascii_lowercase()
1810}
1811
1812struct InstalledPlugin {
1813 plugin: Arc<dyn ErasedClientPlugin>,
1814 manifest: PluginManifest,
1815 resources: Arc<PluginResources>,
1816 diagnostics: Arc<PluginDiagnostics>,
1817}
1818
1819struct PluginInstallRollback {
1820 runtime: Arc<dyn Runtime>,
1821 config: PluginHostConfig,
1822 installed: Vec<InstalledPlugin>,
1823 current: Option<InstalledPlugin>,
1824 upstream: Option<Arc<dyn ClientLifecycle>>,
1825 staged_apis: Option<Arc<ApiRegistry>>,
1826 armed: bool,
1827}
1828
1829impl PluginInstallRollback {
1830 fn new(runtime: Arc<dyn Runtime>, capacity: usize, config: PluginHostConfig) -> Self {
1831 Self {
1832 runtime,
1833 config,
1834 installed: Vec::with_capacity(capacity),
1835 current: None,
1836 upstream: None,
1837 staged_apis: None,
1838 armed: true,
1839 }
1840 }
1841
1842 fn close_resources(&self) {
1843 if let Some(current) = &self.current {
1844 close_plugin_resources(¤t.manifest.id, ¤t.resources);
1845 }
1846 for plugin in self.installed.iter().rev() {
1847 close_plugin_resources(&plugin.manifest.id, &plugin.resources);
1848 }
1849 }
1850
1851 fn schedule_rollback(&mut self) -> Option<ShutdownSignal> {
1852 if !self.armed {
1853 return None;
1854 }
1855 self.close_resources();
1856 let current = self.current.take();
1857 let installed = std::mem::take(&mut self.installed);
1858 let upstream = self.upstream.take();
1859 let staged_apis = self.staged_apis.take();
1860 self.armed = false;
1861 if current.is_none() && installed.is_empty() && upstream.is_none() {
1862 return None;
1863 }
1864 if let Some(upstream) = &upstream
1865 && std::panic::catch_unwind(AssertUnwindSafe(|| upstream.signal_shutdown())).is_err()
1866 {
1867 log::warn!("Upstream lifecycle rollback shutdown signal panicked");
1868 }
1869
1870 let completed = ShutdownNotifier::new();
1871 let completion = completed.subscribe();
1872 let runtime = self.runtime.clone();
1873 let cleanup_runtime = runtime.clone();
1874 let config = self.config;
1875 runtime
1876 .spawn(Box::pin(async move {
1877 let result = AssertUnwindSafe(shutdown_staged_plugins(
1878 cleanup_runtime,
1879 config,
1880 current,
1881 installed,
1882 upstream,
1883 staged_apis,
1884 ))
1885 .catch_unwind()
1886 .await;
1887 completed.notify();
1888 if result.is_err() {
1889 log::warn!("Plugin installation rollback panicked");
1890 }
1891 }))
1892 .detach();
1893 Some(completion)
1894 }
1895
1896 async fn rollback(&mut self) {
1897 if let Some(completion) = self.schedule_rollback() {
1898 wait_for_shutdown(&completion).await;
1899 }
1900 }
1901
1902 fn take_installed(&mut self) -> Vec<InstalledPlugin> {
1903 std::mem::take(&mut self.installed)
1904 }
1905
1906 fn restore_installed(&mut self, installed: Vec<InstalledPlugin>) {
1907 self.installed = installed;
1908 }
1909
1910 fn disarm(&mut self) {
1911 self.armed = false;
1912 self.upstream = None;
1913 self.staged_apis = None;
1914 }
1915}
1916
1917impl Drop for PluginInstallRollback {
1918 fn drop(&mut self) {
1919 let _ = self.schedule_rollback();
1920 }
1921}
1922
1923struct PluginContextParts {
1924 resources: Arc<PluginResources>,
1925 apis: Arc<ApiRegistry>,
1926 runtime: Arc<dyn Runtime>,
1927 connection_generation: Arc<AtomicU64>,
1928 diagnostics: Arc<PluginDiagnostics>,
1929}
1930
1931struct InstalledPlugins {
1932 plugins: Vec<InstalledPlugin>,
1933 staged_apis: Mutex<Option<HashMap<TypeId, ErasedApi>>>,
1934}
1935
1936pub(crate) struct PluginHost {
1937 ordered: Vec<PlannedPlugin>,
1938 manifests: Vec<PluginManifest>,
1939 diagnostics: Vec<Arc<PluginDiagnostics>>,
1940 upstream: Option<Arc<dyn ClientLifecycle>>,
1941 installed: OnceLock<InstalledPlugins>,
1942 apis: OnceLock<HashMap<TypeId, ErasedApi>>,
1943 runtime: OnceLock<Arc<dyn Runtime>>,
1944 event_router: Option<PluginEventRouter>,
1945 config: PluginHostConfig,
1946 terminal: AtomicBool,
1947 terminal_notifier: ShutdownNotifier,
1948 installing_resources: Mutex<Vec<Weak<PluginResources>>>,
1949 upstream_callback_failures: AtomicU64,
1950 upstream_callback_timeouts: AtomicU64,
1951}
1952
1953impl PluginHost {
1954 pub(crate) fn new(
1955 plan: PluginPlan,
1956 upstream: Option<Arc<dyn ClientLifecycle>>,
1957 config: PluginHostConfig,
1958 ) -> Arc<Self> {
1959 Self::new_with_config(plan, upstream, config)
1960 }
1961
1962 #[cfg(test)]
1963 fn new_with_callback_timeout(
1964 plan: PluginPlan,
1965 upstream: Option<Arc<dyn ClientLifecycle>>,
1966 callback_timeout: Duration,
1967 ) -> Arc<Self> {
1968 Self::new_with_config(
1969 plan,
1970 upstream,
1971 PluginHostConfig::new().with_callback_timeout(callback_timeout),
1972 )
1973 }
1974
1975 fn new_with_config(
1976 plan: PluginPlan,
1977 upstream: Option<Arc<dyn ClientLifecycle>>,
1978 config: PluginHostConfig,
1979 ) -> Arc<Self> {
1980 let manifests = plan
1981 .ordered
1982 .iter()
1983 .map(|plugin| plugin.manifest.clone())
1984 .collect::<Vec<_>>();
1985 let event_publishers = manifests
1986 .iter()
1987 .filter(|manifest| {
1988 manifest
1989 .capabilities
1990 .contains(PluginCapability::PluginEvents)
1991 })
1992 .map(|manifest| manifest.id.clone())
1993 .collect::<Vec<_>>();
1994 let event_router =
1995 (!event_publishers.is_empty()).then(|| PluginEventRouter::new(event_publishers));
1996 let diagnostics = (0..manifests.len())
1997 .map(|_| PluginDiagnostics::new())
1998 .collect();
1999 Arc::new(Self {
2000 ordered: plan.ordered,
2001 manifests,
2002 diagnostics,
2003 upstream,
2004 installed: OnceLock::new(),
2005 apis: OnceLock::new(),
2006 runtime: OnceLock::new(),
2007 event_router,
2008 config,
2009 terminal: AtomicBool::new(false),
2010 terminal_notifier: ShutdownNotifier::new(),
2011 installing_resources: Mutex::new(Vec::new()),
2012 upstream_callback_failures: AtomicU64::new(0),
2013 upstream_callback_timeouts: AtomicU64::new(0),
2014 })
2015 }
2016
2017 pub(crate) fn plugin<P: ClientPlugin>(&self) -> Option<Arc<P::Api>> {
2018 downcast_api::<P::Api>(self.apis.get()?.get(&TypeId::of::<P>())?)
2019 }
2020
2021 fn is_published(&self) -> bool {
2022 self.apis.get().is_some()
2023 }
2024
2025 fn installed_plugins(&self) -> &[InstalledPlugin] {
2026 self.installed
2027 .get()
2028 .map(|installed| installed.plugins.as_slice())
2029 .unwrap_or_default()
2030 }
2031
2032 pub(crate) fn manifests(&self) -> &[PluginManifest] {
2033 &self.manifests
2034 }
2035
2036 pub(crate) fn stats(&self) -> PluginHostStats {
2037 let terminal = self.terminal.load(Ordering::Acquire);
2038 let upstream_callback_failures = self.upstream_callback_failures.load(Ordering::Relaxed);
2039 let upstream_callback_timeouts = self.upstream_callback_timeouts.load(Ordering::Relaxed);
2040 let plugins = self
2041 .manifests
2042 .iter()
2043 .zip(&self.diagnostics)
2044 .map(|(manifest, diagnostics)| {
2045 let events = self
2046 .event_router
2047 .as_ref()
2048 .and_then(|router| router.publisher_stats(&manifest.id));
2049 diagnostics.snapshot(&manifest.id, terminal, events)
2050 })
2051 .collect::<Vec<_>>();
2052 let health = if upstream_callback_failures > 0
2053 || upstream_callback_timeouts > 0
2054 || plugins
2055 .iter()
2056 .any(|plugin| plugin.health == PluginHealth::Degraded)
2057 {
2058 PluginHealth::Degraded
2059 } else {
2060 PluginHealth::Healthy
2061 };
2062 PluginHostStats {
2063 terminal,
2064 health,
2065 upstream_callback_failures,
2066 upstream_callback_timeouts,
2067 plugins,
2068 event_router: self.event_router.as_ref().map(PluginEventRouter::stats),
2069 }
2070 }
2071
2072 pub(crate) fn lifecycle_callback_timeout(&self) -> Duration {
2073 let callback_count = self.ordered.len() + usize::from(self.upstream.is_some());
2074 let task_barrier_count = self
2075 .ordered
2076 .iter()
2077 .filter(|plugin| {
2078 plugin
2079 .manifest
2080 .capabilities
2081 .contains(PluginCapability::Tasks)
2082 })
2083 .count();
2084 self.config
2085 .callback_timeout()
2086 .saturating_mul(callback_count as u32)
2087 .saturating_add(
2088 self.config
2089 .task_drain_timeout()
2090 .saturating_mul(task_barrier_count as u32),
2091 )
2092 .saturating_add(Duration::from_secs(1))
2093 }
2094
2095 fn context(
2096 &self,
2097 client: &Weak<Client>,
2098 planned: &PlannedPlugin,
2099 parts: PluginContextParts,
2100 ) -> PluginContext {
2101 let PluginContextParts {
2102 resources,
2103 apis,
2104 runtime,
2105 connection_generation,
2106 diagnostics,
2107 } = parts;
2108 let manifest = &planned.manifest;
2109 let capabilities = manifest.capabilities;
2110 let plugin_id: Arc<str> = Arc::from(manifest.id.as_str());
2111 PluginContext {
2112 plugin_id: manifest.id.clone(),
2113 dependencies: apis.dependency_view(&planned.dependency_markers),
2114 core_events: capabilities
2115 .contains(PluginCapability::CoreEvents)
2116 .then(|| PluginCoreEvents {
2117 client: client.clone(),
2118 resources: Arc::clone(&resources),
2119 plugin_id: Arc::clone(&plugin_id),
2120 diagnostics: Arc::clone(&diagnostics),
2121 }),
2122 tasks: capabilities
2123 .contains(PluginCapability::Tasks)
2124 .then(|| PluginTasks {
2125 runtime: Arc::clone(&runtime),
2126 resources: Arc::clone(&resources),
2127 diagnostics: Arc::clone(&diagnostics),
2128 plugin_id,
2129 }),
2130 messaging: capabilities.contains(PluginCapability::Messaging).then(|| {
2131 PluginMessaging {
2132 client: client.clone(),
2133 resources: Arc::clone(&resources),
2134 }
2135 }),
2136 iq: capabilities
2137 .contains(PluginCapability::Iq)
2138 .then(|| PluginIq {
2139 client: client.clone(),
2140 resources: Arc::clone(&resources),
2141 }),
2142 plugin_events: self
2143 .event_router
2144 .as_ref()
2145 .filter(|_| capabilities.contains(PluginCapability::PluginEvents))
2146 .and_then(|router| {
2147 events::publisher(
2148 &manifest.id,
2149 router.clone(),
2150 Arc::clone(&resources),
2151 connection_generation,
2152 )
2153 }),
2154 }
2155 }
2156
2157 fn connection_scope(
2158 &self,
2159 scope: ConnectionScope,
2160 plugin: &InstalledPlugin,
2161 task_tracker: Option<Arc<TaskTracker>>,
2162 ) -> PluginConnectionScope {
2163 let tasks = if plugin
2164 .manifest
2165 .capabilities
2166 .contains(PluginCapability::Tasks)
2167 {
2168 self.runtime
2169 .get()
2170 .cloned()
2171 .zip(task_tracker)
2172 .map(|(runtime, tracker)| PluginConnectionTasks {
2173 runtime,
2174 scope: scope.clone(),
2175 tracker,
2176 diagnostics: Arc::clone(&plugin.diagnostics),
2177 plugin_id: Arc::from(plugin.manifest.id.as_str()),
2178 })
2179 } else {
2180 None
2181 };
2182 PluginConnectionScope { scope, tasks }
2183 }
2184
2185 async fn wait_for_tasks(
2186 &self,
2187 completion_signals: Vec<ShutdownSignal>,
2188 ) -> Result<(), PluginTaskDrainError> {
2189 let runtime = self
2190 .runtime
2191 .get()
2192 .ok_or(PluginTaskDrainError::RuntimeUnavailable)?;
2193 wait_for_plugin_tasks(
2194 &**runtime,
2195 self.config.task_drain_timeout(),
2196 completion_signals,
2197 )
2198 .await
2199 }
2200
2201 async fn install_all(&self, client: Weak<Client>) -> anyhow::Result<()> {
2202 let Some(strong_client) = client.upgrade() else {
2203 anyhow::bail!("client was dropped during plugin installation");
2204 };
2205 let runtime = strong_client.runtime.clone();
2206 let connection_generation = strong_client.connection_generation.clone();
2207 drop(strong_client);
2208 self.runtime
2209 .set(runtime.clone())
2210 .map_err(|_| anyhow::anyhow!("plugin host was installed more than once"))?;
2211
2212 let installing_resources = &self.installing_resources;
2213 let _installing_resources = scopeguard::guard((), move |_| {
2214 installing_resources
2215 .lock()
2216 .unwrap_or_else(|poisoned| poisoned.into_inner())
2217 .clear();
2218 });
2219 let mut rollback =
2220 PluginInstallRollback::new(runtime.clone(), self.ordered.len(), self.config);
2221 self.abort_install_if_terminal(&mut rollback).await?;
2222 if let Some(upstream) = &self.upstream {
2223 rollback.upstream = Some(upstream.clone());
2224 if let Err(error) =
2225 bounded_plugin_install(&*runtime, self.config.install_timeout(), || {
2226 upstream.install(client.clone())
2227 })
2228 .await
2229 {
2230 rollback.rollback().await;
2231 return Err(error);
2232 }
2233 self.abort_install_if_terminal(&mut rollback).await?;
2234 }
2235
2236 let staging = Arc::new(ApiRegistry::default());
2237 rollback.staged_apis = Some(Arc::clone(&staging));
2238 for (planned, diagnostics) in self.ordered.iter().zip(&self.diagnostics) {
2239 self.abort_install_if_terminal(&mut rollback).await?;
2240 let resources = PluginResources::new();
2241 diagnostics.attach_resources(&resources);
2242 let context = self.context(
2243 &client,
2244 planned,
2245 PluginContextParts {
2246 resources: Arc::clone(&resources),
2247 apis: Arc::clone(&staging),
2248 runtime: runtime.clone(),
2249 connection_generation: connection_generation.clone(),
2250 diagnostics: Arc::clone(diagnostics),
2251 },
2252 );
2253 rollback.current = Some(InstalledPlugin {
2254 plugin: planned.plugin.clone(),
2255 manifest: planned.manifest.clone(),
2256 resources: Arc::clone(&resources),
2257 diagnostics: Arc::clone(diagnostics),
2258 });
2259 if !self.track_installing_resources(&resources) {
2260 rollback.rollback().await;
2261 anyhow::bail!("plugin host shut down during installation");
2262 }
2263 let terminal = self.terminal_notifier.subscribe();
2264 let cancelled = Box::pin(wait_for_shutdown(&terminal));
2265 let install = Box::pin(bounded_plugin_install(
2266 &*runtime,
2267 self.config.install_timeout(),
2268 || planned.plugin.install(context),
2269 ));
2270 let install_result = match futures::future::select(cancelled, install).await {
2271 futures::future::Either::Left((_, install)) => {
2272 if std::panic::catch_unwind(AssertUnwindSafe(|| drop(install))).is_err() {
2273 log::warn!(
2274 "Plugin `{}` install future panicked while being cancelled",
2275 planned.manifest.id
2276 );
2277 }
2278 rollback.rollback().await;
2279 anyhow::bail!("plugin host shut down during installation");
2280 }
2281 futures::future::Either::Right((result, _)) => result,
2282 };
2283 let api = match install_result {
2284 Ok(api) => api,
2285 Err(error) => {
2286 rollback.rollback().await;
2287 anyhow::bail!(
2288 "plugin `{}` installation failed: {error:#}",
2289 planned.manifest.id
2290 );
2291 }
2292 };
2293 match (planned.plugin.marker_type_id(), api) {
2294 (Some(marker), Some(api)) => staging.insert(marker, api),
2295 (None, None) => {}
2296 _ => {
2297 rollback.rollback().await;
2298 anyhow::bail!(
2299 "plugin `{}` returned an API inconsistent with its registration",
2300 planned.manifest.id
2301 );
2302 }
2303 }
2304 self.abort_install_if_terminal(&mut rollback).await?;
2305 let Some(installed) = rollback.current.take() else {
2306 rollback.rollback().await;
2307 anyhow::bail!("plugin installation rollback state was lost");
2308 };
2309 rollback.installed.push(installed);
2310 }
2311 self.abort_install_if_terminal(&mut rollback).await?;
2312
2313 let installed = rollback.take_installed();
2314 let installed = InstalledPlugins {
2315 plugins: installed,
2316 staged_apis: Mutex::new(Some(staging.snapshot())),
2317 };
2318 if let Err(installed) = self.installed.set(installed) {
2319 rollback.restore_installed(installed.plugins);
2320 anyhow::bail!("plugins were installed more than once");
2321 }
2322 rollback.disarm();
2323 Ok(())
2324 }
2325
2326 async fn abort_install_if_terminal(
2327 &self,
2328 rollback: &mut PluginInstallRollback,
2329 ) -> anyhow::Result<()> {
2330 if !self.terminal.load(Ordering::Acquire) {
2331 return Ok(());
2332 }
2333 rollback.rollback().await;
2334 anyhow::bail!("plugin host shut down during installation")
2335 }
2336
2337 fn track_installing_resources(&self, resources: &Arc<PluginResources>) -> bool {
2338 let mut installing = self
2339 .installing_resources
2340 .lock()
2341 .unwrap_or_else(|poisoned| poisoned.into_inner());
2342 if self.terminal.load(Ordering::Acquire) {
2343 drop(installing);
2344 if std::panic::catch_unwind(AssertUnwindSafe(|| resources.close())).is_err() {
2345 resources.teardown_panics.fetch_add(1, Ordering::Relaxed);
2346 log::warn!("Installing plugin resource closure panicked");
2347 }
2348 return false;
2349 }
2350 installing.push(Arc::downgrade(resources));
2351 true
2352 }
2353
2354 fn close_installing_resources(&self) {
2355 let resources = self
2356 .installing_resources
2357 .lock()
2358 .unwrap_or_else(|poisoned| poisoned.into_inner())
2359 .iter()
2360 .filter_map(Weak::upgrade)
2361 .collect::<Vec<_>>();
2362 for resources in resources {
2363 if std::panic::catch_unwind(AssertUnwindSafe(|| resources.close())).is_err() {
2364 resources.teardown_panics.fetch_add(1, Ordering::Relaxed);
2365 log::warn!("Installing plugin resource closure panicked");
2366 }
2367 }
2368 }
2369
2370 pub(crate) fn commit(&self) -> bool {
2371 if self.terminal.load(Ordering::Acquire) {
2372 self.close_installed_resources();
2373 return false;
2374 }
2375 let Some(installed) = self.installed.get() else {
2376 return false;
2377 };
2378 let mut staged = installed
2379 .staged_apis
2380 .lock()
2381 .unwrap_or_else(|poisoned| poisoned.into_inner());
2382 if let Some(apis) = staged.take()
2383 && let Err(apis) = self.apis.set(apis)
2384 {
2385 *staged = Some(apis);
2386 return false;
2387 }
2388 if self.apis.get().is_none() {
2389 return false;
2390 }
2391 for plugin in &installed.plugins {
2392 plugin.resources.prepare_activation();
2393 }
2394 for plugin in &installed.plugins {
2395 plugin.resources.publish_activation();
2396 }
2397 true
2398 }
2399
2400 fn close_installed_resources(&self) {
2401 for plugin in self.installed_plugins().iter().rev() {
2402 close_plugin_resources(&plugin.manifest.id, &plugin.resources);
2403 }
2404 }
2405
2406 async fn run_callback<'a>(
2407 &'a self,
2408 make_future: impl FnOnce() -> BoxFuture<'a, anyhow::Result<()>>,
2409 ) -> Result<(), PluginCallbackError> {
2410 let runtime = self.runtime.get().ok_or_else(|| {
2411 PluginCallbackError::Callback(anyhow::anyhow!("plugin runtime is unavailable"))
2412 })?;
2413 bounded_plugin_callback(&**runtime, self.config.callback_timeout(), make_future).await
2414 }
2415
2416 fn record_upstream_callback(&self, result: &Result<(), PluginCallbackError>) {
2417 match result {
2418 Ok(()) => {}
2419 Err(PluginCallbackError::Timeout { .. }) => {
2420 self.upstream_callback_timeouts
2421 .fetch_add(1, Ordering::Relaxed);
2422 }
2423 Err(PluginCallbackError::TimeoutCancellationPanic { .. }) => {
2424 self.upstream_callback_timeouts
2425 .fetch_add(1, Ordering::Relaxed);
2426 self.upstream_callback_failures
2427 .fetch_add(1, Ordering::Relaxed);
2428 }
2429 Err(PluginCallbackError::Callback(_)) => {
2430 self.upstream_callback_failures
2431 .fetch_add(1, Ordering::Relaxed);
2432 }
2433 }
2434 }
2435}
2436
2437impl ClientLifecycle for PluginHost {
2438 fn install(&self, client: Weak<Client>) -> BoxFuture<'_, anyhow::Result<()>> {
2439 Box::pin(async move { self.install_all(client).await })
2440 }
2441
2442 fn on_ready(&self, scope: ConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
2443 Box::pin(async move {
2444 let mut failures = Vec::new();
2445 if let Some(upstream) = &self.upstream {
2446 let result = self.run_callback(|| upstream.on_ready(scope.clone())).await;
2447 self.record_upstream_callback(&result);
2448 if let Err(error) = result {
2449 failures.push(format!("upstream: {error:#}"));
2450 }
2451 }
2452 for plugin in self.installed_plugins() {
2453 let task_tracker = plugin
2454 .manifest
2455 .capabilities
2456 .contains(PluginCapability::Tasks)
2457 .then(|| {
2458 let (tracker, created) =
2459 plugin.resources.connection_task_tracker(scope.generation());
2460 if created && let Some(runtime) = self.runtime.get() {
2461 plugin.resources.retire_connection_tasks_on_cancel(
2462 runtime,
2463 scope.generation(),
2464 Arc::clone(&tracker),
2465 scope.cancellation_signal(),
2466 );
2467 }
2468 tracker
2469 });
2470 let plugin_scope = self.connection_scope(scope.clone(), plugin, task_tracker);
2471 let result = self
2472 .run_callback(|| plugin.plugin.on_ready(plugin_scope))
2473 .await;
2474 plugin.diagnostics.record_callback(&result);
2475 if let Err(error) = result {
2476 failures.push(format!("{}: {error:#}", plugin.manifest.id));
2477 }
2478 }
2479 finish_callbacks("ready", failures)
2480 })
2481 }
2482
2483 fn on_closed(&self, scope: ConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
2484 Box::pin(async move {
2485 let mut failures = Vec::new();
2486 for plugin in self.installed_plugins().iter().rev() {
2487 let task_tracker = plugin
2488 .manifest
2489 .capabilities
2490 .contains(PluginCapability::Tasks)
2491 .then(|| plugin.resources.close_connection_tasks(scope.generation()));
2492 if let Some(task_tracker) = &task_tracker {
2493 let result = self
2494 .wait_for_tasks(vec![task_tracker.completion_signal()])
2495 .await;
2496 plugin.diagnostics.record_task_drain(&result);
2497 match result {
2498 Ok(()) => plugin
2499 .resources
2500 .forget_connection_tasks(scope.generation(), task_tracker),
2501 Err(error) => {
2502 failures.push(format!("{} tasks: {error:#}", plugin.manifest.id));
2503 }
2504 }
2505 }
2506 let plugin_scope = self.connection_scope(scope.clone(), plugin, task_tracker);
2507 let result = self
2508 .run_callback(|| plugin.plugin.on_closed(plugin_scope))
2509 .await;
2510 plugin.diagnostics.record_callback(&result);
2511 if let Err(error) = result {
2512 failures.push(format!("{}: {error:#}", plugin.manifest.id));
2513 }
2514 }
2515 if let Some(upstream) = &self.upstream {
2516 let result = self.run_callback(|| upstream.on_closed(scope)).await;
2517 self.record_upstream_callback(&result);
2518 if let Err(error) = result {
2519 failures.push(format!("upstream: {error:#}"));
2520 }
2521 }
2522 finish_callbacks("closed", failures)
2523 })
2524 }
2525
2526 fn signal_shutdown(&self) {
2527 self.terminal.store(true, Ordering::Release);
2528 self.close_installing_resources();
2529 self.terminal_notifier.notify();
2530 if let Some(router) = &self.event_router {
2531 router.close();
2532 }
2533 self.close_installed_resources();
2534 if let Some(upstream) = &self.upstream
2535 && std::panic::catch_unwind(AssertUnwindSafe(|| upstream.signal_shutdown())).is_err()
2536 {
2537 self.upstream_callback_failures
2538 .fetch_add(1, Ordering::Relaxed);
2539 log::warn!("Upstream lifecycle synchronous shutdown signal panicked");
2540 }
2541 }
2542
2543 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
2544 Box::pin(async move {
2545 let mut failures = Vec::new();
2546 self.signal_shutdown();
2547 for plugin in self.installed_plugins().iter().rev() {
2548 let task_result = self
2549 .wait_for_tasks(plugin.resources.task_completion_signals())
2550 .await;
2551 plugin.diagnostics.record_task_drain(&task_result);
2552 if let Err(error) = task_result {
2553 failures.push(format!("{} tasks: {error:#}", plugin.manifest.id));
2554 }
2555 let callback_result = self.run_callback(|| plugin.plugin.shutdown()).await;
2556 plugin.diagnostics.record_callback(&callback_result);
2557 plugin.diagnostics.mark_stopped();
2558 if let Err(error) = callback_result {
2559 failures.push(format!("{}: {error:#}", plugin.manifest.id));
2560 }
2561 }
2562 if let Some(upstream) = &self.upstream {
2563 let result = self.run_callback(|| upstream.shutdown()).await;
2564 self.record_upstream_callback(&result);
2565 if let Err(error) = result {
2566 failures.push(format!("upstream: {error:#}"));
2567 }
2568 }
2569 finish_callbacks("shutdown", failures)
2570 })
2571 }
2572}
2573
2574impl Drop for PluginHost {
2575 fn drop(&mut self) {
2576 self.signal_shutdown();
2577 }
2578}
2579
2580#[derive(Debug, Error)]
2581enum PluginCallbackError {
2582 #[error("callback timed out after {timeout_seconds:.3} seconds")]
2583 Timeout { timeout_seconds: f64 },
2584 #[error(
2585 "callback timed out after {timeout_seconds:.3} seconds and panicked while being cancelled"
2586 )]
2587 TimeoutCancellationPanic { timeout_seconds: f64 },
2588 #[error("{0}")]
2589 Callback(#[from] anyhow::Error),
2590}
2591
2592#[derive(Debug, Error)]
2593enum PluginTaskDrainError {
2594 #[error("plugin runtime is unavailable")]
2595 RuntimeUnavailable,
2596 #[error("plugin tasks did not stop within {timeout_seconds:.3} seconds")]
2597 Timeout { timeout_seconds: f64 },
2598}
2599
2600async fn shutdown_staged_plugins(
2601 runtime: Arc<dyn Runtime>,
2602 config: PluginHostConfig,
2603 current: Option<InstalledPlugin>,
2604 mut installed: Vec<InstalledPlugin>,
2605 upstream: Option<Arc<dyn ClientLifecycle>>,
2606 staged_apis: Option<Arc<ApiRegistry>>,
2607) {
2608 if let Some(plugin) = current {
2609 let task_result = wait_for_plugin_tasks(
2610 &*runtime,
2611 config.task_drain_timeout(),
2612 plugin.resources.task_completion_signals(),
2613 )
2614 .await;
2615 plugin.diagnostics.record_task_drain(&task_result);
2616 if let Err(error) = task_result {
2617 log::warn!(
2618 "Plugin `{}` failed-install task cleanup failed: {error:#}",
2619 plugin.manifest.id
2620 );
2621 }
2622 let callback_result = bounded_plugin_callback(&*runtime, config.callback_timeout(), || {
2623 plugin.plugin.shutdown()
2624 })
2625 .await;
2626 plugin.diagnostics.record_callback(&callback_result);
2627 plugin.diagnostics.mark_stopped();
2628 if let Err(error) = callback_result {
2629 log::warn!(
2630 "Plugin `{}` failed-install rollback failed: {error:#}",
2631 plugin.manifest.id
2632 );
2633 }
2634 }
2635 while let Some(plugin) = installed.pop() {
2636 let task_result = wait_for_plugin_tasks(
2637 &*runtime,
2638 config.task_drain_timeout(),
2639 plugin.resources.task_completion_signals(),
2640 )
2641 .await;
2642 plugin.diagnostics.record_task_drain(&task_result);
2643 if let Err(error) = task_result {
2644 log::warn!(
2645 "Plugin `{}` rollback task cleanup failed: {error:#}",
2646 plugin.manifest.id
2647 );
2648 }
2649 let callback_result = bounded_plugin_callback(&*runtime, config.callback_timeout(), || {
2650 plugin.plugin.shutdown()
2651 })
2652 .await;
2653 plugin.diagnostics.record_callback(&callback_result);
2654 plugin.diagnostics.mark_stopped();
2655 if let Err(error) = callback_result {
2656 log::warn!("Plugin `{}` rollback failed: {error:#}", plugin.manifest.id);
2657 }
2658 }
2659 if let Some(upstream) = upstream
2660 && let Err(error) =
2661 bounded_plugin_callback(&*runtime, config.callback_timeout(), || upstream.shutdown())
2662 .await
2663 {
2664 log::warn!("Upstream lifecycle rollback failed: {error:#}");
2665 }
2666 if std::panic::catch_unwind(AssertUnwindSafe(|| drop(staged_apis))).is_err() {
2667 log::warn!("Plugin API panicked while being dropped during rollback");
2668 }
2669}
2670
2671async fn wait_for_plugin_tasks(
2672 runtime: &dyn Runtime,
2673 timeout: Duration,
2674 completion_signals: Vec<ShutdownSignal>,
2675) -> Result<(), PluginTaskDrainError> {
2676 let wait_for_all = async move {
2677 for signal in completion_signals {
2678 wait_for_shutdown(&signal).await;
2679 }
2680 };
2681 runtime_timeout(runtime, timeout, wait_for_all)
2682 .await
2683 .map_err(|_| PluginTaskDrainError::Timeout {
2684 timeout_seconds: timeout.as_secs_f64(),
2685 })
2686}
2687
2688async fn bounded_plugin_callback<'a>(
2689 runtime: &dyn Runtime,
2690 timeout: Duration,
2691 make_future: impl FnOnce() -> BoxFuture<'a, anyhow::Result<()>>,
2692) -> Result<(), PluginCallbackError> {
2693 let callback = Box::pin(plugin_callback(make_future));
2694 match futures::future::select(callback, runtime.sleep(timeout)).await {
2695 futures::future::Either::Left((result, _)) => result.map_err(PluginCallbackError::Callback),
2696 futures::future::Either::Right(((), callback)) => {
2697 let cancellation_panicked =
2698 std::panic::catch_unwind(AssertUnwindSafe(|| drop(callback))).is_err();
2699 if cancellation_panicked {
2700 return Err(PluginCallbackError::TimeoutCancellationPanic {
2701 timeout_seconds: timeout.as_secs_f64(),
2702 });
2703 }
2704 Err(PluginCallbackError::Timeout {
2705 timeout_seconds: timeout.as_secs_f64(),
2706 })
2707 }
2708 }
2709}
2710
2711async fn plugin_callback<'a>(
2712 make_future: impl FnOnce() -> BoxFuture<'a, anyhow::Result<()>>,
2713) -> anyhow::Result<()> {
2714 let mut future = std::panic::catch_unwind(AssertUnwindSafe(make_future))
2715 .map_err(|_| anyhow::anyhow!("callback panicked before returning a future"))?;
2716 let result = AssertUnwindSafe(std::future::poll_fn(|context| {
2717 future.as_mut().poll(context)
2718 }))
2719 .catch_unwind()
2720 .await
2721 .map_err(|_| anyhow::anyhow!("callback future panicked"));
2722 let drop_result = std::panic::catch_unwind(AssertUnwindSafe(|| drop(future)));
2723 if drop_result.is_err() {
2724 anyhow::bail!("callback future panicked while being dropped");
2725 }
2726 result?
2727}
2728
2729async fn plugin_install<'a, T>(
2730 make_future: impl FnOnce() -> BoxFuture<'a, anyhow::Result<T>>,
2731) -> anyhow::Result<T> {
2732 let mut future = std::panic::catch_unwind(AssertUnwindSafe(make_future))
2733 .map_err(|_| anyhow::anyhow!("install panicked before returning a future"))?;
2734 let result = AssertUnwindSafe(std::future::poll_fn(|context| {
2735 future.as_mut().poll(context)
2736 }))
2737 .catch_unwind()
2738 .await
2739 .map_err(|_| anyhow::anyhow!("install future panicked"));
2740 let drop_result = std::panic::catch_unwind(AssertUnwindSafe(|| drop(future)));
2741 if drop_result.is_err() {
2742 anyhow::bail!("install future panicked while being dropped");
2743 }
2744 result?
2745}
2746
2747async fn bounded_plugin_install<'a, T>(
2748 runtime: &dyn Runtime,
2749 timeout: Duration,
2750 make_future: impl FnOnce() -> BoxFuture<'a, anyhow::Result<T>>,
2751) -> anyhow::Result<T> {
2752 let install = Box::pin(plugin_install(make_future));
2753 match futures::future::select(install, runtime.sleep(timeout)).await {
2754 futures::future::Either::Left((result, _)) => result,
2755 futures::future::Either::Right(((), install)) => {
2756 if std::panic::catch_unwind(AssertUnwindSafe(|| drop(install))).is_err() {
2757 anyhow::bail!(
2758 "install timed out after {:.3} seconds and panicked while being cancelled",
2759 timeout.as_secs_f64()
2760 );
2761 }
2762 anyhow::bail!(
2763 "install timed out after {:.3} seconds",
2764 timeout.as_secs_f64()
2765 )
2766 }
2767 }
2768}
2769
2770fn finish_callbacks(stage: &str, failures: Vec<String>) -> anyhow::Result<()> {
2771 if failures.is_empty() {
2772 Ok(())
2773 } else {
2774 anyhow::bail!("plugin {stage} callbacks failed: {}", failures.join("; "))
2775 }
2776}
2777
2778impl Client {
2779 pub fn plugin<P: ClientPlugin>(&self) -> Option<Arc<P::Api>> {
2781 self.plugin_host.as_ref()?.plugin::<P>()
2782 }
2783
2784 pub fn plugin_manifests(&self) -> &[PluginManifest] {
2786 self.plugin_host
2787 .as_ref()
2788 .filter(|host| host.is_published())
2789 .map(|host| host.manifests())
2790 .unwrap_or_default()
2791 }
2792
2793 pub fn plugin_stats(&self) -> Option<PluginHostStats> {
2795 self.plugin_host
2796 .as_ref()
2797 .filter(|host| host.is_published())
2798 .map(|host| host.stats())
2799 }
2800
2801 pub fn plugin_event_router(&self) -> Option<PluginEventRouter> {
2805 self.plugin_host
2806 .as_ref()
2807 .filter(|host| host.is_published())
2808 .and_then(|host| host.event_router.clone())
2809 }
2810}
2811
2812#[cfg(test)]
2813mod tests {
2814 use std::pin::Pin;
2815 use std::sync::Barrier;
2816 use std::sync::atomic::AtomicBool;
2817 use std::time::Duration;
2818
2819 use bytes::Bytes;
2820
2821 use super::*;
2822 use crate::client::{ClientBuilder, ClientBuilderError};
2823 use crate::runtime_impl::TokioRuntime;
2824 use crate::store::persistence_manager::PersistenceManager;
2825 use crate::test_utils::MockHttpClient;
2826 use crate::transport::mock::MockTransportFactory;
2827
2828 type Log = Arc<Mutex<Vec<String>>>;
2829
2830 fn record(log: &Log, value: impl Into<String>) {
2831 log.lock()
2832 .unwrap_or_else(|poisoned| poisoned.into_inner())
2833 .push(value.into());
2834 }
2835
2836 async fn complete_builder() -> ClientBuilder {
2837 let persistence_manager = Arc::new(
2838 PersistenceManager::new(crate::test_utils::create_test_backend().await)
2839 .await
2840 .expect("persistence manager"),
2841 );
2842 ClientBuilder::new()
2843 .with_runtime(TokioRuntime)
2844 .with_persistence_manager(persistence_manager)
2845 .with_transport_factory(MockTransportFactory::new())
2846 .with_http_client(MockHttpClient)
2847 }
2848
2849 #[test]
2850 fn capability_bits_are_distinct_and_composable() {
2851 let capabilities = [
2852 PluginCapability::CoreEvents,
2853 PluginCapability::Tasks,
2854 PluginCapability::Messaging,
2855 PluginCapability::Iq,
2856 PluginCapability::PluginEvents,
2857 ];
2858 let combined = capabilities
2859 .into_iter()
2860 .fold(PluginCapabilities::NONE, PluginCapabilities::with);
2861
2862 assert!(
2863 capabilities
2864 .into_iter()
2865 .all(|capability| combined.contains(capability))
2866 );
2867 assert_eq!(combined.0.count_ones(), capabilities.len() as u32);
2868 }
2869
2870 #[tokio::test]
2871 async fn rejects_zero_plugin_host_deadlines() {
2872 let install = complete_builder()
2873 .await
2874 .with_plugin_host_config(PluginHostConfig::new().with_install_timeout(Duration::ZERO))
2875 .build()
2876 .await;
2877 assert!(matches!(
2878 install,
2879 Err(ClientBuilderError::InvalidPluginInstallTimeout)
2880 ));
2881
2882 let callback = complete_builder()
2883 .await
2884 .with_plugin_host_config(PluginHostConfig::new().with_callback_timeout(Duration::ZERO))
2885 .build()
2886 .await;
2887 assert!(matches!(
2888 callback,
2889 Err(ClientBuilderError::InvalidPluginCallbackTimeout)
2890 ));
2891
2892 let task_drain = complete_builder()
2893 .await
2894 .with_plugin_host_config(
2895 PluginHostConfig::new().with_task_drain_timeout(Duration::ZERO),
2896 )
2897 .build()
2898 .await;
2899 assert!(matches!(
2900 task_drain,
2901 Err(ClientBuilderError::InvalidPluginTaskDrainTimeout)
2902 ));
2903 }
2904
2905 struct FoundationPlugin {
2906 log: Log,
2907 }
2908
2909 struct RuntimePluginAdapter {
2910 id: &'static str,
2911 dependency: Option<&'static str>,
2912 log: Log,
2913 }
2914
2915 impl UntypedClientPlugin for RuntimePluginAdapter {
2916 fn manifest(&self) -> PluginManifest {
2917 let manifest = PluginManifest::new(self.id, "0.1.0");
2918 match self.dependency {
2919 Some(dependency) => manifest.with_dependency(dependency),
2920 None => manifest,
2921 }
2922 }
2923
2924 fn install(&self, _context: PluginContext) -> BoxFuture<'_, anyhow::Result<()>> {
2925 let id = self.id;
2926 let log = Arc::clone(&self.log);
2927 Box::pin(async move {
2928 record(&log, format!("install:{id}"));
2929 Ok(())
2930 })
2931 }
2932
2933 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
2934 let id = self.id;
2935 let log = Arc::clone(&self.log);
2936 Box::pin(async move {
2937 record(&log, format!("shutdown:{id}"));
2938 Ok(())
2939 })
2940 }
2941 }
2942
2943 struct ShutdownDuringPluginInstall;
2944
2945 struct FailingInstallLifecycle {
2946 log: Log,
2947 }
2948
2949 struct CaptureInstallClient {
2950 client: async_channel::Sender<Weak<Client>>,
2951 }
2952
2953 struct BlockingFirstSpawnRuntime {
2954 blocked: AtomicBool,
2955 entered: async_channel::Sender<()>,
2956 release: Arc<Barrier>,
2957 }
2958
2959 #[async_trait::async_trait]
2960 impl Runtime for BlockingFirstSpawnRuntime {
2961 fn spawn(
2962 &self,
2963 future: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
2964 ) -> wacore::runtime::AbortHandle {
2965 if !self.blocked.swap(true, Ordering::AcqRel) {
2966 self.entered.try_send(()).expect("first spawn observer");
2967 self.release.wait();
2968 }
2969 TokioRuntime.spawn(future)
2970 }
2971
2972 fn sleep(&self, duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> {
2973 TokioRuntime.sleep(duration)
2974 }
2975
2976 fn spawn_blocking(
2977 &self,
2978 f: Box<dyn FnOnce() + Send + 'static>,
2979 ) -> Pin<Box<dyn Future<Output = ()> + Send>> {
2980 TokioRuntime.spawn_blocking(f)
2981 }
2982
2983 fn yield_now(&self) -> Option<Pin<Box<dyn Future<Output = ()> + Send>>> {
2984 TokioRuntime.yield_now()
2985 }
2986 }
2987
2988 impl ClientLifecycle for CaptureInstallClient {
2989 fn install(&self, client: Weak<Client>) -> BoxFuture<'_, anyhow::Result<()>> {
2990 let sender = self.client.clone();
2991 Box::pin(async move {
2992 sender.send(client).await?;
2993 Ok(())
2994 })
2995 }
2996 }
2997
2998 struct PublicationProbePlugin;
2999
3000 impl ClientPlugin for PublicationProbePlugin {
3001 type Api = String;
3002
3003 fn manifest(&self) -> PluginManifest {
3004 PluginManifest::new("publication-probe", "0.1.0")
3005 .with_capability(PluginCapability::PluginEvents)
3006 }
3007
3008 fn install(
3009 &self,
3010 _context: PluginContext,
3011 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3012 Box::pin(async { Ok(Arc::new("published-api".to_string())) })
3013 }
3014 }
3015
3016 struct TerminalBlockingInstallPlugin {
3017 started: async_channel::Sender<ShutdownSignal>,
3018 install_dropped: Arc<AtomicBool>,
3019 shutdown_called: Arc<AtomicBool>,
3020 }
3021
3022 impl ClientPlugin for TerminalBlockingInstallPlugin {
3023 type Api = ();
3024
3025 fn manifest(&self) -> PluginManifest {
3026 PluginManifest::new("terminal-blocking-install", "0.1.0")
3027 .with_capability(PluginCapability::Tasks)
3028 }
3029
3030 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3031 let started = self.started.clone();
3032 let install_dropped = self.install_dropped.clone();
3033 Box::pin(async move {
3034 let _drop = DropFlag(install_dropped);
3035 let shutdown = context
3036 .tasks()
3037 .ok_or_else(|| anyhow::anyhow!("tasks capability missing"))?
3038 .shutdown_signal();
3039 started.send(shutdown).await?;
3040 futures::future::pending().await
3041 })
3042 }
3043
3044 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3045 let shutdown_called = self.shutdown_called.clone();
3046 Box::pin(async move {
3047 shutdown_called.store(true, Ordering::Release);
3048 Ok(())
3049 })
3050 }
3051 }
3052
3053 impl ClientLifecycle for ShutdownDuringPluginInstall {
3054 fn install(&self, client: Weak<Client>) -> BoxFuture<'_, anyhow::Result<()>> {
3055 Box::pin(async move {
3056 client
3057 .upgrade()
3058 .ok_or_else(|| anyhow::anyhow!("client unavailable during install"))?
3059 .signal_shutdown_sync();
3060 Ok(())
3061 })
3062 }
3063 }
3064
3065 impl ClientLifecycle for FailingInstallLifecycle {
3066 fn install(&self, _client: Weak<Client>) -> BoxFuture<'_, anyhow::Result<()>> {
3067 let log = Arc::clone(&self.log);
3068 Box::pin(async move {
3069 record(&log, "install:failing-upstream");
3070 anyhow::bail!("injected upstream install failure")
3071 })
3072 }
3073
3074 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3075 let log = Arc::clone(&self.log);
3076 Box::pin(async move {
3077 record(&log, "shutdown:failing-upstream");
3078 Ok(())
3079 })
3080 }
3081 }
3082
3083 impl ClientPlugin for FoundationPlugin {
3084 type Api = String;
3085
3086 fn manifest(&self) -> PluginManifest {
3087 PluginManifest::new("foundation", "0.1.0")
3088 }
3089
3090 fn install(
3091 &self,
3092 _context: PluginContext,
3093 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3094 let log = self.log.clone();
3095 Box::pin(async move {
3096 record(&log, "install:foundation");
3097 Ok(Arc::new("foundation-api".to_string()))
3098 })
3099 }
3100
3101 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3102 let log = self.log.clone();
3103 Box::pin(async move {
3104 record(&log, "shutdown:foundation");
3105 Ok(())
3106 })
3107 }
3108 }
3109
3110 #[tokio::test]
3111 async fn untyped_instances_share_an_adapter_type_and_remain_manifest_keyed() {
3112 let log = Arc::new(Mutex::new(Vec::new()));
3113 let foundation: Arc<dyn UntypedClientPlugin> = Arc::new(RuntimePluginAdapter {
3114 id: "runtime-foundation",
3115 dependency: None,
3116 log: Arc::clone(&log),
3117 });
3118 let client = complete_builder()
3119 .await
3120 .with_untyped_plugin(RuntimePluginAdapter {
3121 id: "runtime-dependent",
3122 dependency: Some("runtime-foundation"),
3123 log: Arc::clone(&log),
3124 })
3125 .with_untyped_plugin_arc(foundation)
3126 .build()
3127 .await
3128 .expect("untyped plugin plan")
3129 .into_client();
3130
3131 assert_eq!(
3132 client
3133 .plugin_manifests()
3134 .iter()
3135 .map(PluginManifest::id)
3136 .collect::<Vec<_>>(),
3137 vec!["runtime-foundation", "runtime-dependent"]
3138 );
3139 assert_eq!(
3140 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
3141 vec!["install:runtime-foundation", "install:runtime-dependent"]
3142 );
3143
3144 client.disconnect().await;
3145 assert_eq!(
3146 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
3147 vec![
3148 "install:runtime-foundation",
3149 "install:runtime-dependent",
3150 "shutdown:runtime-dependent",
3151 "shutdown:runtime-foundation"
3152 ]
3153 );
3154 }
3155
3156 #[tokio::test]
3157 async fn shutdown_during_upstream_install_prevents_plugin_installation() {
3158 let log = Arc::new(Mutex::new(Vec::new()));
3159 let result = complete_builder()
3160 .await
3161 .with_lifecycle(ShutdownDuringPluginInstall)
3162 .with_plugin(FoundationPlugin { log: log.clone() })
3163 .build()
3164 .await;
3165
3166 assert!(matches!(result, Err(ClientBuilderError::PluginInstall(_))));
3167 assert!(
3168 log.lock()
3169 .unwrap_or_else(|poisoned| poisoned.into_inner())
3170 .is_empty()
3171 );
3172 }
3173
3174 #[tokio::test]
3175 async fn upstream_install_failure_runs_partial_rollback() {
3176 let log = Arc::new(Mutex::new(Vec::new()));
3177 let result = complete_builder()
3178 .await
3179 .with_lifecycle(FailingInstallLifecycle {
3180 log: Arc::clone(&log),
3181 })
3182 .with_plugin(FoundationPlugin {
3183 log: Arc::clone(&log),
3184 })
3185 .build()
3186 .await;
3187
3188 assert!(matches!(result, Err(ClientBuilderError::PluginInstall(_))));
3189 assert_eq!(
3190 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
3191 vec!["install:failing-upstream", "shutdown:failing-upstream"]
3192 );
3193 }
3194
3195 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3196 async fn plugin_surfaces_publish_only_after_final_activation() {
3197 let (client_tx, client_rx) = async_channel::bounded(1);
3198 let (entered_tx, entered_rx) = async_channel::bounded(1);
3199 let release = Arc::new(Barrier::new(2));
3200 let builder = complete_builder()
3201 .await
3202 .with_runtime(BlockingFirstSpawnRuntime {
3203 blocked: AtomicBool::new(false),
3204 entered: entered_tx,
3205 release: Arc::clone(&release),
3206 })
3207 .with_lifecycle(CaptureInstallClient { client: client_tx })
3208 .with_plugin(PublicationProbePlugin);
3209
3210 let build = tokio::spawn(async move { builder.build().await });
3211 let leaked_client = client_rx
3212 .recv()
3213 .await
3214 .expect("captured install client")
3215 .upgrade()
3216 .expect("client under construction");
3217 entered_rx.recv().await.expect("client service startup");
3218
3219 assert!(leaked_client.plugin::<PublicationProbePlugin>().is_none());
3220 assert!(leaked_client.plugin_manifests().is_empty());
3221 assert!(leaked_client.plugin_stats().is_none());
3222 assert!(leaked_client.plugin_event_router().is_none());
3223
3224 release.wait();
3225 let client = build
3226 .await
3227 .expect("builder task")
3228 .expect("successful build")
3229 .into_client();
3230 assert_eq!(
3231 client
3232 .plugin::<PublicationProbePlugin>()
3233 .as_deref()
3234 .map(String::as_str),
3235 Some("published-api")
3236 );
3237 assert_eq!(client.plugin_manifests().len(), 1);
3238 assert!(client.plugin_stats().is_some());
3239 assert!(client.plugin_event_router().is_some());
3240 client.disconnect().await;
3241 }
3242
3243 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3244 async fn rejected_construction_never_publishes_staged_plugin_surfaces() {
3245 let (client_tx, client_rx) = async_channel::bounded(1);
3246 let (entered_tx, entered_rx) = async_channel::bounded(1);
3247 let release = Arc::new(Barrier::new(2));
3248 let builder = complete_builder()
3249 .await
3250 .with_runtime(BlockingFirstSpawnRuntime {
3251 blocked: AtomicBool::new(false),
3252 entered: entered_tx,
3253 release: Arc::clone(&release),
3254 })
3255 .with_lifecycle(CaptureInstallClient { client: client_tx })
3256 .with_plugin(PublicationProbePlugin);
3257
3258 let build = tokio::spawn(async move { builder.build().await });
3259 let leaked_client = client_rx
3260 .recv()
3261 .await
3262 .expect("captured install client")
3263 .upgrade()
3264 .expect("client under construction");
3265 entered_rx.recv().await.expect("client service startup");
3266 leaked_client.signal_shutdown_sync();
3267
3268 assert!(leaked_client.plugin::<PublicationProbePlugin>().is_none());
3269 assert!(leaked_client.plugin_manifests().is_empty());
3270 assert!(leaked_client.plugin_stats().is_none());
3271 assert!(leaked_client.plugin_event_router().is_none());
3272
3273 release.wait();
3274 assert!(matches!(
3275 build.await.expect("builder task"),
3276 Err(ClientBuilderError::PluginInstall(_))
3277 ));
3278 assert!(leaked_client.plugin::<PublicationProbePlugin>().is_none());
3279 assert!(leaked_client.plugin_manifests().is_empty());
3280 assert!(leaked_client.plugin_stats().is_none());
3281 assert!(leaked_client.plugin_event_router().is_none());
3282 }
3283
3284 #[tokio::test]
3285 async fn shutdown_cancels_an_inflight_plugin_install_and_closes_its_resources() {
3286 let (client_tx, client_rx) = async_channel::bounded(1);
3287 let (started_tx, started_rx) = async_channel::bounded(1);
3288 let install_dropped = Arc::new(AtomicBool::new(false));
3289 let shutdown_called = Arc::new(AtomicBool::new(false));
3290 let builder = complete_builder()
3291 .await
3292 .with_lifecycle(CaptureInstallClient { client: client_tx })
3293 .with_plugin(TerminalBlockingInstallPlugin {
3294 started: started_tx,
3295 install_dropped: install_dropped.clone(),
3296 shutdown_called: shutdown_called.clone(),
3297 });
3298
3299 let build = tokio::spawn(async move { builder.build().await });
3300 let client = client_rx
3301 .recv()
3302 .await
3303 .expect("captured install client")
3304 .upgrade()
3305 .expect("client under construction");
3306 let resource_shutdown = started_rx.recv().await.expect("plugin install started");
3307
3308 client.signal_shutdown_sync();
3309 assert!(resource_shutdown.is_fired());
3310 let result = tokio::time::timeout(Duration::from_secs(2), build)
3311 .await
3312 .expect("plugin install ignored terminal shutdown")
3313 .expect("build task");
3314
3315 assert!(matches!(result, Err(ClientBuilderError::PluginInstall(_))));
3316 assert!(install_dropped.load(Ordering::Acquire));
3317 assert!(shutdown_called.load(Ordering::Acquire));
3318 drop(client);
3319 }
3320
3321 #[tokio::test]
3322 async fn install_timeout_rolls_back_the_partial_plugin() {
3323 let (started_tx, started_rx) = async_channel::unbounded();
3324 let install_dropped = Arc::new(AtomicBool::new(false));
3325 let shutdown_called = Arc::new(AtomicBool::new(false));
3326 let result = complete_builder()
3327 .await
3328 .with_plugin_host_config(
3329 PluginHostConfig::new().with_install_timeout(Duration::from_millis(10)),
3330 )
3331 .with_plugin(TerminalBlockingInstallPlugin {
3332 started: started_tx,
3333 install_dropped: install_dropped.clone(),
3334 shutdown_called: shutdown_called.clone(),
3335 })
3336 .build()
3337 .await;
3338
3339 let resource_shutdown = started_rx.recv().await.expect("plugin install started");
3340 assert!(matches!(result, Err(ClientBuilderError::PluginInstall(_))));
3341 assert!(resource_shutdown.is_fired());
3342 assert!(install_dropped.load(Ordering::Acquire));
3343 assert!(shutdown_called.load(Ordering::Acquire));
3344 }
3345
3346 struct DependentPlugin {
3347 log: Log,
3348 }
3349
3350 impl ClientPlugin for DependentPlugin {
3351 type Api = String;
3352
3353 fn manifest(&self) -> PluginManifest {
3354 PluginManifest::new("dependent", "0.1.0").with_dependency("foundation")
3355 }
3356
3357 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3358 let log = self.log.clone();
3359 Box::pin(async move {
3360 let foundation = context
3361 .plugin::<FoundationPlugin>()
3362 .ok_or_else(|| anyhow::anyhow!("foundation API is unavailable"))?;
3363 anyhow::ensure!(&*foundation == "foundation-api");
3364 record(&log, "install:dependent");
3365 Ok(Arc::new("dependent-api".to_string()))
3366 })
3367 }
3368
3369 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3370 let log = self.log.clone();
3371 Box::pin(async move {
3372 record(&log, "shutdown:dependent");
3373 Ok(())
3374 })
3375 }
3376 }
3377
3378 #[tokio::test]
3379 async fn installs_in_dependency_order_and_indexes_by_marker_type() {
3380 let log = Arc::new(Mutex::new(Vec::new()));
3381 let build = complete_builder()
3382 .await
3383 .with_plugin(DependentPlugin { log: log.clone() })
3384 .with_plugin(FoundationPlugin { log: log.clone() })
3385 .build()
3386 .await
3387 .expect("valid plugin plan");
3388 let client = build.into_client();
3389
3390 assert_eq!(
3391 client
3392 .plugin::<FoundationPlugin>()
3393 .as_deref()
3394 .map(String::as_str),
3395 Some("foundation-api")
3396 );
3397 assert_eq!(
3398 client
3399 .plugin::<DependentPlugin>()
3400 .as_deref()
3401 .map(String::as_str),
3402 Some("dependent-api")
3403 );
3404 assert_eq!(
3405 client
3406 .plugin_manifests()
3407 .iter()
3408 .map(PluginManifest::id)
3409 .collect::<Vec<_>>(),
3410 vec!["foundation", "dependent"]
3411 );
3412 assert_eq!(
3413 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
3414 vec!["install:foundation", "install:dependent"]
3415 );
3416
3417 client.disconnect().await;
3418 assert_eq!(
3419 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
3420 vec![
3421 "install:foundation",
3422 "install:dependent",
3423 "shutdown:dependent",
3424 "shutdown:foundation"
3425 ]
3426 );
3427 }
3428
3429 struct DeclarativePlugin<const MARKER: u8> {
3430 id: &'static str,
3431 dependency: Option<&'static str>,
3432 }
3433
3434 struct TransitiveProbe;
3435
3436 impl ClientPlugin for TransitiveProbe {
3437 type Api = bool;
3438
3439 fn manifest(&self) -> PluginManifest {
3440 PluginManifest::new("transitive-probe", "0.1.0").with_dependency("dependent")
3441 }
3442
3443 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3444 Box::pin(async move {
3445 anyhow::ensure!(context.plugin::<DependentPlugin>().is_some());
3446 Ok(Arc::new(context.plugin::<FoundationPlugin>().is_none()))
3447 })
3448 }
3449 }
3450
3451 #[tokio::test]
3452 async fn install_context_exposes_only_direct_declared_dependencies() {
3453 let log = Arc::new(Mutex::new(Vec::new()));
3454 let build = complete_builder()
3455 .await
3456 .with_plugin(FoundationPlugin { log: log.clone() })
3457 .with_plugin(DependentPlugin { log })
3458 .with_plugin(TransitiveProbe)
3459 .build()
3460 .await
3461 .expect("declared dependency plan");
3462 let client = build.into_client();
3463 assert_eq!(client.plugin::<TransitiveProbe>().as_deref(), Some(&true));
3464 client.disconnect().await;
3465 }
3466
3467 impl<const MARKER: u8> ClientPlugin for DeclarativePlugin<MARKER> {
3468 type Api = ();
3469
3470 fn manifest(&self) -> PluginManifest {
3471 let manifest = PluginManifest::new(self.id, "0.1.0");
3472 match self.dependency {
3473 Some(dependency) => manifest.with_dependency(dependency),
3474 None => manifest,
3475 }
3476 }
3477
3478 fn install(
3479 &self,
3480 _context: PluginContext,
3481 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3482 Box::pin(async { Ok(Arc::new(())) })
3483 }
3484 }
3485
3486 #[test]
3487 fn rejects_duplicate_ids_missing_dependencies_and_cycles() {
3488 let duplicate = PluginPlan::prepare(vec![
3489 PluginRegistration::new(DeclarativePlugin::<1> {
3490 id: "same",
3491 dependency: None,
3492 }),
3493 PluginRegistration::new(DeclarativePlugin::<2> {
3494 id: "same",
3495 dependency: None,
3496 }),
3497 ]);
3498 assert!(matches!(
3499 duplicate,
3500 Err(PluginPlanError::DuplicateId { ref id }) if id == "same"
3501 ));
3502
3503 let missing = PluginPlan::prepare(vec![PluginRegistration::new(DeclarativePlugin::<3> {
3504 id: "orphan",
3505 dependency: Some("absent"),
3506 })]);
3507 assert!(matches!(
3508 missing,
3509 Err(PluginPlanError::MissingDependency {
3510 ref plugin_id,
3511 ref dependency,
3512 }) if plugin_id == "orphan" && dependency == "absent"
3513 ));
3514
3515 let cycle = PluginPlan::prepare(vec![
3516 PluginRegistration::new(DeclarativePlugin::<4> {
3517 id: "cycle-a",
3518 dependency: Some("cycle-b"),
3519 }),
3520 PluginRegistration::new(DeclarativePlugin::<5> {
3521 id: "cycle-b",
3522 dependency: Some("cycle-a"),
3523 }),
3524 ]);
3525 assert!(matches!(
3526 cycle,
3527 Err(PluginPlanError::DependencyCycle { ref plugins })
3528 if plugins == &["cycle-a", "cycle-b"]
3529 ));
3530 }
3531
3532 struct FixedManifestPlugin<const MARKER: u8>(PluginManifest);
3533
3534 impl<const MARKER: u8> ClientPlugin for FixedManifestPlugin<MARKER> {
3535 type Api = ();
3536
3537 fn manifest(&self) -> PluginManifest {
3538 self.0.clone()
3539 }
3540
3541 fn install(
3542 &self,
3543 _context: PluginContext,
3544 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3545 Box::pin(async { Ok(Arc::new(())) })
3546 }
3547 }
3548
3549 struct PanickingManifestPlugin;
3550
3551 impl ClientPlugin for PanickingManifestPlugin {
3552 type Api = ();
3553
3554 fn manifest(&self) -> PluginManifest {
3555 panic!("injected manifest panic")
3556 }
3557
3558 fn install(
3559 &self,
3560 _context: PluginContext,
3561 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3562 Box::pin(async { Ok(Arc::new(())) })
3563 }
3564 }
3565
3566 #[test]
3567 fn rejects_invalid_or_ambiguous_manifests_without_installing() {
3568 let duplicate_type = PluginPlan::prepare(vec![
3569 PluginRegistration::new(FixedManifestPlugin::<1>(PluginManifest::new(
3570 "first", "0.1.0",
3571 ))),
3572 PluginRegistration::new(FixedManifestPlugin::<1>(PluginManifest::new(
3573 "second", "0.1.0",
3574 ))),
3575 ]);
3576 assert!(matches!(
3577 duplicate_type,
3578 Err(PluginPlanError::DuplicateType { .. })
3579 ));
3580
3581 let invalid_id = PluginPlan::prepare(vec![PluginRegistration::new(
3582 FixedManifestPlugin::<2>(PluginManifest::new("Invalid", "0.1.0")),
3583 )]);
3584 assert!(matches!(invalid_id, Err(PluginPlanError::InvalidId { .. })));
3585
3586 let invalid_version = PluginPlan::prepare(vec![PluginRegistration::new(
3587 FixedManifestPlugin::<3>(PluginManifest::new("invalid-version", "0.1 0")),
3588 )]);
3589 assert!(matches!(
3590 invalid_version,
3591 Err(PluginPlanError::InvalidVersion { .. })
3592 ));
3593
3594 let duplicate_dependency = PluginPlan::prepare(vec![
3595 PluginRegistration::new(FixedManifestPlugin::<4>(PluginManifest::new(
3596 "base", "0.1.0",
3597 ))),
3598 PluginRegistration::new(FixedManifestPlugin::<5>(
3599 PluginManifest::new("duplicate-dependency", "0.1.0")
3600 .with_dependency("base")
3601 .with_dependency("base"),
3602 )),
3603 ]);
3604 assert!(matches!(
3605 duplicate_dependency,
3606 Err(PluginPlanError::DuplicateDependency { .. })
3607 ));
3608
3609 let manifest_panic =
3610 PluginPlan::prepare(vec![PluginRegistration::new(PanickingManifestPlugin)]);
3611 assert!(matches!(
3612 manifest_panic,
3613 Err(PluginPlanError::ManifestPanicked { .. })
3614 ));
3615 }
3616
3617 struct DropFlag(Arc<AtomicBool>);
3618
3619 impl Drop for DropFlag {
3620 fn drop(&mut self) {
3621 self.0.store(true, Ordering::Release);
3622 }
3623 }
3624
3625 struct PendingDropPanic;
3626
3627 impl Future for PendingDropPanic {
3628 type Output = ();
3629
3630 fn poll(
3631 self: Pin<&mut Self>,
3632 _context: &mut std::task::Context<'_>,
3633 ) -> std::task::Poll<Self::Output> {
3634 std::task::Poll::Pending
3635 }
3636 }
3637
3638 impl Drop for PendingDropPanic {
3639 fn drop(&mut self) {
3640 panic!("injected task cancellation panic");
3641 }
3642 }
3643
3644 struct PanickingDropApi;
3645
3646 impl Drop for PanickingDropApi {
3647 fn drop(&mut self) {
3648 panic!("injected API drop panic");
3649 }
3650 }
3651
3652 struct PanickingDropPlugin {
3653 shutdown_called: Arc<AtomicBool>,
3654 }
3655
3656 impl ClientPlugin for PanickingDropPlugin {
3657 type Api = PanickingDropApi;
3658
3659 fn manifest(&self) -> PluginManifest {
3660 PluginManifest::new("panicking-drop", "0.1.0")
3661 }
3662
3663 fn install(
3664 &self,
3665 _context: PluginContext,
3666 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3667 Box::pin(async { Ok(Arc::new(PanickingDropApi)) })
3668 }
3669
3670 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3671 let shutdown_called = self.shutdown_called.clone();
3672 Box::pin(async move {
3673 shutdown_called.store(true, Ordering::Release);
3674 Ok(())
3675 })
3676 }
3677 }
3678
3679 #[tokio::test]
3680 async fn panicking_staged_api_drop_cannot_strand_rollback_completion() {
3681 let shutdown_called = Arc::new(AtomicBool::new(false));
3682 let plugin = Arc::new(PanickingDropPlugin {
3683 shutdown_called: shutdown_called.clone(),
3684 });
3685 let manifest = plugin.manifest();
3686 let erased_plugin: Arc<dyn ErasedClientPlugin> = Arc::new(PluginAdapter(plugin));
3687 let resources = PluginResources::new();
3688 let registry = Arc::new(ApiRegistry::default());
3689 let api: ErasedApi = Arc::new(TypedApi(Arc::new(PanickingDropApi)));
3690 registry.insert(TypeId::of::<PanickingDropPlugin>(), api);
3691
3692 let mut rollback =
3693 PluginInstallRollback::new(Arc::new(TokioRuntime), 1, PluginHostConfig::default());
3694 rollback.installed.push(InstalledPlugin {
3695 plugin: erased_plugin,
3696 manifest,
3697 resources,
3698 diagnostics: PluginDiagnostics::new(),
3699 });
3700 rollback.staged_apis = Some(registry);
3701
3702 tokio::time::timeout(Duration::from_secs(2), rollback.rollback())
3703 .await
3704 .expect("panicking API drop stranded rollback completion");
3705 assert!(shutdown_called.load(Ordering::Acquire));
3706 }
3707
3708 struct ContextRetainingApi {
3709 _context: PluginContext,
3710 _drop_flag: DropFlag,
3711 }
3712
3713 struct ContextRetainingPlugin {
3714 api_dropped: Arc<AtomicBool>,
3715 }
3716
3717 impl ClientPlugin for ContextRetainingPlugin {
3718 type Api = ContextRetainingApi;
3719
3720 fn manifest(&self) -> PluginManifest {
3721 PluginManifest::new("context-retaining", "0.1.0")
3722 }
3723
3724 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3725 let api_dropped = self.api_dropped.clone();
3726 Box::pin(async move {
3727 Ok(Arc::new(ContextRetainingApi {
3728 _context: context,
3729 _drop_flag: DropFlag(api_dropped),
3730 }))
3731 })
3732 }
3733 }
3734
3735 #[tokio::test]
3736 async fn retained_context_does_not_cycle_with_the_api_registry() {
3737 let api_dropped = Arc::new(AtomicBool::new(false));
3738 let build = complete_builder()
3739 .await
3740 .with_plugin(ContextRetainingPlugin {
3741 api_dropped: api_dropped.clone(),
3742 })
3743 .build()
3744 .await
3745 .expect("context-retaining plugin");
3746 let (client, sync_tasks) = build.into_parts();
3747 drop(sync_tasks);
3748 let api = client
3749 .plugin::<ContextRetainingPlugin>()
3750 .expect("retained-context API");
3751 let weak_api = Arc::downgrade(&api);
3752 drop(api);
3753
3754 client.disconnect().await;
3755 drop(client);
3756 wait_for_flag(&api_dropped).await;
3757 assert!(weak_api.upgrade().is_none());
3758 }
3759
3760 struct RollbackPlugin {
3761 log: Log,
3762 task_dropped: Arc<AtomicBool>,
3763 api_dropped: Arc<AtomicBool>,
3764 }
3765
3766 struct RollbackApi {
3767 _drop_flag: DropFlag,
3768 }
3769
3770 impl ClientPlugin for RollbackPlugin {
3771 type Api = RollbackApi;
3772
3773 fn manifest(&self) -> PluginManifest {
3774 PluginManifest::new("rollback", "0.1.0").with_capability(PluginCapability::Tasks)
3775 }
3776
3777 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3778 let log = self.log.clone();
3779 let task_dropped = self.task_dropped.clone();
3780 let api_dropped = self.api_dropped.clone();
3781 Box::pin(async move {
3782 record(&log, "install:rollback");
3783 let guard = DropFlag(task_dropped);
3784 context
3785 .tasks()
3786 .ok_or_else(|| anyhow::anyhow!("tasks capability missing"))?
3787 .spawn(async move {
3788 let _guard = guard;
3789 futures::future::pending::<()>().await;
3790 })?;
3791 Ok(Arc::new(RollbackApi {
3792 _drop_flag: DropFlag(api_dropped),
3793 }))
3794 })
3795 }
3796
3797 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3798 let log = self.log.clone();
3799 let task_dropped = self.task_dropped.clone();
3800 let api_dropped = self.api_dropped.clone();
3801 Box::pin(async move {
3802 anyhow::ensure!(
3803 task_dropped.load(Ordering::Acquire),
3804 "rollback task still running during shutdown"
3805 );
3806 anyhow::ensure!(
3807 !api_dropped.load(Ordering::Acquire),
3808 "rollback API dropped before shutdown"
3809 );
3810 record(&log, "shutdown:rollback");
3811 Ok(())
3812 })
3813 }
3814 }
3815
3816 struct FailingPlugin {
3817 log: Log,
3818 }
3819
3820 impl ClientPlugin for FailingPlugin {
3821 type Api = ();
3822
3823 fn manifest(&self) -> PluginManifest {
3824 PluginManifest::new("failing", "0.1.0").with_dependency("rollback")
3825 }
3826
3827 fn install(
3828 &self,
3829 _context: PluginContext,
3830 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3831 let log = self.log.clone();
3832 Box::pin(async move {
3833 record(&log, "install:failing");
3834 anyhow::bail!("injected failure")
3835 })
3836 }
3837
3838 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3839 let log = self.log.clone();
3840 Box::pin(async move {
3841 record(&log, "shutdown:failing");
3842 Ok(())
3843 })
3844 }
3845 }
3846
3847 #[tokio::test]
3848 async fn install_failure_rolls_back_resources_and_plugins_in_lifo_order() {
3849 let log = Arc::new(Mutex::new(Vec::new()));
3850 let task_dropped = Arc::new(AtomicBool::new(false));
3851 let api_dropped = Arc::new(AtomicBool::new(false));
3852 let result = complete_builder()
3853 .await
3854 .with_lifecycle(UpstreamLifecycle { log: log.clone() })
3855 .with_plugin(FailingPlugin { log: log.clone() })
3856 .with_plugin(RollbackPlugin {
3857 log: log.clone(),
3858 task_dropped: task_dropped.clone(),
3859 api_dropped: api_dropped.clone(),
3860 })
3861 .build()
3862 .await;
3863
3864 assert!(matches!(result, Err(ClientBuilderError::PluginInstall(_))));
3865 assert_eq!(
3866 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
3867 vec![
3868 "install:upstream",
3869 "install:rollback",
3870 "install:failing",
3871 "shutdown:failing",
3872 "shutdown:rollback",
3873 "shutdown:upstream"
3874 ]
3875 );
3876 tokio::time::timeout(Duration::from_secs(1), async {
3877 while !task_dropped.load(Ordering::Acquire) {
3878 tokio::task::yield_now().await;
3879 }
3880 })
3881 .await
3882 .expect("rollback aborted the install-scoped task");
3883 assert!(api_dropped.load(Ordering::Acquire));
3884 }
3885
3886 struct BlockingFailingPlugin {
3887 log: Log,
3888 started: async_channel::Sender<()>,
3889 release: async_channel::Receiver<()>,
3890 }
3891
3892 impl ClientPlugin for BlockingFailingPlugin {
3893 type Api = ();
3894
3895 fn manifest(&self) -> PluginManifest {
3896 PluginManifest::new("blocking-failure", "0.1.0").with_dependency("rollback")
3897 }
3898
3899 fn install(
3900 &self,
3901 _context: PluginContext,
3902 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
3903 let log = self.log.clone();
3904 Box::pin(async move {
3905 record(&log, "install:blocking-failure");
3906 anyhow::bail!("injected failure")
3907 })
3908 }
3909
3910 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3911 let log = self.log.clone();
3912 let started = self.started.clone();
3913 let release = self.release.clone();
3914 Box::pin(async move {
3915 record(&log, "shutdown:blocking-failure-started");
3916 let _ = started.try_send(());
3917 let _ = release.recv().await;
3918 record(&log, "shutdown:blocking-failure-finished");
3919 Ok(())
3920 })
3921 }
3922 }
3923
3924 struct SignalAwareUpstream {
3925 log: Log,
3926 signalled: Arc<AtomicBool>,
3927 shutdown_saw_signal: Arc<AtomicBool>,
3928 }
3929
3930 impl ClientLifecycle for SignalAwareUpstream {
3931 fn install(&self, _client: Weak<Client>) -> BoxFuture<'_, anyhow::Result<()>> {
3932 let log = self.log.clone();
3933 Box::pin(async move {
3934 record(&log, "install:upstream");
3935 Ok(())
3936 })
3937 }
3938
3939 fn signal_shutdown(&self) {
3940 if !self.signalled.swap(true, Ordering::AcqRel) {
3941 record(&self.log, "signal:upstream");
3942 }
3943 }
3944
3945 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
3946 let log = self.log.clone();
3947 let signalled = self.signalled.clone();
3948 let shutdown_saw_signal = self.shutdown_saw_signal.clone();
3949 Box::pin(async move {
3950 shutdown_saw_signal.store(signalled.load(Ordering::Acquire), Ordering::Release);
3951 record(&log, "shutdown:upstream");
3952 Ok(())
3953 })
3954 }
3955 }
3956
3957 #[tokio::test]
3958 async fn cancelled_explicit_rollback_finishes_detached_and_signals_upstream() {
3959 let log = Arc::new(Mutex::new(Vec::new()));
3960 let task_dropped = Arc::new(AtomicBool::new(false));
3961 let api_dropped = Arc::new(AtomicBool::new(false));
3962 let signalled = Arc::new(AtomicBool::new(false));
3963 let shutdown_saw_signal = Arc::new(AtomicBool::new(false));
3964 let (started_tx, started_rx) = async_channel::bounded(1);
3965 let (release_tx, release_rx) = async_channel::bounded(1);
3966 let builder = complete_builder()
3967 .await
3968 .with_lifecycle(SignalAwareUpstream {
3969 log: log.clone(),
3970 signalled: signalled.clone(),
3971 shutdown_saw_signal: shutdown_saw_signal.clone(),
3972 })
3973 .with_plugin(BlockingFailingPlugin {
3974 log: log.clone(),
3975 started: started_tx,
3976 release: release_rx,
3977 })
3978 .with_plugin(RollbackPlugin {
3979 log: log.clone(),
3980 task_dropped: task_dropped.clone(),
3981 api_dropped: api_dropped.clone(),
3982 });
3983
3984 let build = tokio::spawn(async move { builder.build().await });
3985 started_rx
3986 .recv()
3987 .await
3988 .expect("failed plugin rollback started");
3989 assert!(signalled.load(Ordering::Acquire));
3990 build.abort();
3991 let _ = build.await;
3992 release_tx.send(()).await.expect("release rollback hook");
3993
3994 tokio::time::timeout(Duration::from_secs(1), async {
3995 loop {
3996 let complete = log
3997 .lock()
3998 .unwrap_or_else(|poisoned| poisoned.into_inner())
3999 .last()
4000 .is_some_and(|entry| entry == "shutdown:upstream");
4001 if complete
4002 && task_dropped.load(Ordering::Acquire)
4003 && api_dropped.load(Ordering::Acquire)
4004 {
4005 break;
4006 }
4007 tokio::task::yield_now().await;
4008 }
4009 })
4010 .await
4011 .expect("detached rollback completed after build cancellation");
4012
4013 assert!(shutdown_saw_signal.load(Ordering::Acquire));
4014 assert_eq!(
4015 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4016 vec![
4017 "install:upstream",
4018 "install:rollback",
4019 "install:blocking-failure",
4020 "signal:upstream",
4021 "shutdown:blocking-failure-started",
4022 "shutdown:blocking-failure-finished",
4023 "shutdown:rollback",
4024 "shutdown:upstream"
4025 ]
4026 );
4027 }
4028
4029 struct BlockingInstallPlugin {
4030 log: Log,
4031 started: async_channel::Sender<()>,
4032 release: async_channel::Receiver<()>,
4033 }
4034
4035 impl ClientPlugin for BlockingInstallPlugin {
4036 type Api = ();
4037
4038 fn manifest(&self) -> PluginManifest {
4039 PluginManifest::new("blocking-install", "0.1.0").with_dependency("rollback")
4040 }
4041
4042 fn install(
4043 &self,
4044 _context: PluginContext,
4045 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
4046 let log = self.log.clone();
4047 let started = self.started.clone();
4048 let release = self.release.clone();
4049 Box::pin(async move {
4050 record(&log, "install:blocking");
4051 let _ = started.try_send(());
4052 release.recv().await?;
4053 Ok(Arc::new(()))
4054 })
4055 }
4056
4057 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
4058 let log = self.log.clone();
4059 Box::pin(async move {
4060 record(&log, "shutdown:blocking");
4061 Ok(())
4062 })
4063 }
4064 }
4065
4066 #[tokio::test]
4067 async fn cancelled_build_closes_resources_and_schedules_lifo_rollback() {
4068 let log = Arc::new(Mutex::new(Vec::new()));
4069 let task_dropped = Arc::new(AtomicBool::new(false));
4070 let api_dropped = Arc::new(AtomicBool::new(false));
4071 let (started_tx, started_rx) = async_channel::bounded(1);
4072 let (_release_tx, release_rx) = async_channel::bounded(1);
4073 let builder = complete_builder()
4074 .await
4075 .with_plugin(BlockingInstallPlugin {
4076 log: log.clone(),
4077 started: started_tx,
4078 release: release_rx,
4079 })
4080 .with_plugin(RollbackPlugin {
4081 log: log.clone(),
4082 task_dropped: task_dropped.clone(),
4083 api_dropped: api_dropped.clone(),
4084 });
4085
4086 let build = tokio::spawn(async move { builder.build().await });
4087 started_rx.recv().await.expect("blocking install started");
4088 build.abort();
4089 let _ = build.await;
4090
4091 tokio::time::timeout(Duration::from_secs(1), async {
4092 loop {
4093 let complete = log
4094 .lock()
4095 .unwrap_or_else(|poisoned| poisoned.into_inner())
4096 .last()
4097 .is_some_and(|entry| entry == "shutdown:rollback");
4098 if complete
4099 && task_dropped.load(Ordering::Acquire)
4100 && api_dropped.load(Ordering::Acquire)
4101 {
4102 break;
4103 }
4104 tokio::task::yield_now().await;
4105 }
4106 })
4107 .await
4108 .expect("cancelled build rollback completed");
4109
4110 assert_eq!(
4111 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4112 vec![
4113 "install:rollback",
4114 "install:blocking",
4115 "shutdown:blocking",
4116 "shutdown:rollback"
4117 ]
4118 );
4119 }
4120
4121 struct PanickingPlugin {
4122 log: Log,
4123 }
4124
4125 impl ClientPlugin for PanickingPlugin {
4126 type Api = ();
4127
4128 fn manifest(&self) -> PluginManifest {
4129 PluginManifest::new("panicking", "0.1.0").with_dependency("rollback")
4130 }
4131
4132 fn install(
4133 &self,
4134 _context: PluginContext,
4135 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
4136 record(&self.log, "install:panicking");
4137 panic!("injected install panic")
4138 }
4139
4140 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
4141 let log = self.log.clone();
4142 Box::pin(async move {
4143 record(&log, "shutdown:panicking");
4144 Ok(())
4145 })
4146 }
4147 }
4148
4149 #[tokio::test]
4150 async fn install_panic_isolated_and_rolled_back() {
4151 let log = Arc::new(Mutex::new(Vec::new()));
4152 let result = complete_builder()
4153 .await
4154 .with_plugin(PanickingPlugin { log: log.clone() })
4155 .with_plugin(RollbackPlugin {
4156 log: log.clone(),
4157 task_dropped: Arc::new(AtomicBool::new(false)),
4158 api_dropped: Arc::new(AtomicBool::new(false)),
4159 })
4160 .build()
4161 .await;
4162
4163 assert!(matches!(result, Err(ClientBuilderError::PluginInstall(_))));
4164 assert_eq!(
4165 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4166 vec![
4167 "install:rollback",
4168 "install:panicking",
4169 "shutdown:panicking",
4170 "shutdown:rollback"
4171 ]
4172 );
4173 }
4174
4175 struct ScopedTaskPlugin {
4176 install_started: Arc<AtomicBool>,
4177 install_dropped: Arc<AtomicBool>,
4178 connection_started: Arc<AtomicBool>,
4179 connection_dropped: Arc<AtomicBool>,
4180 closed_after_task: Arc<AtomicBool>,
4181 shutdown_after_task: Arc<AtomicBool>,
4182 }
4183
4184 struct CooperativeTaskPlugin {
4185 install_started: Arc<AtomicBool>,
4186 install_finished: Arc<AtomicBool>,
4187 connection_started: Arc<AtomicBool>,
4188 connection_finished: Arc<AtomicBool>,
4189 closed_after_task: Arc<AtomicBool>,
4190 shutdown_after_task: Arc<AtomicBool>,
4191 }
4192
4193 impl ClientPlugin for CooperativeTaskPlugin {
4194 type Api = ();
4195
4196 fn manifest(&self) -> PluginManifest {
4197 PluginManifest::new("cooperative-tasks", "0.1.0")
4198 .with_capability(PluginCapability::Tasks)
4199 }
4200
4201 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
4202 let tasks = context
4203 .tasks()
4204 .cloned()
4205 .ok_or_else(|| anyhow::anyhow!("tasks capability missing"));
4206 let started = Arc::clone(&self.install_started);
4207 let finished = Arc::clone(&self.install_finished);
4208 Box::pin(async move {
4209 let tasks = tasks?;
4210 let shutdown = tasks.shutdown_signal();
4211 tasks.spawn_cooperative(async move {
4212 started.store(true, Ordering::Release);
4213 wait_for_shutdown(&shutdown).await;
4214 tokio::task::yield_now().await;
4215 finished.store(true, Ordering::Release);
4216 })?;
4217 Ok(Arc::new(()))
4218 })
4219 }
4220
4221 fn on_ready(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
4222 let tasks = scope
4223 .tasks()
4224 .cloned()
4225 .ok_or_else(|| anyhow::anyhow!("connection tasks capability missing"));
4226 let started = Arc::clone(&self.connection_started);
4227 let finished = Arc::clone(&self.connection_finished);
4228 Box::pin(async move {
4229 let tasks = tasks?;
4230 let cancelled = tasks.cancellation_signal();
4231 tasks.spawn_cooperative(async move {
4232 started.store(true, Ordering::Release);
4233 wait_for_shutdown(&cancelled).await;
4234 tokio::task::yield_now().await;
4235 finished.store(true, Ordering::Release);
4236 })?;
4237 Ok(())
4238 })
4239 }
4240
4241 fn on_closed(&self, _scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
4242 let finished = self.connection_finished.load(Ordering::Acquire);
4243 let observed = Arc::clone(&self.closed_after_task);
4244 Box::pin(async move {
4245 observed.store(finished, Ordering::Release);
4246 Ok(())
4247 })
4248 }
4249
4250 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
4251 let finished = self.install_finished.load(Ordering::Acquire);
4252 let observed = Arc::clone(&self.shutdown_after_task);
4253 Box::pin(async move {
4254 observed.store(finished, Ordering::Release);
4255 Ok(())
4256 })
4257 }
4258 }
4259
4260 struct TimedDrainPlugin {
4261 started: Arc<AtomicBool>,
4262 finished: Arc<AtomicBool>,
4263 release_tx: async_channel::Sender<()>,
4264 release_rx: async_channel::Receiver<()>,
4265 }
4266
4267 impl ClientPlugin for TimedDrainPlugin {
4268 type Api = ();
4269
4270 fn manifest(&self) -> PluginManifest {
4271 PluginManifest::new("timed-drain", "0.1.0").with_capability(PluginCapability::Tasks)
4272 }
4273
4274 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
4275 let tasks = context
4276 .tasks()
4277 .cloned()
4278 .ok_or_else(|| anyhow::anyhow!("tasks capability missing"));
4279 let started = Arc::clone(&self.started);
4280 let finished = Arc::clone(&self.finished);
4281 let release = self.release_rx.clone();
4282 Box::pin(async move {
4283 tasks?.spawn_cooperative(async move {
4284 started.store(true, Ordering::Release);
4285 let _ = release.recv().await;
4286 finished.store(true, Ordering::Release);
4287 })?;
4288 Ok(Arc::new(()))
4289 })
4290 }
4291
4292 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
4293 let release = self.release_tx.clone();
4294 Box::pin(async move {
4295 let _ = release.try_send(());
4296 Ok(())
4297 })
4298 }
4299 }
4300
4301 impl ClientPlugin for ScopedTaskPlugin {
4302 type Api = ();
4303
4304 fn manifest(&self) -> PluginManifest {
4305 PluginManifest::new("scoped-tasks", "0.1.0").with_capability(PluginCapability::Tasks)
4306 }
4307
4308 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
4309 let started = self.install_started.clone();
4310 let observed = self.install_started.clone();
4311 let dropped = self.install_dropped.clone();
4312 Box::pin(async move {
4313 context
4314 .tasks()
4315 .ok_or_else(|| anyhow::anyhow!("tasks capability missing"))?
4316 .spawn(async move {
4317 started.store(true, Ordering::Release);
4318 let _guard = DropFlag(dropped);
4319 futures::future::pending::<()>().await;
4320 })?;
4321 tokio::task::yield_now().await;
4322 anyhow::ensure!(!observed.load(Ordering::Acquire));
4323 Ok(Arc::new(()))
4324 })
4325 }
4326
4327 fn on_ready(&self, scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
4328 let started = self.connection_started.clone();
4329 let dropped = self.connection_dropped.clone();
4330 Box::pin(async move {
4331 scope
4332 .tasks()
4333 .ok_or_else(|| anyhow::anyhow!("connection tasks capability missing"))?
4334 .spawn(async move {
4335 started.store(true, Ordering::Release);
4336 let _guard = DropFlag(dropped);
4337 futures::future::pending::<()>().await;
4338 })?;
4339 Ok(())
4340 })
4341 }
4342
4343 fn on_closed(&self, _scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
4344 let task_dropped = self.connection_dropped.load(Ordering::Acquire);
4345 let closed_after_task = self.closed_after_task.clone();
4346 Box::pin(async move {
4347 closed_after_task.store(task_dropped, Ordering::Release);
4348 Ok(())
4349 })
4350 }
4351
4352 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
4353 let task_dropped = self.install_dropped.load(Ordering::Acquire);
4354 let shutdown_after_task = self.shutdown_after_task.clone();
4355 Box::pin(async move {
4356 shutdown_after_task.store(task_dropped, Ordering::Release);
4357 Ok(())
4358 })
4359 }
4360 }
4361
4362 async fn wait_for_flag(flag: &AtomicBool) {
4363 tokio::time::timeout(Duration::from_secs(1), async {
4364 while !flag.load(Ordering::Acquire) {
4365 tokio::task::yield_now().await;
4366 }
4367 })
4368 .await
4369 .expect("task state transition");
4370 }
4371
4372 #[tokio::test]
4373 async fn install_tasks_start_after_publish_and_outlive_connection_tasks() {
4374 let install_started = Arc::new(AtomicBool::new(false));
4375 let install_dropped = Arc::new(AtomicBool::new(false));
4376 let connection_started = Arc::new(AtomicBool::new(false));
4377 let connection_dropped = Arc::new(AtomicBool::new(false));
4378 let closed_after_task = Arc::new(AtomicBool::new(false));
4379 let shutdown_after_task = Arc::new(AtomicBool::new(false));
4380 let build = complete_builder()
4381 .await
4382 .with_plugin(ScopedTaskPlugin {
4383 install_started: install_started.clone(),
4384 install_dropped: install_dropped.clone(),
4385 connection_started: connection_started.clone(),
4386 connection_dropped: connection_dropped.clone(),
4387 closed_after_task: closed_after_task.clone(),
4388 shutdown_after_task: shutdown_after_task.clone(),
4389 })
4390 .build()
4391 .await
4392 .expect("scoped task plugin");
4393 let client = build.into_client();
4394 wait_for_flag(&install_started).await;
4395 let host = client.plugin_host.as_ref().expect("plugin host").clone();
4396 let resources =
4397 Arc::clone(&host.installed.get().expect("installed plugins").plugins[0].resources);
4398 let stats = client.plugin_stats().expect("plugin stats");
4399 assert_eq!(stats.health, PluginHealth::Healthy);
4400 assert_eq!(stats.plugins[0].state, PluginState::Active);
4401 assert_eq!(stats.plugins[0].install_tasks, 1);
4402 assert_eq!(stats.plugins[0].connection_tasks, 0);
4403
4404 let scope = ConnectionScope::new(88);
4405 host.on_ready(scope.clone())
4406 .await
4407 .expect("plugin ready callback");
4408 wait_for_flag(&connection_started).await;
4409 let stats = client.plugin_stats().expect("ready plugin stats");
4410 assert_eq!(stats.plugins[0].install_tasks, 1);
4411 assert_eq!(stats.plugins[0].connection_tasks, 1);
4412 assert_eq!(stats.plugins[0].connection_generations, 1);
4413 assert_eq!(stats.plugins[0].callbacks_completed, 1);
4414 scope.cancel();
4415 wait_for_flag(&connection_dropped).await;
4416 tokio::time::timeout(Duration::from_secs(1), async {
4417 loop {
4418 let retained = resources
4419 .connection_tasks
4420 .lock()
4421 .unwrap_or_else(|poisoned| poisoned.into_inner())
4422 .trackers
4423 .contains_key(&scope.generation());
4424 if !retained {
4425 break;
4426 }
4427 tokio::task::yield_now().await;
4428 }
4429 })
4430 .await
4431 .expect("cancelled generation tracker retired without on_closed");
4432 host.on_closed(scope).await.expect("plugin closed callback");
4433 assert!(connection_dropped.load(Ordering::Acquire));
4434 assert!(closed_after_task.load(Ordering::Acquire));
4435 assert!(!install_dropped.load(Ordering::Acquire));
4436 let stats = client.plugin_stats().expect("closed plugin stats");
4437 assert_eq!(stats.plugins[0].connection_tasks, 0);
4438 assert_eq!(stats.plugins[0].connection_generations, 0);
4439 assert_eq!(stats.plugins[0].callbacks_completed, 2);
4440
4441 client.disconnect().await;
4442 assert!(install_dropped.load(Ordering::Acquire));
4443 assert!(shutdown_after_task.load(Ordering::Acquire));
4444 let stats = client.plugin_stats().expect("stopped plugin stats");
4445 assert_eq!(stats.plugins[0].state, PluginState::Stopped);
4446 assert_eq!(stats.plugins[0].install_tasks, 0);
4447 assert_eq!(stats.plugins[0].callbacks_completed, 3);
4448 }
4449
4450 #[tokio::test]
4451 async fn cooperative_tasks_drain_before_lifecycle_callbacks() {
4452 let install_started = Arc::new(AtomicBool::new(false));
4453 let install_finished = Arc::new(AtomicBool::new(false));
4454 let connection_started = Arc::new(AtomicBool::new(false));
4455 let connection_finished = Arc::new(AtomicBool::new(false));
4456 let closed_after_task = Arc::new(AtomicBool::new(false));
4457 let shutdown_after_task = Arc::new(AtomicBool::new(false));
4458 let config = PluginHostConfig::new()
4459 .with_callback_timeout(Duration::from_secs(1))
4460 .with_task_drain_timeout(Duration::from_secs(1));
4461 let client = complete_builder()
4462 .await
4463 .with_plugin_host_config(config)
4464 .with_plugin(CooperativeTaskPlugin {
4465 install_started: Arc::clone(&install_started),
4466 install_finished: Arc::clone(&install_finished),
4467 connection_started: Arc::clone(&connection_started),
4468 connection_finished: Arc::clone(&connection_finished),
4469 closed_after_task: Arc::clone(&closed_after_task),
4470 shutdown_after_task: Arc::clone(&shutdown_after_task),
4471 })
4472 .build()
4473 .await
4474 .expect("cooperative task plugin")
4475 .into_client();
4476 let host = client.plugin_host.as_ref().expect("plugin host").clone();
4477 assert_eq!(host.config, config);
4478 wait_for_flag(&install_started).await;
4479
4480 let scope = ConnectionScope::new(212);
4481 host.on_ready(scope.clone())
4482 .await
4483 .expect("cooperative ready callback");
4484 wait_for_flag(&connection_started).await;
4485 scope.cancel();
4486 host.on_closed(scope)
4487 .await
4488 .expect("cooperative closed callback");
4489 assert!(connection_finished.load(Ordering::Acquire));
4490 assert!(closed_after_task.load(Ordering::Acquire));
4491
4492 client.disconnect().await;
4493 assert!(install_finished.load(Ordering::Acquire));
4494 assert!(shutdown_after_task.load(Ordering::Acquire));
4495 let stats = client.plugin_stats().expect("cooperative plugin stats");
4496 assert_eq!(stats.plugins[0].task_drain_timeouts, 0);
4497 assert_eq!(stats.plugins[0].health, PluginHealth::Healthy);
4498 }
4499
4500 #[tokio::test]
4501 async fn configured_task_drain_timeout_degrades_and_continues_shutdown() {
4502 let started = Arc::new(AtomicBool::new(false));
4503 let finished = Arc::new(AtomicBool::new(false));
4504 let (release_tx, release_rx) = async_channel::bounded(1);
4505 let client = complete_builder()
4506 .await
4507 .with_plugin_host_config(
4508 PluginHostConfig::new()
4509 .with_callback_timeout(Duration::from_secs(1))
4510 .with_task_drain_timeout(Duration::from_millis(10)),
4511 )
4512 .with_plugin(TimedDrainPlugin {
4513 started: Arc::clone(&started),
4514 finished: Arc::clone(&finished),
4515 release_tx,
4516 release_rx,
4517 })
4518 .build()
4519 .await
4520 .expect("timed drain plugin")
4521 .into_client();
4522 wait_for_flag(&started).await;
4523
4524 client.disconnect().await;
4525 wait_for_flag(&finished).await;
4526 let stats = client.plugin_stats().expect("timed drain stats");
4527 assert_eq!(stats.plugins[0].task_drain_timeouts, 1);
4528 assert_eq!(stats.plugins[0].health, PluginHealth::Degraded);
4529 assert_eq!(stats.plugins[0].state, PluginState::Stopped);
4530 }
4531
4532 #[tokio::test]
4533 async fn connection_scoped_tasks_stop_when_the_generation_is_cancelled() {
4534 let scope = ConnectionScope::new(77);
4535 let task_dropped = Arc::new(AtomicBool::new(false));
4536 let tasks = PluginConnectionTasks {
4537 runtime: Arc::new(TokioRuntime),
4538 scope: scope.clone(),
4539 tracker: TaskTracker::new(),
4540 diagnostics: PluginDiagnostics::new(),
4541 plugin_id: Arc::from("connection-task-test"),
4542 };
4543 let guard = DropFlag(task_dropped.clone());
4544 tasks
4545 .spawn(async move {
4546 let _guard = guard;
4547 futures::future::pending::<()>().await;
4548 })
4549 .expect("open connection scope");
4550
4551 scope.cancel();
4552 tokio::time::timeout(Duration::from_secs(1), async {
4553 while !task_dropped.load(Ordering::Acquire) {
4554 tokio::task::yield_now().await;
4555 }
4556 })
4557 .await
4558 .expect("connection cancellation stopped the scoped task");
4559 assert!(matches!(
4560 tasks.spawn(async {}),
4561 Err(PluginResourceError::ShuttingDown)
4562 ));
4563 }
4564
4565 #[tokio::test]
4566 async fn spawned_task_panics_are_isolated_and_degrade_health() {
4567 let diagnostics = PluginDiagnostics::new();
4568 let plugin_id: Arc<str> = Arc::from("panicking-task-test");
4569 let resources = PluginResources::new();
4570 diagnostics.attach_resources(&resources);
4571 resources.activate();
4572 let install_tasks = PluginTasks {
4573 runtime: Arc::new(TokioRuntime),
4574 resources: resources.clone(),
4575 diagnostics: diagnostics.clone(),
4576 plugin_id: plugin_id.clone(),
4577 };
4578 install_tasks
4579 .spawn(async { panic!("injected install task panic") })
4580 .expect("spawn install task");
4581
4582 tokio::time::timeout(Duration::from_secs(1), async {
4583 while diagnostics.task_panics.load(Ordering::Relaxed) < 1 {
4584 tokio::task::yield_now().await;
4585 }
4586 })
4587 .await
4588 .expect("install task panic was not recorded");
4589 assert_eq!(resources.install_tasks.active(), 0);
4590
4591 let connection_scope = ConnectionScope::new(101);
4592 let connection_tracker = TaskTracker::new();
4593 let connection_tasks = PluginConnectionTasks {
4594 runtime: Arc::new(TokioRuntime),
4595 scope: connection_scope,
4596 tracker: connection_tracker.clone(),
4597 diagnostics: diagnostics.clone(),
4598 plugin_id: plugin_id.clone(),
4599 };
4600 connection_tasks
4601 .spawn(async { panic!("injected connection task panic") })
4602 .expect("spawn connection task");
4603
4604 tokio::time::timeout(Duration::from_secs(1), async {
4605 while diagnostics.task_panics.load(Ordering::Relaxed) < 2 {
4606 tokio::task::yield_now().await;
4607 }
4608 })
4609 .await
4610 .expect("connection task panic was not recorded");
4611 assert_eq!(connection_tracker.active(), 0);
4612
4613 let cancellation_scope = ConnectionScope::new(102);
4614 let cancellation_tracker = TaskTracker::new();
4615 let cancellation_tasks = PluginConnectionTasks {
4616 runtime: Arc::new(TokioRuntime),
4617 scope: cancellation_scope.clone(),
4618 tracker: cancellation_tracker.clone(),
4619 diagnostics: diagnostics.clone(),
4620 plugin_id,
4621 };
4622 cancellation_tasks
4623 .spawn(PendingDropPanic)
4624 .expect("spawn cancellation task");
4625 cancellation_scope.cancel();
4626
4627 tokio::time::timeout(Duration::from_secs(1), async {
4628 while diagnostics.task_panics.load(Ordering::Relaxed) < 3 {
4629 tokio::task::yield_now().await;
4630 }
4631 })
4632 .await
4633 .expect("task cancellation panic was not recorded");
4634 assert_eq!(cancellation_tracker.active(), 0);
4635
4636 let stats = diagnostics.snapshot("panicking-task-test", false, None);
4637 assert_eq!(stats.task_panics, 3);
4638 assert_eq!(stats.health, PluginHealth::Degraded);
4639 resources.close();
4640 }
4641
4642 #[tokio::test]
4643 async fn task_sleeps_return_when_their_owner_is_cancelled() {
4644 let resources = PluginResources::new();
4645 resources.activate();
4646 let install_tasks = PluginTasks {
4647 runtime: Arc::new(TokioRuntime),
4648 resources: resources.clone(),
4649 diagnostics: PluginDiagnostics::new(),
4650 plugin_id: Arc::from("install-sleep-test"),
4651 };
4652 let install_sleeper =
4653 tokio::spawn(async move { install_tasks.sleep(Duration::from_secs(60)).await });
4654 tokio::task::yield_now().await;
4655 resources.close();
4656 assert_eq!(
4657 tokio::time::timeout(Duration::from_secs(1), install_sleeper)
4658 .await
4659 .expect("install sleep cancellation")
4660 .expect("install sleeper task"),
4661 Err(PluginResourceError::ShuttingDown)
4662 );
4663
4664 let scope = ConnectionScope::new(91);
4665 let connection_tasks = PluginConnectionTasks {
4666 runtime: Arc::new(TokioRuntime),
4667 scope: scope.clone(),
4668 tracker: TaskTracker::new(),
4669 diagnostics: PluginDiagnostics::new(),
4670 plugin_id: Arc::from("connection-sleep-test"),
4671 };
4672 let connection_sleeper =
4673 tokio::spawn(async move { connection_tasks.sleep(Duration::from_secs(60)).await });
4674 tokio::task::yield_now().await;
4675 scope.cancel();
4676 assert_eq!(
4677 tokio::time::timeout(Duration::from_secs(1), connection_sleeper)
4678 .await
4679 .expect("connection sleep cancellation")
4680 .expect("connection sleeper task"),
4681 Err(PluginResourceError::ShuttingDown)
4682 );
4683 }
4684
4685 struct UpstreamLifecycle {
4686 log: Log,
4687 }
4688
4689 impl ClientLifecycle for UpstreamLifecycle {
4690 fn install(&self, _client: Weak<Client>) -> BoxFuture<'_, anyhow::Result<()>> {
4691 let log = self.log.clone();
4692 Box::pin(async move {
4693 record(&log, "install:upstream");
4694 Ok(())
4695 })
4696 }
4697
4698 fn shutdown(&self) -> BoxFuture<'_, anyhow::Result<()>> {
4699 let log = self.log.clone();
4700 Box::pin(async move {
4701 record(&log, "shutdown:upstream");
4702 Ok(())
4703 })
4704 }
4705 }
4706
4707 struct FailingReadyLifecycle;
4708
4709 impl ClientLifecycle for FailingReadyLifecycle {
4710 fn on_ready(&self, _scope: ConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
4711 Box::pin(async { anyhow::bail!("injected upstream ready failure") })
4712 }
4713 }
4714
4715 struct ReadyPlugin<const MARKER: u8> {
4716 id: &'static str,
4717 dependency: Option<&'static str>,
4718 log: Log,
4719 stalls: bool,
4720 }
4721
4722 impl<const MARKER: u8> ClientPlugin for ReadyPlugin<MARKER> {
4723 type Api = ();
4724
4725 fn manifest(&self) -> PluginManifest {
4726 let manifest = PluginManifest::new(self.id, "0.1.0");
4727 match self.dependency {
4728 Some(dependency) => manifest.with_dependency(dependency),
4729 None => manifest,
4730 }
4731 }
4732
4733 fn install(
4734 &self,
4735 _context: PluginContext,
4736 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
4737 Box::pin(async { Ok(Arc::new(())) })
4738 }
4739
4740 fn on_ready(&self, _scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
4741 let id = self.id;
4742 let log = self.log.clone();
4743 let stalls = self.stalls;
4744 Box::pin(async move {
4745 record(&log, format!("ready:{id}"));
4746 if stalls {
4747 futures::future::pending::<()>().await;
4748 }
4749 Ok(())
4750 })
4751 }
4752 }
4753
4754 struct DropPanickingReadyPlugin {
4755 log: Log,
4756 }
4757
4758 struct DropPanickingPendingFuture;
4759
4760 impl Future for DropPanickingPendingFuture {
4761 type Output = anyhow::Result<()>;
4762
4763 fn poll(
4764 self: Pin<&mut Self>,
4765 _context: &mut std::task::Context<'_>,
4766 ) -> std::task::Poll<Self::Output> {
4767 std::task::Poll::Pending
4768 }
4769 }
4770
4771 impl Drop for DropPanickingPendingFuture {
4772 fn drop(&mut self) {
4773 panic!("injected callback cancellation panic");
4774 }
4775 }
4776
4777 impl ClientPlugin for DropPanickingReadyPlugin {
4778 type Api = ();
4779
4780 fn manifest(&self) -> PluginManifest {
4781 PluginManifest::new("drop-panicking-ready", "0.1.0")
4782 }
4783
4784 fn install(
4785 &self,
4786 _context: PluginContext,
4787 ) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
4788 Box::pin(async { Ok(Arc::new(())) })
4789 }
4790
4791 fn on_ready(&self, _scope: PluginConnectionScope) -> BoxFuture<'_, anyhow::Result<()>> {
4792 record(&self.log, "ready:drop-panicking-ready");
4793 Box::pin(DropPanickingPendingFuture)
4794 }
4795 }
4796
4797 #[tokio::test]
4798 async fn upstream_ready_failure_does_not_suppress_plugins() {
4799 let log = Arc::new(Mutex::new(Vec::new()));
4800 let client = complete_builder()
4801 .await
4802 .with_lifecycle(FailingReadyLifecycle)
4803 .with_plugin(ReadyPlugin::<1> {
4804 id: "ready-probe",
4805 dependency: None,
4806 log: log.clone(),
4807 stalls: false,
4808 })
4809 .build()
4810 .await
4811 .expect("ready probe client")
4812 .into_client();
4813
4814 let result = client
4815 .plugin_host
4816 .as_ref()
4817 .expect("plugin host")
4818 .on_ready(ConnectionScope::new(91))
4819 .await;
4820 assert!(result.is_err());
4821 assert_eq!(
4822 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4823 vec!["ready:ready-probe"]
4824 );
4825 let stats = client.plugin_stats().expect("plugin host stats");
4826 assert_eq!(stats.health, PluginHealth::Degraded);
4827 assert_eq!(stats.upstream_callback_failures, 1);
4828 assert_eq!(stats.upstream_callback_timeouts, 0);
4829 assert_eq!(stats.plugins[0].health, PluginHealth::Healthy);
4830 client.disconnect().await;
4831 }
4832
4833 #[tokio::test]
4834 async fn timed_out_plugin_callback_does_not_suppress_following_plugins() {
4835 let log = Arc::new(Mutex::new(Vec::new()));
4836 let plan = PluginPlan::prepare(vec![
4837 PluginRegistration::new(ReadyPlugin::<2> {
4838 id: "stalling-ready",
4839 dependency: None,
4840 log: log.clone(),
4841 stalls: true,
4842 }),
4843 PluginRegistration::new(ReadyPlugin::<3> {
4844 id: "following-ready",
4845 dependency: Some("stalling-ready"),
4846 log: log.clone(),
4847 stalls: false,
4848 }),
4849 ])
4850 .expect("valid callback plan")
4851 .expect("non-empty callback plan");
4852 let host = PluginHost::new_with_callback_timeout(plan, None, Duration::from_millis(10));
4853 let client = complete_builder()
4854 .await
4855 .with_lifecycle_arc(host.clone())
4856 .build()
4857 .await
4858 .expect("callback timeout client")
4859 .into_client();
4860
4861 let result = host.on_ready(ConnectionScope::new(92)).await;
4862 assert!(result.is_err());
4863 assert_eq!(
4864 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4865 vec!["ready:stalling-ready", "ready:following-ready"]
4866 );
4867 let stats = host.stats();
4868 assert_eq!(stats.health, PluginHealth::Degraded);
4869 let stalling = stats
4870 .plugins
4871 .iter()
4872 .find(|plugin| plugin.plugin_id == "stalling-ready")
4873 .expect("stalling plugin stats");
4874 assert_eq!(stalling.health, PluginHealth::Degraded);
4875 assert_eq!(stalling.callback_timeouts, 1);
4876 assert_eq!(stalling.callback_failures, 0);
4877 let following = stats
4878 .plugins
4879 .iter()
4880 .find(|plugin| plugin.plugin_id == "following-ready")
4881 .expect("following plugin stats");
4882 assert_eq!(following.health, PluginHealth::Healthy);
4883 assert_eq!(following.callbacks_completed, 1);
4884 client.disconnect().await;
4885 }
4886
4887 #[tokio::test]
4888 async fn panicking_timeout_cancellation_does_not_suppress_following_plugins() {
4889 let log = Arc::new(Mutex::new(Vec::new()));
4890 let plan = PluginPlan::prepare(vec![
4891 PluginRegistration::new(DropPanickingReadyPlugin { log: log.clone() }),
4892 PluginRegistration::new(ReadyPlugin::<4> {
4893 id: "following-drop-panic",
4894 dependency: Some("drop-panicking-ready"),
4895 log: log.clone(),
4896 stalls: false,
4897 }),
4898 ])
4899 .expect("valid callback plan")
4900 .expect("non-empty callback plan");
4901 let host = PluginHost::new_with_callback_timeout(plan, None, Duration::from_millis(10));
4902 let client = complete_builder()
4903 .await
4904 .with_lifecycle_arc(host.clone())
4905 .build()
4906 .await
4907 .expect("callback cancellation client")
4908 .into_client();
4909
4910 let result = host.on_ready(ConnectionScope::new(93)).await;
4911 assert!(result.is_err());
4912 assert_eq!(
4913 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4914 vec!["ready:drop-panicking-ready", "ready:following-drop-panic"]
4915 );
4916 let stats = host.stats();
4917 let panicking = stats
4918 .plugins
4919 .iter()
4920 .find(|plugin| plugin.plugin_id == "drop-panicking-ready")
4921 .expect("drop-panicking plugin stats");
4922 assert_eq!(panicking.health, PluginHealth::Degraded);
4923 assert_eq!(panicking.callback_timeouts, 1);
4924 assert_eq!(panicking.callback_failures, 1);
4925 let following = stats
4926 .plugins
4927 .iter()
4928 .find(|plugin| plugin.plugin_id == "following-drop-panic")
4929 .expect("following plugin stats");
4930 assert_eq!(following.health, PluginHealth::Healthy);
4931 assert_eq!(following.callbacks_completed, 1);
4932 client.disconnect().await;
4933 }
4934
4935 #[tokio::test]
4936 async fn composes_existing_lifecycle_outside_plugin_lifo_order() {
4937 let log = Arc::new(Mutex::new(Vec::new()));
4938 let build = complete_builder()
4939 .await
4940 .with_lifecycle(UpstreamLifecycle { log: log.clone() })
4941 .with_plugin(FoundationPlugin { log: log.clone() })
4942 .build()
4943 .await
4944 .expect("composed lifecycle");
4945 let client = build.into_client();
4946 assert_eq!(
4947 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4948 vec!["install:upstream", "install:foundation"]
4949 );
4950
4951 client.disconnect().await;
4952 assert_eq!(
4953 *log.lock().unwrap_or_else(|poisoned| poisoned.into_inner()),
4954 vec![
4955 "install:upstream",
4956 "install:foundation",
4957 "shutdown:foundation",
4958 "shutdown:upstream"
4959 ]
4960 );
4961 }
4962
4963 struct NoopEventHandler;
4964
4965 impl EventHandler for NoopEventHandler {
4966 fn handle_event(&self, _event: Arc<wacore::types::events::Event>) {}
4967 }
4968
4969 struct EventSubscriptionPlugin;
4970
4971 struct PanickingDropEventHandler;
4972
4973 impl EventHandler for PanickingDropEventHandler {
4974 fn handle_event(&self, _event: Arc<wacore::types::events::Event>) {}
4975 }
4976
4977 impl Drop for PanickingDropEventHandler {
4978 fn drop(&mut self) {
4979 panic!("injected event handler drop panic");
4980 }
4981 }
4982
4983 struct PanickingCoreEventHandler;
4984
4985 impl EventHandler for PanickingCoreEventHandler {
4986 fn handle_event(&self, _event: Arc<wacore::types::events::Event>) {
4987 panic!("injected core-event handler panic");
4988 }
4989 }
4990
4991 struct PanickingCoreEventPlugin;
4992
4993 impl ClientPlugin for PanickingCoreEventPlugin {
4994 type Api = PluginCoreEventSubscription;
4995
4996 fn manifest(&self) -> PluginManifest {
4997 PluginManifest::new("panicking-core-event", "0.1.0")
4998 .with_capability(PluginCapability::CoreEvents)
4999 }
5000
5001 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
5002 Box::pin(async move {
5003 let subscription = context
5004 .core_events()
5005 .ok_or_else(|| anyhow::anyhow!("core events capability missing"))?
5006 .subscribe(
5007 EventInterest::of(&[EventKind::Connected]),
5008 Arc::new(PanickingCoreEventHandler),
5009 )?;
5010 Ok(Arc::new(subscription))
5011 })
5012 }
5013 }
5014
5015 struct PanickingSubscriptionPlugin;
5016
5017 impl ClientPlugin for PanickingSubscriptionPlugin {
5018 type Api = PluginCoreEventSubscription;
5019
5020 fn manifest(&self) -> PluginManifest {
5021 PluginManifest::new("panicking-subscription", "0.1.0")
5022 .with_capability(PluginCapability::CoreEvents)
5023 }
5024
5025 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
5026 Box::pin(async move {
5027 let subscription = context
5028 .core_events()
5029 .ok_or_else(|| anyhow::anyhow!("core events capability missing"))?
5030 .subscribe(
5031 EventInterest::of(&[EventKind::Connected]),
5032 Arc::new(PanickingDropEventHandler),
5033 )?;
5034 Ok(Arc::new(subscription))
5035 })
5036 }
5037 }
5038
5039 struct ShutdownSignalPlugin;
5040
5041 impl ClientPlugin for ShutdownSignalPlugin {
5042 type Api = ShutdownSignal;
5043
5044 fn manifest(&self) -> PluginManifest {
5045 PluginManifest::new("shutdown-signal", "0.1.0").with_capability(PluginCapability::Tasks)
5046 }
5047
5048 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
5049 Box::pin(async move {
5050 context
5051 .tasks()
5052 .map(PluginTasks::shutdown_signal)
5053 .map(Arc::new)
5054 .ok_or_else(|| anyhow::anyhow!("tasks capability missing"))
5055 })
5056 }
5057 }
5058
5059 struct ShutdownSignalLifecycle(Arc<AtomicBool>);
5060
5061 impl ClientLifecycle for ShutdownSignalLifecycle {
5062 fn signal_shutdown(&self) {
5063 self.0.store(true, Ordering::Release);
5064 }
5065 }
5066
5067 impl ClientPlugin for EventSubscriptionPlugin {
5068 type Api = PluginCoreEventSubscription;
5069
5070 fn manifest(&self) -> PluginManifest {
5071 PluginManifest::new("event-subscription", "0.1.0")
5072 .with_capability(PluginCapability::CoreEvents)
5073 }
5074
5075 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
5076 Box::pin(async move {
5077 let subscription = context
5078 .core_events()
5079 .ok_or_else(|| anyhow::anyhow!("core events capability missing"))?
5080 .subscribe(
5081 EventInterest::of(&[EventKind::Connected, EventKind::RawNode]),
5082 Arc::new(NoopEventHandler),
5083 )?;
5084 Ok(Arc::new(subscription))
5085 })
5086 }
5087 }
5088
5089 struct ReentrantSubscriptionHandler {
5090 events: PluginCoreEvents,
5091 }
5092
5093 impl EventHandler for ReentrantSubscriptionHandler {
5094 fn handle_event(&self, _event: Arc<wacore::types::events::Event>) {}
5095 }
5096
5097 impl Drop for ReentrantSubscriptionHandler {
5098 fn drop(&mut self) {
5099 let _ = self.events.subscribe(
5100 EventInterest::of(&[EventKind::Connected]),
5101 Arc::new(NoopEventHandler),
5102 );
5103 }
5104 }
5105
5106 struct ReentrantSubscriptionPlugin;
5107
5108 struct ReentrantSubscriptionApi {
5109 events: PluginCoreEvents,
5110 _subscription: PluginCoreEventSubscription,
5111 }
5112
5113 impl ClientPlugin for ReentrantSubscriptionPlugin {
5114 type Api = ReentrantSubscriptionApi;
5115
5116 fn manifest(&self) -> PluginManifest {
5117 PluginManifest::new("reentrant-subscription", "0.1.0")
5118 .with_capability(PluginCapability::CoreEvents)
5119 }
5120
5121 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
5122 Box::pin(async move {
5123 let events = context
5124 .core_events()
5125 .cloned()
5126 .ok_or_else(|| anyhow::anyhow!("core events capability missing"))?;
5127 let subscription = events.subscribe(
5128 EventInterest::of(&[EventKind::Connected]),
5129 Arc::new(ReentrantSubscriptionHandler {
5130 events: events.clone(),
5131 }),
5132 )?;
5133 Ok(Arc::new(ReentrantSubscriptionApi {
5134 events,
5135 _subscription: subscription,
5136 }))
5137 })
5138 }
5139 }
5140
5141 #[tokio::test]
5142 async fn shutdown_removes_plugin_event_subscriptions_and_raw_lease() {
5143 let build = complete_builder()
5144 .await
5145 .with_plugin(EventSubscriptionPlugin)
5146 .build()
5147 .await
5148 .expect("event subscription plugin");
5149 let client = build.into_client();
5150 assert!(client.core.event_bus.has_handler_for(EventKind::Connected));
5151 assert!(client.raw_node_forwarding_enabled());
5152
5153 client.disconnect().await;
5154 assert!(!client.core.event_bus.has_handler_for(EventKind::Connected));
5155 assert!(!client.raw_node_forwarding_enabled());
5156 }
5157
5158 #[tokio::test]
5159 async fn plugin_subscription_updates_interest_and_can_unsubscribe_early() {
5160 let client = complete_builder()
5161 .await
5162 .with_plugin(EventSubscriptionPlugin)
5163 .build()
5164 .await
5165 .expect("event subscription plugin")
5166 .into_client();
5167 let subscription = client
5168 .plugin::<EventSubscriptionPlugin>()
5169 .expect("subscription API");
5170
5171 assert!(subscription.is_active());
5172 assert!(subscription.interest().wants(EventKind::RawNode));
5173 assert!(client.raw_node_forwarding_enabled());
5174 assert!(
5175 subscription
5176 .update_interest(EventInterest::of(&[EventKind::Connected]))
5177 .expect("interest update")
5178 );
5179 assert!(!client.raw_node_forwarding_enabled());
5180 assert!(!subscription.interest().wants(EventKind::RawNode));
5181 assert!(client.core.event_bus.has_handler_for(EventKind::Connected));
5182 assert!(
5183 subscription
5184 .update_interest(EventInterest::of(&[EventKind::RawNode]))
5185 .expect("raw-node interest update")
5186 );
5187 assert!(client.raw_node_forwarding_enabled());
5188 assert!(!client.core.event_bus.has_handler_for(EventKind::Connected));
5189
5190 assert!(subscription.unsubscribe());
5191 assert!(!subscription.is_active());
5192 assert!(!subscription.unsubscribe());
5193 assert!(!client.raw_node_forwarding_enabled());
5194 assert!(!client.core.event_bus.has_handler_for(EventKind::Connected));
5195 assert_eq!(
5196 client
5197 .plugin_stats()
5198 .expect("plugin stats")
5199 .plugins
5200 .first()
5201 .expect("plugin stats entry")
5202 .core_event_subscriptions,
5203 0
5204 );
5205 client.disconnect().await;
5206 }
5207
5208 #[tokio::test]
5209 async fn dropped_plugin_subscriptions_leave_no_retained_registry_entries() {
5210 let client = complete_builder()
5211 .await
5212 .build()
5213 .await
5214 .expect("client")
5215 .into_client();
5216 let resources = PluginResources::new();
5217 resources.activate();
5218 let diagnostics = PluginDiagnostics::new();
5219 diagnostics.attach_resources(&resources);
5220 let events = PluginCoreEvents {
5221 client: Arc::downgrade(&client),
5222 resources: Arc::clone(&resources),
5223 plugin_id: Arc::from("subscription-churn"),
5224 diagnostics,
5225 };
5226
5227 let subscriptions = (0..128)
5228 .map(|_| {
5229 events
5230 .subscribe(
5231 EventInterest::of(&[EventKind::Connected]),
5232 Arc::new(NoopEventHandler),
5233 )
5234 .expect("subscription")
5235 })
5236 .collect::<Vec<_>>();
5237 let registrations = subscriptions
5238 .iter()
5239 .map(|subscription| Arc::downgrade(&subscription.inner))
5240 .collect::<Vec<_>>();
5241 assert_eq!(
5242 resources
5243 .subscriptions
5244 .lock()
5245 .unwrap_or_else(|poisoned| poisoned.into_inner())
5246 .len(),
5247 subscriptions.len()
5248 );
5249
5250 drop(subscriptions);
5251
5252 assert!(
5253 resources
5254 .subscriptions
5255 .lock()
5256 .unwrap_or_else(|poisoned| poisoned.into_inner())
5257 .is_empty()
5258 );
5259 assert!(
5260 registrations
5261 .into_iter()
5262 .all(|registration| registration.upgrade().is_none())
5263 );
5264 assert_eq!(resources.stats().core_event_subscriptions, 0);
5265 client.disconnect().await;
5266 }
5267
5268 #[tokio::test]
5269 async fn panicking_core_event_handler_is_isolated_and_degrades_only_its_plugin() {
5270 let client = complete_builder()
5271 .await
5272 .with_plugin(PanickingCoreEventPlugin)
5273 .with_plugin(ShutdownSignalPlugin)
5274 .build()
5275 .await
5276 .expect("panicking core-event client")
5277 .into_client();
5278
5279 let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
5280 client
5281 .core
5282 .event_bus
5283 .dispatch(wacore::types::events::Event::Connected(
5284 wacore::types::events::Connected::builder().build(),
5285 ));
5286 }));
5287
5288 assert!(result.is_ok());
5289 let stats = client.plugin_stats().expect("plugin stats");
5290 assert_eq!(stats.health, PluginHealth::Degraded);
5291 let panicking = stats
5292 .plugins
5293 .iter()
5294 .find(|plugin| plugin.plugin_id == "panicking-core-event")
5295 .expect("panicking plugin stats");
5296 assert_eq!(panicking.health, PluginHealth::Degraded);
5297 assert_eq!(panicking.core_event_panics, 1);
5298 assert_eq!(panicking.core_events_delivered, 0);
5299 let unaffected = stats
5300 .plugins
5301 .iter()
5302 .find(|plugin| plugin.plugin_id == "shutdown-signal")
5303 .expect("unaffected plugin stats");
5304 assert_eq!(unaffected.health, PluginHealth::Healthy);
5305 client.disconnect().await;
5306 }
5307
5308 #[test]
5309 fn delayed_plugin_handler_drop_is_isolated_and_counted() {
5310 let resources = PluginResources::new();
5311 let diagnostics = PluginDiagnostics::new();
5312 diagnostics.attach_resources(&resources);
5313 let handler = Arc::new(PluginCoreEventHandler {
5314 plugin_id: Arc::from("delayed-drop"),
5315 inner: Some(Arc::new(PanickingDropEventHandler)),
5316 resources: Arc::downgrade(&resources),
5317 diagnostics,
5318 });
5319 let delayed_snapshot = handler.clone();
5320 drop(handler);
5321
5322 let result = std::panic::catch_unwind(AssertUnwindSafe(|| drop(delayed_snapshot)));
5323
5324 assert!(result.is_ok());
5325 assert_eq!(resources.stats().teardown_panics, 1);
5326 }
5327
5328 #[tokio::test]
5329 async fn panicking_handler_drop_does_not_strand_later_plugins_or_upstream() {
5330 let upstream_signalled = Arc::new(AtomicBool::new(false));
5331 let client = complete_builder()
5332 .await
5333 .with_lifecycle(ShutdownSignalLifecycle(upstream_signalled.clone()))
5334 .with_plugin(ShutdownSignalPlugin)
5335 .with_plugin(PanickingSubscriptionPlugin)
5336 .build()
5337 .await
5338 .expect("panicking subscription client")
5339 .into_client();
5340 let plugin_shutdown = client
5341 .plugin::<ShutdownSignalPlugin>()
5342 .expect("shutdown signal API");
5343
5344 let result = std::panic::catch_unwind(AssertUnwindSafe(|| client.signal_shutdown_sync()));
5345
5346 assert!(result.is_ok());
5347 assert!(plugin_shutdown.is_fired());
5348 assert!(upstream_signalled.load(Ordering::Acquire));
5349 let stats = client.plugin_stats().expect("plugin stats");
5350 let panicking = stats
5351 .plugins
5352 .iter()
5353 .find(|plugin| plugin.plugin_id == "panicking-subscription")
5354 .expect("panicking plugin stats");
5355 assert_eq!(panicking.health, PluginHealth::Degraded);
5356 assert_eq!(panicking.resource_teardown_panics, 1);
5357 client.disconnect().await;
5358 }
5359
5360 #[tokio::test]
5361 async fn resource_close_drops_reentrant_handlers_outside_the_subscription_lock() {
5362 let client = complete_builder()
5363 .await
5364 .with_plugin(ReentrantSubscriptionPlugin)
5365 .build()
5366 .await
5367 .expect("reentrant subscription plugin")
5368 .into_client();
5369 let shutdown_client = client.clone();
5370 let (completed_tx, completed_rx) = std::sync::mpsc::sync_channel(1);
5371 let shutdown = std::thread::spawn(move || {
5372 shutdown_client.signal_shutdown_sync();
5373 let _ = completed_tx.send(());
5374 });
5375
5376 completed_rx
5377 .recv_timeout(Duration::from_secs(2))
5378 .expect("reentrant handler teardown must not deadlock");
5379 shutdown.join().expect("shutdown thread");
5380 client.disconnect().await;
5381 }
5382
5383 #[tokio::test]
5384 async fn rejected_subscription_drops_reentrant_handler_outside_the_subscription_lock() {
5385 let client = complete_builder()
5386 .await
5387 .with_plugin(ReentrantSubscriptionPlugin)
5388 .build()
5389 .await
5390 .expect("reentrant subscription plugin")
5391 .into_client();
5392 let events = client
5393 .plugin::<ReentrantSubscriptionPlugin>()
5394 .expect("plugin event API");
5395 client.signal_shutdown_sync();
5396
5397 let (completed_tx, completed_rx) = std::sync::mpsc::sync_channel(1);
5398 let subscribe_events = events.clone();
5399 let subscribe = std::thread::spawn(move || {
5400 let result = subscribe_events.events.subscribe(
5401 EventInterest::of(&[EventKind::Connected]),
5402 Arc::new(ReentrantSubscriptionHandler {
5403 events: subscribe_events.events.clone(),
5404 }),
5405 );
5406 let _ = completed_tx.send(result);
5407 });
5408
5409 let result = completed_rx
5410 .recv_timeout(Duration::from_secs(2))
5411 .expect("rejected reentrant subscription must not deadlock");
5412 assert!(matches!(result, Err(PluginResourceError::ShuttingDown)));
5413 subscribe.join().expect("subscription thread");
5414 client.disconnect().await;
5415 }
5416
5417 #[tokio::test]
5418 async fn synchronous_shutdown_closes_plugin_resources_with_live_client_refs() {
5419 let task_dropped = Arc::new(AtomicBool::new(false));
5420 let client = complete_builder()
5421 .await
5422 .with_plugin(RollbackPlugin {
5423 log: Arc::new(Mutex::new(Vec::new())),
5424 task_dropped: task_dropped.clone(),
5425 api_dropped: Arc::new(AtomicBool::new(false)),
5426 })
5427 .with_plugin(EventSubscriptionPlugin)
5428 .build()
5429 .await
5430 .expect("plugin resource client")
5431 .into_client();
5432 let retained_client = client.clone();
5433
5434 client.signal_shutdown_sync();
5435 wait_for_flag(&task_dropped).await;
5436
5437 assert!(!retained_client.raw_node_forwarding_enabled());
5438 assert!(
5439 !retained_client
5440 .core
5441 .event_bus
5442 .has_handler_for(EventKind::Connected)
5443 );
5444 retained_client.disconnect().await;
5445 }
5446
5447 struct CapabilityProbe;
5448
5449 impl ClientPlugin for CapabilityProbe {
5450 type Api = [bool; 5];
5451
5452 fn manifest(&self) -> PluginManifest {
5453 PluginManifest::new("capability-probe", "0.1.0")
5454 .with_capability(PluginCapability::Messaging)
5455 }
5456
5457 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
5458 Box::pin(async move {
5459 Ok(Arc::new([
5460 context.core_events().is_some(),
5461 context.tasks().is_some(),
5462 context.messaging().is_some(),
5463 context.iq().is_some(),
5464 context.plugin_events().is_some(),
5465 ]))
5466 })
5467 }
5468 }
5469
5470 #[tokio::test]
5471 async fn context_exposes_only_declared_capabilities() {
5472 let build = complete_builder()
5473 .await
5474 .with_plugin(CapabilityProbe)
5475 .build()
5476 .await
5477 .expect("capability plugin");
5478 let client = build.into_client();
5479 assert_eq!(
5480 client.plugin::<CapabilityProbe>().as_deref(),
5481 Some(&[false, false, true, false, false])
5482 );
5483 assert!(client.plugin_event_router().is_none());
5484 client.disconnect().await;
5485 }
5486
5487 struct PluginEventPublisher;
5488
5489 impl ClientPlugin for PluginEventPublisher {
5490 type Api = PluginEvents;
5491
5492 fn manifest(&self) -> PluginManifest {
5493 PluginManifest::new("event-publisher", "0.1.0")
5494 .with_capability(PluginCapability::PluginEvents)
5495 }
5496
5497 fn install(&self, context: PluginContext) -> BoxFuture<'_, anyhow::Result<Arc<Self::Api>>> {
5498 Box::pin(async move {
5499 context
5500 .plugin_events()
5501 .cloned()
5502 .map(Arc::new)
5503 .ok_or_else(|| anyhow::anyhow!("plugin events capability missing"))
5504 })
5505 }
5506 }
5507
5508 #[tokio::test]
5509 async fn typed_plugin_api_publishes_only_to_exact_bounded_routes() {
5510 let client = complete_builder()
5511 .await
5512 .with_plugin(PluginEventPublisher)
5513 .with_plugin(CapabilityProbe)
5514 .build()
5515 .await
5516 .expect("plugin event publisher")
5517 .into_client();
5518 let publisher = client
5519 .plugin::<PluginEventPublisher>()
5520 .expect("typed publisher API");
5521 let router = client.plugin_event_router().expect("plugin event router");
5522 let tick = PluginEventTopic::new("tick").expect("valid topic");
5523 let silent_selector =
5524 PluginEventSelector::new("capability-probe", tick.clone()).expect("valid selector");
5525 assert!(matches!(
5526 router.subscribe(
5527 [silent_selector],
5528 PluginEventEndpointConfig::new(1, PluginEventOverflow::DropNewest),
5529 ),
5530 Err(PluginEventSubscribeError::UnknownPublisher { .. })
5531 ));
5532 let selector = publisher.selector(&tick);
5533 let subscription = router
5534 .subscribe(
5535 [selector.clone()],
5536 PluginEventEndpointConfig::new(1, PluginEventOverflow::DropNewest),
5537 )
5538 .expect("bounded event endpoint");
5539
5540 assert!(publisher.has_subscribers(&tick));
5541 const TICK_PAYLOAD: &[u8] = br#"{"messages":1}"#;
5542 let generation = client.connection_generation.load(Ordering::Acquire);
5543 assert_eq!(
5544 publisher
5545 .publish(
5546 &tick,
5547 2,
5548 PluginEventPayloadEncoding::Json,
5549 Bytes::from_static(TICK_PAYLOAD),
5550 )
5551 .expect("publish tick"),
5552 PluginEventPublishReport {
5553 matched: 1,
5554 enqueued: 1,
5555 dropped: 0,
5556 closed: 0,
5557 }
5558 );
5559 let stats = client.plugin_stats().expect("plugin host stats");
5560 assert_eq!(stats.health, PluginHealth::Healthy);
5561 let publisher_stats = stats
5562 .plugins
5563 .iter()
5564 .find(|plugin| plugin.plugin_id == "event-publisher")
5565 .expect("publisher stats");
5566 assert_eq!(publisher_stats.state, PluginState::Active);
5567 assert_eq!(publisher_stats.events.expect("event stats").published, 1);
5568 let memory = client.memory_report().await;
5569 assert_eq!(memory.plugins, 2);
5570 assert_eq!(memory.plugin_event_endpoints, 1);
5571 assert_eq!(memory.plugin_event_endpoint_capacity, 1);
5572 assert_eq!(memory.plugin_event_queue.entries, 1);
5573 assert_eq!(
5574 memory.plugin_event_queue.bytes,
5575 u64::try_from(TICK_PAYLOAD.len()).expect("payload length")
5576 );
5577 assert!(memory.total_estimated_bytes() >= memory.plugin_event_queue.bytes);
5578 let event = subscription.recv().await.expect("routed tick");
5579 assert_eq!(&*event.plugin_id, "event-publisher");
5580 assert_eq!(event.topic, tick);
5581 assert_eq!(event.schema_version, 2);
5582 assert_eq!(event.payload_encoding, PluginEventPayloadEncoding::Json);
5583 assert_eq!(event.payload, Bytes::from_static(TICK_PAYLOAD));
5584 assert_eq!(event.connection_generation, generation);
5585 assert_eq!(event.sequence, 1);
5586
5587 let next_generation = client.connection_generation.fetch_add(1, Ordering::SeqCst) + 1;
5588 publisher
5589 .publish(&tick, 2, PluginEventPayloadEncoding::Json, Bytes::new())
5590 .expect("publish after generation change");
5591 let event = subscription.recv().await.expect("next generation tick");
5592 assert_eq!(event.connection_generation, next_generation);
5593 assert_eq!(event.sequence, 2);
5594
5595 client.disconnect().await;
5596 assert!(matches!(
5597 publisher.publish(&tick, 2, PluginEventPayloadEncoding::Json, Bytes::new(),),
5598 Err(PluginEventPublishError::Resource(
5599 PluginResourceError::ShuttingDown
5600 ))
5601 ));
5602 assert!(matches!(
5603 subscription.recv().await,
5604 Err(PluginEventReceiveError)
5605 ));
5606 assert!(matches!(
5607 router.subscribe(
5608 [selector],
5609 PluginEventEndpointConfig::new(1, PluginEventOverflow::DropNewest),
5610 ),
5611 Err(PluginEventSubscribeError::Closed)
5612 ));
5613 let stats = client.plugin_stats().expect("terminal plugin stats");
5614 assert_eq!(stats.health, PluginHealth::Degraded);
5615 let publisher_stats = stats
5616 .plugins
5617 .iter()
5618 .find(|plugin| plugin.plugin_id == "event-publisher")
5619 .expect("terminal publisher stats");
5620 assert_eq!(publisher_stats.state, PluginState::Stopped);
5621 assert_eq!(publisher_stats.health, PluginHealth::Degraded);
5622 assert_eq!(
5623 publisher_stats.events,
5624 Some(PluginEventPublisherStats {
5625 published: 2,
5626 publish_failures: 1,
5627 matched: 2,
5628 enqueued: 2,
5629 delivered: 2,
5630 dropped: 0,
5631 closed: 0,
5632 })
5633 );
5634 let router_stats = stats.event_router.expect("terminal router stats");
5635 assert_eq!(router_stats.active_endpoints, 0);
5636 assert_eq!(router_stats.queued_events, 0);
5637 assert_eq!(router_stats.delivered, 2);
5638 }
5639}