cordis-core 0.2.11

Typed lifecycle, services, events, effects, and observation for the Cordis v3 runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
//! Effects — generation-owned cleanup obligations with exact at-most-once
//! claims.
//!
//! Every side effect (listener registration, service publication, a
//! fiber-bound task, a plain cleanup) registers exactly one cleanup
//! obligation into the **current open generation** of the selected fiber.
//! The generation owns its obligations: when the generation closes —
//! unload, restart/update replacement, failed-apply rollback, or disposal —
//! the framework claims every remaining obligation and runs it in strict
//! sequential reverse-commit order, attempting all of them even when some
//! fail.
//!
//! Registration returns a move-only [`EffectRegistration`]: the exact
//! capability for claiming that one occurrence early. Manual control and
//! the generation drain arbitrate one exact claim — whichever removes the
//! occurrence first owns it, and the loser observes `false`. A winning
//! [`EffectRegistration::dispose`] transfers completion to framework
//! ownership: the cleanup reaches its end independently of caller polling.
//! A cleanup that returns an error or panics permanently consumes the
//! occurrence; the failure normalizes once into [`EffectFailure`].
//!
//! Cleanups are `FnOnce + Send + 'static` (never reusable callbacks) whose
//! result adapts through the sealed [`CleanupResult`]: `()` for infallible
//! cleanup, `Result<(), E>` for a fallible one. Async cleanups return Send
//! futures. Registering through a context whose fiber generation is closed
//! (stable Pending or Failed, draining, or disposed) fails fast with
//! [`EffectRegistrationError::InactiveContext`] and runs nothing.

use crate::context::Context;
use crate::fiber::Fiber;
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Weak};

/// A boxed, owned, sendable future — the erased currency of listener and
/// tail signatures.
///
/// Crate-private boxed-future representation used only across internal owner seams.
/// Public semantic operations expose their concrete `impl Future` forms instead.
pub(crate) type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;

/// One stored cleanup obligation: a one-shot closure whose execution
/// resolves to its already-normalized failure, if any. Boxed `FnOnce`
/// makes at-most-once a type-level fact instead of a protocol comment.
pub(crate) type Cleanup = Box<dyn FnOnce() -> BoxFuture<Result<(), EffectFailure>> + Send>;

/// Adapt an infallible synchronous cleanup (resource bookkeeping: store
/// withdrawal, hook removal) into the journal's shape.
pub(crate) fn sync_cleanup<F>(f: F) -> Cleanup
where
    F: FnOnce() + Send + 'static,
{
    Box::new(move || {
        Box::pin(async move {
            f();
            Ok(())
        })
    })
}

/// Adapt an infallible async cleanup into the journal's shape. The call
/// happens on the first poll of the returned future, so a panic at call
/// time is inside the containment boundary, not at the adapter.
pub(crate) fn fut_cleanup<F>(f: F) -> Cleanup
where
    F: FnOnce() -> BoxFuture<()> + Send + 'static,
{
    Box::new(move || {
        Box::pin(async move {
            f().await;
            Ok(())
        })
    })
}

/// Run one claimed cleanup to completion: invoke, await, contain.
///
/// The closure call itself is inside the caught future — a synchronous
/// cleanup panics at call time (before any future exists to await) and an
/// async one panics on poll, so both phases must be within the boundary.
/// A returned error arrives already normalized by [`CleanupResult`]; a
/// panic is caught here and normalized into the same [`EffectFailure`].
/// Either way the occurrence is consumed: nothing restores it.
pub(crate) async fn execute_cleanup(cleanup: Cleanup) -> Option<EffectFailure> {
    match crate::contained::catch_contained(async move { cleanup().await }).await {
        Ok(Ok(())) => None,
        Ok(Err(failure)) => Some(failure),
        Err(payload) => Some(EffectFailure::panicked(crate::contained::payload_text(
            &payload,
        ))),
    }
}

/// Report one claimed cleanup's failure through the containment
/// boundary's single routing (`cordis:`-prefixed warn record, stderr
/// fallback). Used when nobody is left to receive the failure: the
/// generation drain, and a winning manual dispose whose caller abandoned
/// the wait. A failure is delivered exactly once — returned to a waiting
/// caller, or reported here; never both.
pub(crate) fn report_cleanup_failure(
    logger: Option<&crate::logger::Logger>,
    failure: &EffectFailure,
) {
    crate::contained::report_text(logger, format!("cordis: effect cleanup {failure}"));
}

