Skip to main content

turbovault_plugin_api/
lifecycle.rs

1use std::sync::Arc;
2
3use tokio::sync::watch;
4
5/// Cooperative shutdown signal handed to a plugin's background work.
6///
7/// A plugin that maintains derived state generally wants a worker: something
8/// that drains the hook bus, re-embeds changed notes, compacts an index. That
9/// work outlives any single tool call, so it needs a way to learn the host is
10/// going away — otherwise a detached task keeps running against state that is
11/// being torn down, and the process cannot exit cleanly.
12///
13/// Cheap to clone; every clone observes the same signal.
14#[derive(Clone)]
15pub struct ShutdownSignal {
16    receiver: watch::Receiver<bool>,
17}
18
19impl std::fmt::Debug for ShutdownSignal {
20    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        formatter
22            .debug_struct("ShutdownSignal")
23            .field("is_shutting_down", &self.is_shutting_down())
24            .finish()
25    }
26}
27
28impl ShutdownSignal {
29    /// Whether shutdown has already been requested.
30    ///
31    /// Check this at the top of a work loop; use [`Self::cancelled`] to wait.
32    pub fn is_shutting_down(&self) -> bool {
33        *self.receiver.borrow()
34    }
35
36    /// Resolve when shutdown is requested, immediately if it already was.
37    ///
38    /// Select on this alongside the work a loop would otherwise block on:
39    ///
40    /// ```ignore
41    /// loop {
42    ///     tokio::select! {
43    ///         _ = shutdown.cancelled() => break,
44    ///         event = events.recv() => { /* ... */ }
45    ///     }
46    /// }
47    /// ```
48    pub async fn cancelled(&self) {
49        let mut receiver = self.receiver.clone();
50        // `borrow_and_update` marks the current value seen, so the wait below
51        // reacts to the next change rather than an already-observed one.
52        if *receiver.borrow_and_update() {
53            return;
54        }
55        // A send error means the host dropped the trigger without signalling,
56        // which can only happen if the host is already gone — treat it as
57        // shutdown rather than waiting forever.
58        let _ = receiver.changed().await;
59    }
60}
61
62/// Host-side handle that requests shutdown.
63///
64/// Held by the server, never handed across the plugin boundary.
65pub struct ShutdownTrigger {
66    sender: Arc<watch::Sender<bool>>,
67    /// Keeps the channel alive when no plugin holds a signal.
68    ///
69    /// Without a live receiver the state would be lost, and a shutdown
70    /// requested before any worker started — an early failure during startup,
71    /// say — would leave a worker that starts afterwards waiting forever for a
72    /// signal that already fired.
73    _keepalive: Arc<watch::Receiver<bool>>,
74}
75
76impl std::fmt::Debug for ShutdownTrigger {
77    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        formatter
79            .debug_struct("ShutdownTrigger")
80            .finish_non_exhaustive()
81    }
82}
83
84impl Default for ShutdownTrigger {
85    fn default() -> Self {
86        Self::new()
87    }
88}
89
90impl ShutdownTrigger {
91    /// Create a trigger that has not yet fired.
92    pub fn new() -> Self {
93        let (sender, keepalive) = watch::channel(false);
94        Self {
95            sender: Arc::new(sender),
96            _keepalive: Arc::new(keepalive),
97        }
98    }
99
100    /// Hand out a signal observing this trigger.
101    pub fn signal(&self) -> ShutdownSignal {
102        ShutdownSignal {
103            receiver: self.sender.subscribe(),
104        }
105    }
106
107    /// Request shutdown. Idempotent.
108    pub fn shutdown(&self) {
109        // `send_replace` rather than `send`: the latter reports an error when
110        // no receiver exists, and this state must be recorded whether or not
111        // anyone is listening yet.
112        self.sender.send_replace(true);
113    }
114}
115
116impl Clone for ShutdownTrigger {
117    fn clone(&self) -> Self {
118        Self {
119            sender: Arc::clone(&self.sender),
120            _keepalive: Arc::clone(&self._keepalive),
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[tokio::test]
130    async fn waiting_workers_are_released_on_shutdown() {
131        let trigger = ShutdownTrigger::new();
132        let signal = trigger.signal();
133        assert!(!signal.is_shutting_down());
134
135        let waiter = tokio::spawn({
136            let signal = signal.clone();
137            async move {
138                signal.cancelled().await;
139                true
140            }
141        });
142
143        trigger.shutdown();
144        assert!(waiter.await.expect("waiter task"));
145        assert!(signal.is_shutting_down());
146    }
147
148    #[tokio::test]
149    async fn a_signal_taken_after_shutdown_resolves_immediately() {
150        let trigger = ShutdownTrigger::new();
151        trigger.shutdown();
152        let signal = trigger.signal();
153        assert!(signal.is_shutting_down());
154        // Must not hang: the worker started too late to see the transition.
155        signal.cancelled().await;
156    }
157}