Skip to main content

cordis/
effect.rs

1//! Lifecycle-owned effects and single-shot asynchronous disposers.
2
3use crate::fiber::{Fiber, FiberInner};
4use crate::utils::{BoxFuture, block_on, lock};
5use crate::{CordisError, ErrorCode, Result};
6use std::fmt::{self, Debug, Formatter};
7use std::future::Future;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Arc, Mutex, Weak};
10
11/// A boxed, single-shot asynchronous cleanup operation.
12pub struct AsyncDisposer {
13    callback: Option<Box<dyn FnOnce() -> BoxFuture<Result<()>> + Send + 'static>>,
14}
15
16impl AsyncDisposer {
17    /// Wrap a synchronous cleanup callback.
18    pub fn from_sync<F>(callback: F) -> Self
19    where
20        F: FnOnce() -> Result<()> + Send + 'static,
21    {
22        Self {
23            callback: Some(Box::new(move || Box::pin(async move { callback() }))),
24        }
25    }
26
27    /// Wrap an infallible synchronous cleanup callback.
28    pub fn infallible<F>(callback: F) -> Self
29    where
30        F: FnOnce() + Send + 'static,
31    {
32        Self::from_sync(move || {
33            callback();
34            Ok(())
35        })
36    }
37
38    /// Wrap an asynchronous cleanup callback.
39    pub fn from_async<F, Fut>(callback: F) -> Self
40    where
41        F: FnOnce() -> Fut + Send + 'static,
42        Fut: Future<Output = Result<()>> + Send + 'static,
43    {
44        Self {
45            callback: Some(Box::new(move || Box::pin(callback()))),
46        }
47    }
48
49    /// Run this disposer. Calling `run` consumes it, enforcing single-shot use.
50    pub async fn run(mut self) -> Result<()> {
51        match self.callback.take() {
52            Some(callback) => callback().await,
53            None => Ok(()),
54        }
55    }
56}
57
58impl Debug for AsyncDisposer {
59    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
60        f.debug_struct("AsyncDisposer")
61            .field("pending", &self.callback.is_some())
62            .finish()
63    }
64}
65
66/// Diagnostic tree describing a live effect and nested effects it owns.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct EffectMeta {
69    /// Human-readable effect label.
70    pub label: String,
71    /// Nested effect metadata.
72    pub children: Vec<EffectMeta>,
73}
74
75impl EffectMeta {
76    /// Construct a leaf effect metadata node.
77    pub fn new(label: impl Into<String>) -> Self {
78        Self {
79            label: label.into(),
80            children: Vec::new(),
81        }
82    }
83}
84
85pub(crate) struct EffectCell {
86    pub(crate) id: u64,
87    owner: Weak<FiberInner>,
88    disposed: AtomicBool,
89    disposer: Mutex<Option<AsyncDisposer>>,
90    children: Mutex<Vec<Arc<EffectCell>>>,
91    meta: Mutex<EffectMeta>,
92}
93
94impl EffectCell {
95    pub(crate) fn new(
96        id: u64,
97        owner: Weak<FiberInner>,
98        label: impl Into<String>,
99        disposer: AsyncDisposer,
100    ) -> Arc<Self> {
101        Arc::new(Self {
102            id,
103            owner,
104            disposed: AtomicBool::new(false),
105            disposer: Mutex::new(Some(disposer)),
106            children: Mutex::new(Vec::new()),
107            meta: Mutex::new(EffectMeta::new(label)),
108        })
109    }
110
111    async fn dispose(self: &Arc<Self>) -> Result<()> {
112        if self.disposed.swap(true, Ordering::AcqRel) {
113            return Ok(());
114        }
115
116        if let Some(owner) = self.owner.upgrade() {
117            owner.remove_effect(self.id);
118        }
119
120        let children = {
121            let mut children = lock(&self.children);
122            std::mem::take(&mut *children)
123        };
124        let mut first_error = None;
125        for child in children.into_iter().rev() {
126            if let Err(error) = Box::pin(child.dispose()).await {
127                if first_error.is_none() {
128                    first_error = Some(error);
129                }
130            }
131        }
132
133        let disposer = lock(&self.disposer).take();
134        if let Some(disposer) = disposer {
135            if let Err(error) = disposer.run().await {
136                if first_error.is_none() {
137                    first_error = Some(error);
138                }
139            }
140        }
141
142        match first_error {
143            Some(error) => Err(error),
144            None => Ok(()),
145        }
146    }
147
148    pub(crate) fn cancel(&self) {
149        if self.disposed.swap(true, Ordering::AcqRel) {
150            return;
151        }
152        lock(&self.disposer).take();
153        lock(&self.children).clear();
154        if let Some(owner) = self.owner.upgrade() {
155            owner.remove_effect(self.id);
156        }
157    }
158
159    fn adopt(self: &Arc<Self>, child: Arc<EffectCell>) -> Result<()> {
160        // Serialize against dispose()/cancel(): they swap `disposed` before
161        // draining `children`, so re-checking `disposed` while holding the
162        // children lock guarantees that a child pushed after a passing check
163        // is always seen by a concurrent disposal. Without this, a child
164        // adopted mid-disposal is detached from its fiber's list but never
165        // cleaned up — a silent leak.
166        let mut children = lock(&self.children);
167        if self.disposed.load(Ordering::Acquire) {
168            return Err(CordisError::new(ErrorCode::InactiveEffect));
169        }
170        if child.disposed.load(Ordering::Acquire) {
171            return Ok(());
172        }
173        if let Some(owner) = child.owner.upgrade() {
174            owner.remove_effect(child.id);
175        }
176        lock(&self.meta).children.push(lock(&child.meta).clone());
177        children.push(child);
178        Ok(())
179    }
180}
181
182/// A cloneable handle to one registered effect.
183///
184/// Dropping a handle does not dispose the effect: ownership belongs to the
185/// fiber.  Call [`EffectHandle::dispose`] for early cleanup, or dispose the
186/// owning fiber.
187#[derive(Clone)]
188pub struct EffectHandle {
189    pub(crate) cell: Arc<EffectCell>,
190}
191
192impl EffectHandle {
193    pub(crate) fn new(cell: Arc<EffectCell>) -> Self {
194        Self { cell }
195    }
196
197    /// Dispose this effect synchronously, waiting for asynchronous cleanup.
198    pub fn dispose(&self) -> Result<()> {
199        block_on(self.dispose_async())
200    }
201
202    /// Dispose this effect asynchronously.
203    pub async fn dispose_async(&self) -> Result<()> {
204        self.cell.dispose().await
205    }
206
207    /// Stop owning this effect without running its cleanup callback.
208    ///
209    /// This is intended for framework structural effects. Application code
210    /// normally wants [`dispose`](Self::dispose).
211    pub fn cancel(&self) {
212        self.cell.cancel();
213    }
214
215    /// Move `child` under this effect's diagnostic and disposal tree.
216    pub fn adopt(&self, child: EffectHandle) -> Result<()> {
217        self.cell.adopt(child.cell)
218    }
219
220    /// Return a snapshot of diagnostic metadata.
221    pub fn meta(&self) -> EffectMeta {
222        lock(&self.cell.meta).clone()
223    }
224
225    /// Whether cleanup has already started.
226    pub fn is_disposed(&self) -> bool {
227        self.cell.disposed.load(Ordering::Acquire)
228    }
229
230    /// Return the owning fiber while it remains alive.
231    pub fn owner(&self) -> Option<Fiber> {
232        self.cell.owner.upgrade().map(Fiber::from_inner)
233    }
234}
235
236impl Debug for EffectHandle {
237    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
238        f.debug_struct("EffectHandle")
239            .field("id", &self.cell.id)
240            .field("meta", &self.meta())
241            .field("disposed", &self.is_disposed())
242            .finish()
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::thread;
250    use std::time::Duration;
251
252    /// Disposing a parent recurses into adopted children (reverse adoption
253    /// order) — this exercises the boxed recursive dispose path at runtime.
254    #[test]
255    fn dispose_runs_adopted_children_in_reverse_order() {
256        let runs = Arc::new(Mutex::new(Vec::new()));
257        let parent = EffectCell::new(
258            1,
259            Weak::new(),
260            "parent",
261            AsyncDisposer::from_sync(|| Ok(())),
262        );
263        for id in [2_u64, 3] {
264            let runs = runs.clone();
265            let child = EffectCell::new(
266                id,
267                Weak::new(),
268                "child",
269                AsyncDisposer::from_sync(move || {
270                    runs.lock().unwrap().push(id);
271                    Ok(())
272                }),
273            );
274            parent.adopt(child).unwrap();
275        }
276        block_on(parent.dispose()).unwrap();
277        assert_eq!(*runs.lock().unwrap(), vec![3, 2]);
278    }
279
280    /// Regression: adopt checked `disposed` outside the children lock, so a
281    /// child adopted while the parent was mid-disposal ended up detached from
282    /// its owner but never cleaned up. The children lock is the gate: the
283    /// adopting thread must observe the disposal and back off.
284    #[test]
285    fn adopt_racing_parent_disposal_is_rejected() {
286        let parent = EffectCell::new(
287            1,
288            Weak::new(),
289            "parent",
290            AsyncDisposer::from_sync(|| Ok(())),
291        );
292        let child_ran = Arc::new(AtomicBool::new(false));
293        let child_ran_in_disposer = child_ran.clone();
294        let child = EffectCell::new(
295            2,
296            Weak::new(),
297            "child",
298            AsyncDisposer::from_sync(move || {
299                child_ran_in_disposer.store(true, Ordering::SeqCst);
300                Ok(())
301            }),
302        );
303
304        // Hold the adoption gate so the ordering is deterministic.
305        let gate = lock(&parent.children);
306        let adopting_parent = parent.clone();
307        let adopt_handle = thread::spawn(move || adopting_parent.adopt(child));
308        thread::sleep(Duration::from_millis(50));
309        let disposing_parent = parent.clone();
310        let dispose_handle = thread::spawn(move || block_on(disposing_parent.dispose()));
311        thread::sleep(Duration::from_millis(50));
312        drop(gate);
313
314        assert!(adopt_handle.join().unwrap().is_err());
315        dispose_handle.join().unwrap().unwrap();
316        assert!(!child_ran.load(Ordering::SeqCst));
317    }
318}