/// Opaque key of one journal occurrence, handed to the matching
/// [`EffectRegistration`] claim capability. Crate-internal: nothing public
/// constructs or consumes it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct DisposableToken(u64);

/// The per-generation cleanup journal: a keyed list of cleanup obligations
/// preserving insertion (commit) order. `tokens()` returns keys in forward
/// order and the owning fiber's drain runs cleanups LIFO by reversing it;
/// `remove` is the exact one-time claim both manual control and the drain
/// compete on. The journal holds no labels and answers no introspection —
/// presence of a resource is observed through that resource's own semantic
/// interface, never through cleanup bookkeeping.
#[derive(Default)]
pub(crate) struct DisposableList {
    sn: u64,
    map: BTreeMap<u64, Cleanup>,
}

impl DisposableList {
    /// An empty journal.
    pub(crate) fn new() -> Self {
        Self::default()
    }

    /// Commit one cleanup obligation; returns its exact claim token.
    pub(crate) fn push(&mut self, cleanup: Cleanup) -> DisposableToken {
        self.sn += 1;
        let sn = self.sn;
        self.map.insert(sn, cleanup);
        DisposableToken(sn)
    }

    /// Claim a previously pushed occurrence, returning its cleanup if it
    /// was still owned by the generation — the remove-returns-ownership
    /// two-phase shape (ADR 0010): the caller runs or drops the cleanup
    /// *after* releasing the lock, so user-controlled destruction never
    /// executes inside the critical section.
    pub(crate) fn remove(&mut self, token: DisposableToken) -> Option<Cleanup> {
        self.map.remove(&token.0)
    }

    /// Snapshot of every still-owned token, in commit order — the drain's
    /// worklist (it reverses this for LIFO).
    pub(crate) fn tokens(&self) -> Vec<DisposableToken> {
        self.map.keys().map(|k| DisposableToken(*k)).collect()
    }
}

mod sealed {
    pub trait Sealed {}
    impl Sealed for () {}
    impl<E: std::error::Error> Sealed for Result<(), E> {}
}

/// What an effect cleanup may return. Sealed: exactly `()` (infallible)
/// and `Result<(), E>` for `E: Error` (fallible) are adapted — the last
/// boundary that still knows the error's type, so a failure normalizes
/// exactly once into the opaque [`EffectFailure`] and the original error
/// object, `Any` access, and downcasts never escape.
pub trait CleanupResult: sealed::Sealed {
    #[doc(hidden)]
    fn into_outcome(self) -> std::result::Result<(), EffectFailure>;
}

impl CleanupResult for () {
    fn into_outcome(self) -> std::result::Result<(), EffectFailure> {
        Ok(())
    }
}

impl<E: std::error::Error> CleanupResult for Result<(), E> {
    fn into_outcome(self) -> std::result::Result<(), EffectFailure> {
        self.map_err(|e| EffectFailure::returned(e.to_string()))
    }
}

/// The semantic kind of an [`EffectFailure`]: the cleanup returned an
/// error, or it panicked.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EffectFailureKind {
    /// The cleanup returned `Err(_)` from its `Result<(), E>` form.
    ReturnedError,
    /// The cleanup panicked (at call time or while its future was
    /// polled); the panic was contained at the cleanup boundary.
    Panic,
}

/// One cleanup's normalized failure: its semantic [`EffectFailureKind`]
/// plus owned diagnostic text. Opaque on purpose — no public constructor,
/// no original error object, no downcast: the failure is reported and
/// matched by kind, never re-thrown or inspected by type.
pub struct EffectFailure {
    kind: EffectFailureKind,
    diagnostic: String,
}

impl EffectFailure {
    pub(crate) fn returned(diagnostic: String) -> Self {
        Self {
            kind: EffectFailureKind::ReturnedError,
            diagnostic,
        }
    }

    pub(crate) fn panicked(payload: String) -> Self {
        Self {
            kind: EffectFailureKind::Panic,
            diagnostic: payload,
        }
    }

    /// Whether the cleanup returned an error or panicked.
    pub fn kind(&self) -> EffectFailureKind {
        self.kind
    }

    /// The owned diagnostic text: the returned error's `Display`, or the
    /// rendered panic payload.
    pub fn diagnostic(&self) -> &str {
        &self.diagnostic
    }
}

