Skip to main content

whatsapp_rust/plugins/
mod.rs

1//! Build-time client plugins and their capability-scoped host.
2
3mod 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/// A capability a plugin asks the host to expose during installation.
49#[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/// Compact set of capabilities requested by one plugin.
82#[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/// Deadlines applied by the native plugin host.
98#[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    /// Bound each plugin and upstream lifecycle installation.
115    pub const fn with_install_timeout(mut self, timeout: Duration) -> Self {
116        self.install_timeout = timeout;
117        self
118    }
119
120    /// Bound each `on_ready`, `on_closed`, and `shutdown` callback.
121    pub const fn with_callback_timeout(mut self, timeout: Duration) -> Self {
122        self.callback_timeout = timeout;
123        self
124    }
125
126    /// Bound each install- or connection-scoped task drain.
127    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/// Build-time declaration used for validation, ordering, and future foreign adapters.
152#[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
198/// Target-correct future returned by native plugin entry points.
199pub type PluginFuture<'a, T> = BoxFuture<'a, T>;
200
201/// A trusted native plugin installed exactly once while the client is still inert.
202/// Capabilities shape the handles it receives; they are not an in-process sandbox.
203/// A plugin value belongs to one client installation, even when registered through an `Arc`.
204pub 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    /// Release plugin-owned state. This may run after `install` began but returned an error.
220    fn shutdown(&self) -> PluginFuture<'_, anyhow::Result<()>> {
221        Box::pin(async { Ok(()) })
222    }
223}
224
225/// A trusted plugin instance identified only by its manifest ID.
226///
227/// Unlike [`ClientPlugin`], this trait publishes no Rust type-indexed API, so
228/// multiple instances of the same adapter type may be registered. It is the
229/// intended host seam for runtime-defined or foreign-language plugins.
230pub 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/// Manifest validation or dependency-ordering failure.
249#[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/// Capability use after the client or plugin scope has ended.
277#[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/// Lifecycle state of one installed plugin.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310#[non_exhaustive]
311pub enum PluginState {
312    Installing,
313    Active,
314    ShuttingDown,
315    /// The bounded shutdown attempt completed; health and task counts show incomplete cleanup.
316    Stopped,
317}
318
319/// Sticky health derived from cumulative host and event-router failures.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321#[non_exhaustive]
322pub enum PluginHealth {
323    Healthy,
324    Degraded,
325}
326
327/// On-demand runtime snapshot for one plugin, identified only by its public manifest ID.
328#[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    /// Lifecycle hooks that returned successfully.
335    pub callbacks_completed: u64,
336    /// Lifecycle hook errors and isolated panics.
337    pub callback_failures: u64,
338    pub callback_timeouts: u64,
339    pub task_drain_timeouts: u64,
340    /// Spawned workers that panicked while running or being cancelled.
341    pub task_panics: u64,
342    /// Core-event handler calls that returned without panicking.
343    pub core_events_delivered: u64,
344    /// Panics isolated before they could unwind through the client's event dispatcher.
345    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/// On-demand aggregate for the native plugin host.
355#[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/// Ownership token for one plugin core-event subscription.
567///
568/// Dropping the token unsubscribes immediately. Host shutdown also invalidates
569/// a retained token, so keeping it in a plugin API cannot extend client work.
570#[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    /// Replace the filter while preserving the handler registration.
585    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    /// Remove the handler now instead of waiting for `Drop`.
594    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(&registration));
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        // Lifecycle queue pressure may discard on_closed, so retirement follows cancellation.
730        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/// Install-scoped task capability. Work starts after the complete plugin set is published and
997/// stops during rollback or shutdown.
998#[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    /// Track work that must observe [`shutdown_signal`](Self::shutdown_signal)
1015    /// and finish itself after shutdown is signalled.
1016    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    /// Sleep through the configured runtime, returning promptly when this plugin shuts down.
1052    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/// Selective subscription access to the sealed core event bus.
1064/// Handlers run inline and must hand slow work to a task capability.
1065#[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/// High-level message sending without exposing the raw client or backend.
1152#[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/// Typed IQ execution without exposing the raw client or stores.
1187#[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
1207/// Capabilities and already-installed dependencies visible during installation.
1208pub 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    /// Return a declared dependency without making retained contexts own it.
1224    /// Clone the returned API during installation if it must outlive this call.
1225    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/// One connection generation plus its optional connection-scoped task capability.
1252#[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/// Task capability whose cancellation is signalled synchronously when its generation retires.
1281#[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    /// Track work that must observe [`cancellation_signal`](Self::cancellation_signal)
1299    /// and finish itself after this generation is cancelled.
1300    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    /// Sleep through the configured runtime, returning promptly when this generation retires.
1336    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(&current.manifest.id, &current.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    /// Return the API exposed by plugin marker `P`, if that plugin was installed.
2780    pub fn plugin<P: ClientPlugin>(&self) -> Option<Arc<P::Api>> {
2781        self.plugin_host.as_ref()?.plugin::<P>()
2782    }
2783
2784    /// Manifests in dependency-resolved installation order.
2785    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    /// Snapshot lifecycle, task, subscription, and custom-event health for installed plugins.
2794    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    /// Subscribe to custom events emitted by installed plugins.
2802    ///
2803    /// Returns `None` when no manifest requested custom-event publication.
2804    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}