impl std::fmt::Debug for EffectFailure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EffectFailure")
            .field("kind", &self.kind)
            .field("diagnostic", &self.diagnostic)
            .finish()
    }
}

impl std::fmt::Display for EffectFailure {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.kind {
            EffectFailureKind::ReturnedError => {
                write!(f, "returned an error: {}", self.diagnostic)
            }
            EffectFailureKind::Panic => write!(f, "panicked: {}", self.diagnostic),
        }
    }
}

impl std::error::Error for EffectFailure {}

/// Why an effect registration was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum EffectRegistrationError {
    /// The selected fiber's generation is not open: it is stably Pending
    /// or Failed, draining, or disposed. Nothing was registered and the
    /// cleanup stays with the caller for rollback.
    #[error("the context's fiber generation is closed to new cleanup")]
    InactiveContext,
}

/// Why a task registration was refused.
///
/// Both refusal arms are pre-commit: nothing was registered into the
/// generation and no task was started — the would-be task future drops
/// with the caller, outside every lock.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TaskRegistrationError {
    /// The selected fiber's generation is not open: it is stably Pending
    /// or Failed, draining, or disposed.
    #[error("the context's fiber generation is closed to new tasks")]
    InactiveContext,
    /// No async runtime is current on this thread to drive the task.
    #[error("no async runtime is current")]
    ExecutorUnavailable,
}

/// The exact, move-only claim capability for one registered cleanup
/// occurrence.
///
/// Both operations consume the registration, so the two manual operations
/// cannot race each other; each races only the generation drain:
///
/// - [`disarm`](Self::disarm) releases the occurrence *without* running
///   the cleanup — for resources that finished naturally;
/// - [`dispose`](Self::dispose) runs the cleanup now, under framework
///   ownership: once the claim is won, completion no longer depends on
///   the caller polling the returned future.
///
/// Whichever side — manual control or the generation drain — claims the
/// occurrence first owns it; the consumed loser reports `false` and runs
/// nothing again. Dropping the registration is inert: the occurrence stays
/// generation-owned and the drain runs it when the generation closes.
pub struct EffectRegistration {
    token: DisposableToken,
    owner: Weak<Fiber>,
}

impl EffectRegistration {
    /// Release this cleanup occurrence **without running it**. `true` when
    /// the claim was won here; `false` when the generation drain (or an
    /// earlier claim) already owns or consumed the occurrence.
    pub fn disarm(self) -> bool {
        let Some(fiber) = self.owner.upgrade() else {
            return false;
        };
        // the claimed cleanup is dropped here, in the caller's frame —
        // `remove_disposable` has already released the journal lock, so
        // captures whose Drop re-enters the fiber cannot deadlock
        // (ADR 0010 two-phase)
        fiber.remove_disposable(self.token).is_some()
    }

    /// Run this cleanup now and keep the generation drain from running it
    /// again. `Ok(true)` when this call claimed the occurrence and the
    /// cleanup completed; `Ok(false)` when the drain already claimed it
    /// (nothing runs twice); `Err(EffectFailure)` when this call claimed
    /// the occurrence and the cleanup returned an error or panicked — the
    /// occurrence is permanently consumed either way.
    ///
    /// Once the claim wins, the cleanup's completion is framework-owned:
    /// dropping this future after the claim abandons only the wait, and a
    /// failure nobody is left to receive is reported through the fiber's
    /// diagnostics instead. Dropping the future *before* its first poll
    /// changes nothing — the occurrence stays generation-owned.
    pub async fn dispose(self) -> std::result::Result<bool, EffectFailure> {
        let Some(fiber) = self.owner.upgrade() else {
            return Ok(false);
        };
        let Some(cleanup) = fiber.remove_disposable(self.token) else {
            return Ok(false);
        };
        // The claim is won: from here the cleanup completes under
        // framework ownership. The work is detached onto the current
        // executor and the outcome travels back through a one-shot
        // channel, so the caller's cancellation abandons only the wait,
        // never the cleanup.
        //
        // Off-runtime, [`detach`] drives the work on one dedicated std
        // thread under its own current-thread Tokio runtime, so a cleanup
        // that touches Tokio runs as written. The rejected alternatives:
        // inline execution would let caller cancellation interrupt the
        // cleanup mid-poll; refusal has no error channel after a won
        // claim. The path is degenerate — every async framework surface
        // requires a runtime — so thread count is bounded by off-runtime
        // usage, and each thread exits when its cleanup completes.
        let logger = fiber.fiber_ctx().map(|ctx| ctx.logger());
        let (tx, rx) = tokio::sync::oneshot::channel();
        let work = async move {
            let outcome = execute_cleanup(cleanup).await;
            if let Err(outcome) = tx.send(outcome)
                && let Some(failure) = outcome
            {
                // the caller abandoned the wait — the failure still gets
                // its exactly-one diagnostic report
                report_cleanup_failure(logger.as_ref(), &failure);
            }
        };
        detach(work);
        match rx.await {
            Ok(None) => Ok(true),
            Ok(Some(failure)) => Err(failure),
            // unreachable while the runtime lives: `work` always sends.
            // A dead send side means the driving executor itself was torn
            // down mid-cleanup — the claim was still won and owned.
            Err(_closed) => Ok(true),
        }
    }
}

impl Context {
    /// Register an async cleanup as one obligation of this context's
    /// current fiber generation — the path for cleanups with something to
    /// await. The closure runs at most once: claimed by the generation
    /// drain (LIFO) or early by the returned [`EffectRegistration`]. A
    /// cleanup with nothing to await belongs on
    /// [`Context::effect_sync`] instead.
    ///
    /// Fails with [`EffectRegistrationError::InactiveContext`] when the
    /// generation is closed (stable Pending or Failed, draining, or
    /// disposed); nothing is registered then and the cleanup drops with
    /// the caller, outside every lock.
    pub fn effect<F, Fut, R>(
        &self,
        cleanup: F,
    ) -> std::result::Result<EffectRegistration, EffectRegistrationError>
    where
        F: FnOnce() -> Fut + Send + 'static,
        Fut: Future<Output = R> + Send,
        R: CleanupResult,
    {
        self.register_cleanup(Box::new(move || {
            Box::pin(async move { cleanup().await.into_outcome() })
        }))
    }

    /// Register a **synchronous** cleanup as one obligation of this
    /// context's current fiber generation — the spelling for cleanups
    /// with nothing to await, so no call site needs a `Box::pin(async {})`
    /// stub. Same semantics as [`Context::effect`].
    pub fn effect_sync<F, R>(
        &self,
        cleanup: F,
    ) -> std::result::Result<EffectRegistration, EffectRegistrationError>
    where
        F: FnOnce() -> R + Send + 'static,
        R: CleanupResult,
    {
        self.register_cleanup(Box::new(move || {
            Box::pin(async move { cleanup().into_outcome() })
        }))
    }

    /// The one registration path both spellings share: the closure is
    /// already wrapped into the journal's normalized shape (construction
    /// of the closure happened at the call site, conversion of its result
    /// happens at execution — neither under the journal lock).
    fn register_cleanup(
        &self,
        cleanup: Cleanup,
    ) -> std::result::Result<EffectRegistration, EffectRegistrationError> {
        // a plain effect publishes nothing but its cleanup obligation, so
        // the gated seam can only refuse on admission — map it onto the
        // operation's one variant
        let token = crate::gated::push_gated(self.fiber(), cleanup, &mut crate::gated::NoPublish)
            .map_err(|_| EffectRegistrationError::InactiveContext)?;
        Ok(EffectRegistration {
            token,
            owner: Arc::downgrade(self.fiber()),
        })
    }
}

/// Drive `work` to completion under framework ownership, independent of
/// the caller: spawned onto the current runtime, or — off-runtime —
/// driven by one dedicated std thread running its own current-thread
/// Tokio runtime, so cleanups that legitimately touch Tokio (`spawn`,
/// channel joins) run as written instead of panicking inside a
/// runtime-less `block_on` (the postcommit-completion law, ADR 0029).
/// Building that runtime can only fail on OS resource exhaustion; the
/// last-resort fallback drives without one. Same thread posture as
/// deadline.rs's watchdogs (bare spawn, panic on OS failure); each
/// thread exits when its work completes.
pub(crate) fn detach(work: impl Future<Output = ()> + Send + 'static) {
    match tokio::runtime::Handle::try_current() {
        Ok(handle) => {
            let _join = handle.spawn(work);
        }
        Err(_) => {
            std::thread::spawn(move || {
                match tokio::runtime::Builder::new_current_thread()
                    .enable_all()
                    .build()
                {
                    Ok(runtime) => runtime.block_on(work),
                    Err(_) => futures::executor::block_on(work),
                }
            });
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{DisposableList, EffectFailureKind, sync_cleanup};

    // The claim seam the drain and manual control compete on: `remove`
    // hands the cleanup out instead of dropping it in place — the
    // lock-side half of the discipline the owner's call sites complete
    // (ADR 0010), and exactly-once by construction.
    #[test]
    fn remove_claims_the_occurrence_exactly_once() {
        let mut list = DisposableList::new();
        let token = list.push(sync_cleanup(|| {}));
        assert!(list.remove(token).is_some(), "first claim hands it out");
        assert!(
            list.remove(token).is_none(),
            "a token resolves exactly once"
        );
        assert!(list.tokens().is_empty());
    }

    // The drain's worklist is commit order; the fiber reverses it for
    // LIFO — pinned here so the journal and the drain cannot drift apart.
    #[test]
    fn tokens_snapshot_commit_order() {
        let mut list = DisposableList::new();
        let a = list.push(sync_cleanup(|| {}));
        let b = list.push(sync_cleanup(|| {}));
        assert_eq!(
            list.tokens(),
            vec![a, b],
            "forward order — the drain reverses it"
        );
        list.remove(a);
        assert_eq!(list.tokens(), vec![b]);
    }

    #[test]
    fn cleanup_result_normalizes_once() {
        use super::CleanupResult;
        assert!(().into_outcome().is_ok());
        #[derive(Debug, thiserror::Error)]
        #[error("cleanup boom")]
        struct Boom;
        let failure = Err::<(), Boom>(Boom).into_outcome().unwrap_err();
        assert_eq!(failure.kind(), EffectFailureKind::ReturnedError);
        assert_eq!(failure.diagnostic(), "cleanup boom");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn cleanup_can_reenter_same_generation_registration() {
        use crate::Context;
        use std::sync::Arc;
        use std::sync::atomic::{AtomicBool, Ordering};
        use std::time::Duration;

        let ctx = Context::new();
        let reentered = Arc::new(AtomicBool::new(false));
        let cleanup_ctx = ctx.clone();
        let cleanup_reentered = reentered.clone();
        let registration = ctx
            .effect_sync(move || {
                cleanup_ctx
                    .effect_sync(|| {})
                    .expect("cleanup may reenter the same generation journal");
                cleanup_reentered.store(true, Ordering::SeqCst);
            })
            .unwrap();

        let dispose = registration.dispose();
        tokio::pin!(dispose);
        let watchdog = crate::deadline::watchdog(Duration::from_secs(2));
        tokio::pin!(watchdog);
        tokio::select! {
            result = &mut dispose => assert_eq!(result.unwrap(), true),
            _ = &mut watchdog => panic!("cleanup reentry deadlocked generation bookkeeping"),
        }
        assert!(reentered.load(Ordering::SeqCst));
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn cleanup_error_conversion_can_reenter_generation_bookkeeping() {
        use crate::Context;
        use std::fmt;
        use std::time::Duration;

        struct ReentrantError(Context);

        impl fmt::Debug for ReentrantError {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str("ReentrantError")
            }
        }

        impl fmt::Display for ReentrantError {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                self.0
                    .effect_sync(|| {})
                    .expect("cleanup error conversion may reenter the generation journal");
                f.write_str("issue55 reentrant cleanup error")
            }
        }

        impl std::error::Error for ReentrantError {}

        let ctx = Context::new();
        let conversion_ctx = ctx.clone();
        let registration = ctx
            .effect_sync(move || -> Result<(), ReentrantError> {
                Err(ReentrantError(conversion_ctx))
            })
            .unwrap();

        let dispose = registration.dispose();
        tokio::pin!(dispose);
        let watchdog = crate::deadline::watchdog(Duration::from_secs(2));
        tokio::pin!(watchdog);
        let failure = tokio::select! {
            result = &mut dispose => result.expect_err("cleanup returns the probe error"),
            _ = &mut watchdog => panic!("cleanup error conversion deadlocked generation bookkeeping"),
        };
        assert_eq!(failure.kind(), EffectFailureKind::ReturnedError);
        assert_eq!(failure.diagnostic(), "issue55 reentrant cleanup error");
    }
}