modrun 1.0.1

Modular application composer for Tokio: domain modules, typed wiring, lifecycle
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
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
use std::path::PathBuf;
use std::time::Duration;

use crate::banner::Banner;
use crate::banner::emit;
use crate::container::{Container, seed_builtins};
use crate::error::{Error, Result, combine_results, with_cleanup};
use crate::graph::render_dot;
use crate::invoke::InvokeOut;
use crate::invoke::ScopedInvoker;
use crate::lifecycle::Lifecycle;
use crate::module::Module;
use crate::option::ModOption;
use crate::provide_group::init_group;
use crate::provide_group::require_group;
use crate::scope::ScopeId;
use crate::shutdown::Shutdowner;
use crate::timeout::DEFAULT_TIMEOUT;
use crate::trace;
use crate::trace::dot_graph_written;
use crate::trace::emit_unfinished;
use crate::trace::hooks_abandoned;
use crate::trace::invoke_cancelled;
use crate::trace::invoke_failed;
use crate::trace::invoke_panicked;
use crate::trace::invoking;
use crate::trace::rollback_failed;
use crate::trace::rolled_back;
use crate::trace::rolling_back;
use crate::trace::running_app_dropped;
use crate::trace::start_failed;
use crate::trace::stop_failed;
use crate::trace::stopped;
use crate::wiring::{impl_group_wiring_methods, impl_wiring_methods};
use std::borrow::Cow;
use std::fmt;
use std::fs::write;
use std::future::Future;
use std::mem::take;
use std::time::Instant;
use tokio::time::timeout;

/// Entry point for configuring an application: [`Modrun::builder`].
#[derive(Debug)]
pub struct Modrun;

/// Fluent builder for a modrun application.
///
/// ```
/// # use modrun::Modrun;
/// #[derive(Clone)]
/// struct Config { port: u16 }
/// #[derive(Clone)]
/// struct Server;
///
/// fn new_server(_cfg: Config) -> Server { Server }
/// fn boot(_server: Server) {}
///
/// # #[tokio::main]
/// # async fn main() -> modrun::Result<()> {
/// Modrun::builder()
///     .supply(Config { port: 8080 })
///     .provide(new_server)
///     .invoke(boot)
///     .start()
///     .await?
///     .stop()
///     .await
/// # }
/// ```
pub struct ModrunBuilder {
    options: Vec<Box<dyn ModOption>>,
    banner: Banner,
    build_timeout: Option<Duration>,
    start_timeout: Option<Duration>,
    stop_timeout: Option<Duration>,
    dot_graph_path: Option<PathBuf>,
}

impl Default for ModrunBuilder {
    fn default() -> Self {
        Self {
            options: Vec::new(),
            banner: Banner::default(),
            build_timeout: Some(DEFAULT_TIMEOUT),
            start_timeout: Some(DEFAULT_TIMEOUT),
            stop_timeout: Some(DEFAULT_TIMEOUT),
            dot_graph_path: None,
        }
    }
}

impl fmt::Debug for ModrunBuilder {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("ModrunBuilder")
            .field("options", &self.options.len())
            .field("banner", &self.banner)
            .field("build_timeout", &self.build_timeout)
            .field("start_timeout", &self.start_timeout)
            .field("stop_timeout", &self.stop_timeout)
            .field("dot_graph_path", &self.dot_graph_path)
            .finish()
    }
}

impl Modrun {
    /// Start configuring an application.
    #[must_use]
    pub fn builder() -> ModrunBuilder {
        ModrunBuilder::default()
    }
}

impl ModrunBuilder {
    fn push_option(&mut self, option: Box<dyn ModOption>) {
        self.options.push(option);
    }

    /// Install a domain [`Module`].
    #[must_use]
    pub fn module(mut self, module: Module) -> Self {
        self.push_option(module.into_option());
        self
    }

    /// Total budget for graph construction (async constructors and invokers).
    ///
    /// Timeouts are cooperative: work that yields at `.await` is cancelled when
    /// the budget expires. Synchronous blocking (for example `std::thread::sleep`
    /// inside a sync invoker or constructor) cannot be preempted; after it
    /// returns, an over-budget success is still reported as
    /// [`Error::BuildTimeout`](crate::Error::BuildTimeout).
    ///
    /// [`Shutdowner`](crate::Shutdowner) and OS signals during [`run`](Self::run)
    /// use the same cooperative rule: they cancel the current phase at its next
    /// `.await`, so a shutdown from a synchronous OnStart does not skip later
    /// hooks that have not yet yielded.
    ///
    /// The cancellation timer follows Tokio's clock; the over-budget `Ok` check
    /// uses wall-clock [`std::time::Instant`]. Pausing Tokio time in tests
    /// (`tokio::time::pause`) can make those two disagree.
    ///
    /// If set more than once, the last value wins. [`no_build_timeout`](Self::no_build_timeout)
    /// disables the budget.
    #[must_use]
    pub fn build_timeout(mut self, duration: Duration) -> Self {
        self.build_timeout = Some(duration);
        self
    }

    /// Do not bound graph construction.
    #[must_use]
    pub fn no_build_timeout(mut self) -> Self {
        self.build_timeout = None;
        self
    }

    /// Total budget for all OnStart hooks.
    ///
    /// Same cooperative semantics as [`build_timeout`](Self::build_timeout):
    /// blocking work is not preempted, but an over-budget `Ok` is still turned
    /// into [`Error::StartTimeout`](crate::Error::StartTimeout). Unwind after a
    /// failed or cancelled start is budgeted by [`stop_timeout`](Self::stop_timeout).
    ///
    /// If set more than once, the last value wins. [`no_start_timeout`](Self::no_start_timeout)
    /// disables the budget.
    #[must_use]
    pub fn start_timeout(mut self, duration: Duration) -> Self {
        self.start_timeout = Some(duration);
        self
    }

    /// Do not bound OnStart. Unwind after a failed/cancelled start still uses
    /// [`stop_timeout`](Self::stop_timeout).
    #[must_use]
    pub fn no_start_timeout(mut self) -> Self {
        self.start_timeout = None;
        self
    }

    /// Total budget for all OnStop hooks (including unwind after a failed or
    /// cancelled start).
    ///
    /// Same cooperative semantics as [`build_timeout`](Self::build_timeout):
    /// blocking OnStop work is not preempted, but an over-budget `Ok` is still
    /// turned into [`Error::StopTimeout`](crate::Error::StopTimeout) /
    /// [`Error::UnwindTimeout`](crate::Error::UnwindTimeout).
    ///
    /// If set more than once, the last value wins. [`no_stop_timeout`](Self::no_stop_timeout)
    /// disables the budget.
    #[must_use]
    pub fn stop_timeout(mut self, duration: Duration) -> Self {
        self.stop_timeout = Some(duration);
        self
    }

    /// Do not bound OnStop / unwind.
    #[must_use]
    pub fn no_stop_timeout(mut self) -> Self {
        self.stop_timeout = None;
        self
    }

    /// Replace the default modrun banner with custom text (for example
    /// `include_str!("banner.txt")`).
    ///
    /// Printed to stderr once at the beginning of [`run`](Self::run) or
    /// [`start`](Self::start), before framework tracing events. Unlike the
    /// default banner, custom text is printed even when stderr is not a TTY.
    #[must_use]
    pub fn banner(mut self, text: impl Into<Cow<'static, str>>) -> Self {
        self.banner = Banner::Custom(text.into());
        self
    }

    /// Do not print a startup banner.
    ///
    /// The default banner is already skipped when stderr is not a TTY. Call this
    /// from tests that run in a terminal, or when the ASCII art is never wanted.
    #[must_use]
    pub fn no_banner(mut self) -> Self {
        self.banner = Banner::Off;
        self
    }

    /// Render the dependency graph as a Graphviz DOT document without running
    /// constructors or invokers.
    ///
    /// Wiring is validated first, so missing providers and dependency cycles
    /// surface as errors rather than an incomplete graph.
    ///
    /// # Errors
    ///
    /// Returns the same validation errors as [`start`](Self::start).
    pub fn render_dot(self) -> Result<String> {
        let state = self.prepare_build_state()?;
        Ok(render_dot(&state.container, &state.invokers))
    }

    /// Write the dependency graph to a DOT file before graph construction runs.
    ///
    /// The file is written after wiring validation succeeds and before any
    /// constructor or invoker runs. Combine with [`run`](Self::run) or
    /// [`start`](Self::start) to export the graph for an application that will
    /// actually start.
    #[must_use]
    pub fn dot_graph(mut self, path: impl Into<PathBuf>) -> Self {
        self.dot_graph_path = Some(path.into());
        self
    }

    fn print_banner(&self) {
        emit(&self.banner);
    }

    /// Build → start hooks → wait for shutdown → stop.
    ///
    /// When the `signal` crate feature is enabled (on by default), OS signal
    /// listeners are installed from the beginning of this call on **Unix**
    /// (Ctrl-C / SIGTERM) and **Windows** (Ctrl-C / Ctrl-Break / Ctrl-Close /
    /// Ctrl-Shutdown). Other targets ignore the feature for OS signals and rely
    /// on [`Shutdowner`](crate::Shutdowner) only. [`start`](Self::start) does
    /// not install signal handlers.
    ///
    /// With `default-features = false`, only [`Shutdowner`](crate::Shutdowner)
    /// unblocks this future.
    ///
    /// Signal listeners are owned by this future and dropped when it returns —
    /// calling `run` repeatedly on the same runtime does not accumulate tasks.
    ///
    /// A shutdown request or OS signal during build or start cancels that phase
    /// and unwinds hooks that already started, plus any stop-only hooks already
    /// registered (even if OnStart never ran). If cleanup succeeds, `run`
    /// returns `Ok(())` so process managers treat it as a graceful stop rather
    /// than a crash. A timeout, hook failure, or background [`crate::task`]
    /// failure still returns an error — a concurrent graceful shutdown does not
    /// turn that failure into `Ok(())`.
    ///
    /// Cancellation is cooperative (same as [`build_timeout`](Self::build_timeout)):
    /// the in-flight future is dropped at its next `.await`. After start
    /// succeeds, this future waits until a signal or [`Shutdowner::shutdown`].
    /// A background [`crate::task`] that fails or panics requests shutdown on
    /// its own. Custom work spawned with [`tokio::spawn`] must still call
    /// [`Shutdowner::shutdown`] or `run` waits forever for an OS signal.
    ///
    /// # Errors
    ///
    /// Returns an error when wiring/validation fails, a start or stop hook
    /// fails, a phase times out, or OS signal listeners cannot be installed.
    ///
    /// # Panics
    ///
    /// A panic in a constructor, invoker, or hook is a programming error and is
    /// not converted into [`Error`]. Tracing records the in-flight phase as
    /// `panicked`, but lifecycle unwind may not run.
    pub async fn run(self) -> Result<()> {
        self.print_banner();
        let shutdown = Shutdowner::new();
        let lifecycle = Lifecycle::new(shutdown.clone());
        let mut signals = SignalWatch::install()?;

        let mut state = self.into_build_state(lifecycle.clone(), shutdown.clone())?;
        let stop_timeout = state.stop_timeout;

        let app = tokio::select! {
            biased;
            result = run_invokers(&mut state) => match result {
                Ok(()) => built_app_from_state(state),
                Err(e) => {
                    return finish_run(
                        Err(e),
                        unwind_registered(&lifecycle, stop_timeout).await,
                    );
                }
            },
            _ = shutdown.wait() => {
                crate::trace::shutdown_requested();
                return finish_interrupt(
                    &shutdown,
                    unwind_registered(&lifecycle, stop_timeout).await,
                );
            }
            signal = signals.recv() => {
                crate::trace::received_signal(signal);
                shutdown.shutdown();
                crate::trace::shutdown_requested();
                return finish_run(
                    Ok(()),
                    unwind_registered(&lifecycle, stop_timeout).await,
                );
            }
        };

        // Race only OnStart. Unwind runs afterwards so a shutdown cannot
        // swallow a start error that is already in hand.
        let started = tokio::select! {
            biased;
            result = app.start_hooks() => result,
            _ = shutdown.wait() => {
                crate::trace::shutdown_requested();
                crate::trace::rolling_back_after_shutdown();
                return finish_interrupt(&shutdown, graceful_unwind(&app).await);
            }
            signal = signals.recv() => {
                crate::trace::received_signal(signal);
                shutdown.shutdown();
                crate::trace::shutdown_requested();
                crate::trace::rolling_back_after_shutdown();
                return finish_run(Ok(()), graceful_unwind(&app).await);
            }
        };

        match started {
            Ok(()) => trace::started(),
            Err(err) => {
                rolling_back(&err);
                let cleanup = graceful_unwind(&app).await;
                start_failed(&err);
                return finish_run(Err(err), cleanup);
            }
        }

        tokio::select! {
            _ = shutdown.wait() => {
                crate::trace::shutdown_requested();
            }
            signal = signals.recv() => {
                crate::trace::received_signal(signal);
                shutdown.shutdown();
                crate::trace::shutdown_requested();
            }
        }
        app.stop().await
    }

    /// Build and start without waiting for a shutdown signal (useful in tests).
    ///
    /// Does not install Ctrl-C / SIGTERM handlers; call [`RunningApp::stop`]
    /// yourself (or use [`ModrunBuilder::run`](Self::run) for signal-driven stop).
    ///
    /// A background [`crate::task`] that fails after its OnStart has returned
    /// does **not** cancel remaining start hooks or fail this call. Poll
    /// [`Shutdowner::is_requested`] / [`Shutdowner::wait`], or call
    /// [`RunningApp::stop`], to observe it. [`run`](Self::run) tears the
    /// process down when a worker fails.
    ///
    /// # Errors
    ///
    /// Returns an error when wiring/validation fails, graph construction times
    /// out, a start hook fails, or start times out (including cleanup failures
    /// while unwinding).
    ///
    /// # Panics
    ///
    /// Same as [`run`](Self::run): a panic in a constructor, invoker, or hook is
    /// not converted into [`Error`] and may skip lifecycle unwind.
    pub async fn start(self) -> Result<RunningApp> {
        self.print_banner();
        let shutdown = Shutdowner::new();
        let lifecycle = Lifecycle::new(shutdown.clone());
        let mut state = self.into_build_state(lifecycle.clone(), shutdown)?;
        let stop_timeout = state.stop_timeout;
        if let Err(e) = run_invokers(&mut state).await {
            return Err(with_cleanup(
                e,
                unwind_registered(&lifecycle, stop_timeout).await,
            ));
        }
        let app = built_app_from_state(state);
        app.start().await?;
        Ok(RunningApp { inner: Some(app) })
    }

    fn into_build_state(
        mut self,
        lifecycle: Lifecycle,
        shutdown: Shutdowner,
    ) -> Result<BuildState> {
        let dot_graph_path = self.dot_graph_path.take();
        let state = self.prepare_build_state_with(lifecycle, shutdown)?;
        if let Some(path) = dot_graph_path {
            let dot = render_dot(&state.container, &state.invokers);
            write(&path, dot).map_err(|source| Error::io("write dot graph", source))?;
            dot_graph_written(path.as_os_str().to_string_lossy().as_ref());
        }
        Ok(state)
    }

    fn prepare_build_state(self) -> Result<BuildState> {
        let shutdown = Shutdowner::new();
        let lifecycle = Lifecycle::new(shutdown.clone());
        self.prepare_build_state_with(lifecycle, shutdown)
    }

    fn prepare_build_state_with(
        self,
        lifecycle: Lifecycle,
        shutdown: Shutdowner,
    ) -> Result<BuildState> {
        let mut state = BuildState {
            container: Container::new(),
            invokers: Vec::new(),
            lifecycle,
            build_timeout: self.build_timeout,
            start_timeout: self.start_timeout,
            stop_timeout: self.stop_timeout,
            current_scope: ScopeId::ROOT,
        };
        seed_builtins(&mut state.container, state.lifecycle.clone(), shutdown)?;

        for opt in self.options {
            opt.apply(&mut state)?;
        }

        let invoker_deps: Vec<_> = state
            .invokers
            .iter()
            .map(|scoped| (scoped.scope, scoped.invoker.dep_types()))
            .collect();
        state.container.validate(&invoker_deps)?;

        Ok(state)
    }
}

async fn run_invokers(state: &mut BuildState) -> Result<()> {
    let invokers = take(&mut state.invokers);
    let budget = state.build_timeout;
    let invoke = async {
        for scoped in invokers {
            let ScopedInvoker { scope, invoker } = scoped;
            let function = invoker.name();
            let deps = invoker.dep_list();
            let module = state.container.scopes().name(scope);
            invoking(function, deps.as_slice(), module);
            let mut invoke_guard = InflightInvoke::new(function, module);
            let previous = state.container.enter_scope(scope);
            let result = async {
                if !deps.as_slice().is_empty() {
                    state.container.ensure_built(deps.as_slice()).await?;
                }
                match invoker.call(&state.container) {
                    InvokeOut::Done(r) => r,
                    InvokeOut::Fut(fut) => fut.await,
                }
            }
            .await;
            state.container.leave_scope(previous);
            invoke_guard.finish();
            if let Err(ref err) = result {
                invoke_failed(function, deps.as_slice(), module, err);
            }
            result?;
        }
        Ok(())
    };
    with_timeout(
        budget,
        invoke,
        Error::BuildTimeout(budget.unwrap_or(Duration::ZERO)),
    )
    .await
}

struct InflightInvoke {
    function: &'static str,
    module: &'static str,
    finished: bool,
}

impl InflightInvoke {
    fn new(function: &'static str, module: &'static str) -> Self {
        Self {
            function,
            module,
            finished: false,
        }
    }

    fn finish(&mut self) {
        self.finished = true;
    }
}

impl Drop for InflightInvoke {
    fn drop(&mut self) {
        emit_unfinished(
            self.finished,
            || invoke_panicked(self.function, self.module),
            || invoke_cancelled(self.function, self.module),
        );
    }
}

fn built_app_from_state(state: BuildState) -> BuiltApp {
    BuiltApp {
        lifecycle: state.lifecycle,
        start_timeout: state.start_timeout,
        stop_timeout: state.stop_timeout,
    }
}

fn finish_run(phase: Result<()>, cleanup: Result<()>) -> Result<()> {
    match phase {
        Ok(()) => cleanup,
        Err(e) => Err(with_cleanup(e, cleanup)),
    }
}

/// [`Shutdowner::shutdown`] during build/start is a graceful `Ok(())`.
/// A background [`crate::task`] failure is not: keep a specific join error,
/// or [`Error::TaskFailedDuringStart`]. An unwind timeout still retains that
/// failure as [`Error::CleanupAfterFailure`].
fn finish_interrupt(shutdown: &Shutdowner, cleanup: Result<()>) -> Result<()> {
    if shutdown.is_failure() {
        match cleanup {
            Ok(()) => Err(Error::TaskFailedDuringStart),
            Err(e) if matches!(e, Error::UnwindTimeout(_) | Error::StopTimeout(_)) => {
                Err(with_cleanup(Error::TaskFailedDuringStart, Err(e)))
            }
            Err(e) => Err(e),
        }
    } else {
        cleanup
    }
}

fn report_unwind_result(lifecycle: &Lifecycle, result: Result<()>) -> Result<()> {
    match result {
        Ok(()) => {
            rolled_back();
            Ok(())
        }
        Err(err) => {
            rollback_failed(&err);
            let leftover = lifecycle.pending_stops();
            if leftover > 0 {
                hooks_abandoned(leftover);
            }
            Err(err)
        }
    }
}

async fn unwind_lifecycle(lifecycle: &Lifecycle, stop_timeout: Option<Duration>) -> Result<()> {
    lifecycle.prepare_for_unwind();
    report_unwind_result(
        lifecycle,
        with_timeout(
            stop_timeout,
            lifecycle.unwind_started(),
            Error::UnwindTimeout(stop_timeout.unwrap_or(Duration::ZERO)),
        )
        .await,
    )
}

async fn unwind_registered(lifecycle: &Lifecycle, stop_timeout: Option<Duration>) -> Result<()> {
    unwind_lifecycle(lifecycle, stop_timeout).await
}

impl_wiring_methods!(ModrunBuilder);
impl_group_wiring_methods!(ModrunBuilder);

impl ModrunBuilder {
    /// Register an empty [`Group`](crate::Group) so invokers can depend on `Group<T>`
    /// when no module contributes a member.
    #[must_use]
    pub fn init_group<T: Clone + Send + Sync + 'static>(mut self) -> Self {
        self.push_option(init_group::<T>());
        self
    }

    /// Fail at build time when `Group<T>` would be empty.
    ///
    /// Registers the group if needed, so this does not require [`init_group`](Self::init_group)
    /// or a prior [`provide_group`](Self::provide_group). Only the composition root
    /// should call this; modules cannot declare group policy.
    #[must_use]
    pub fn require_group<T: Clone + Send + Sync + 'static>(mut self) -> Self {
        self.push_option(require_group::<T>());
        self
    }
}

/// Mutable state while options are applied. Formerly `AppBuilder`.
pub(crate) struct BuildState {
    pub(crate) container: Container,
    pub(crate) invokers: Vec<ScopedInvoker>,
    pub(crate) lifecycle: Lifecycle,
    pub(crate) build_timeout: Option<Duration>,
    pub(crate) start_timeout: Option<Duration>,
    pub(crate) stop_timeout: Option<Duration>,
    pub(crate) current_scope: ScopeId,
}

struct BuiltApp {
    lifecycle: Lifecycle,
    start_timeout: Option<Duration>,
    stop_timeout: Option<Duration>,
}

async fn graceful_unwind(app: &BuiltApp) -> Result<()> {
    unwind_lifecycle(&app.lifecycle, app.stop_timeout).await
}

async fn with_timeout(
    budget: Option<Duration>,
    fut: impl Future<Output = Result<()>>,
    timed_out: Error,
) -> Result<()> {
    match budget {
        None => fut.await,
        Some(d) => {
            let started = Instant::now();
            match timeout(d, fut).await {
                // Prefer a real phase error over a timeout.
                Ok(Err(err)) => Err(err),
                // Sync blocking can prevent the timer from firing until the
                // work returns; treat an over-budget Ok as a timeout.
                Ok(Ok(())) if started.elapsed() >= d => Err(timed_out),
                Ok(Ok(())) => Ok(()),
                Err(_) => Err(timed_out),
            }
        }
    }
}

impl BuiltApp {
    /// OnStart only. The caller is responsible for unwind on failure or cancel.
    async fn start_hooks(&self) -> Result<()> {
        with_timeout(
            self.start_timeout,
            self.lifecycle.start(),
            Error::StartTimeout(self.start_timeout.unwrap_or(Duration::ZERO)),
        )
        .await
    }

    async fn start(&self) -> Result<()> {
        match self.start_hooks().await {
            Ok(()) => {
                trace::started();
                Ok(())
            }
            Err(err) => {
                rolling_back(&err);
                let unwound = graceful_unwind(self).await;
                start_failed(&err);
                combine_results(Err(err), unwound)
            }
        }
    }

    async fn stop(&self) -> Result<()> {
        let result = with_timeout(
            self.stop_timeout,
            self.lifecycle.stop(),
            Error::StopTimeout(self.stop_timeout.unwrap_or(Duration::ZERO)),
        )
        .await;
        match &result {
            Err(err) => {
                stop_failed(err);
                let leftover = self.lifecycle.pending_stops();
                if leftover > 0 {
                    hooks_abandoned(leftover);
                }
            }
            Ok(()) => stopped(),
        }
        result
    }
}

/// OS signal listener owned by [`ModrunBuilder::run`]. Dropping it uninstalls
/// the wait without leaving a detached task behind.
struct SignalWatch {
    #[cfg(all(feature = "signal", unix))]
    sigint: tokio::signal::unix::Signal,
    #[cfg(all(feature = "signal", unix))]
    sigterm: tokio::signal::unix::Signal,
    #[cfg(all(feature = "signal", windows))]
    ctrl_c: tokio::signal::windows::CtrlC,
    #[cfg(all(feature = "signal", windows))]
    ctrl_break: tokio::signal::windows::CtrlBreak,
    #[cfg(all(feature = "signal", windows))]
    ctrl_close: tokio::signal::windows::CtrlClose,
    #[cfg(all(feature = "signal", windows))]
    ctrl_shutdown: tokio::signal::windows::CtrlShutdown,
}

impl SignalWatch {
    fn install() -> Result<Self> {
        #[cfg(all(feature = "signal", unix))]
        {
            use tokio::signal::unix::{SignalKind, signal};
            let sigint = signal(SignalKind::interrupt()).map_err(Error::SigintListen)?;
            let sigterm = signal(SignalKind::terminate()).map_err(Error::SigtermListen)?;
            Ok(Self { sigint, sigterm })
        }

        #[cfg(all(feature = "signal", windows))]
        {
            use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close, ctrl_shutdown};
            Ok(Self {
                ctrl_c: ctrl_c().map_err(Error::SignalListen)?,
                ctrl_break: ctrl_break().map_err(Error::SignalListen)?,
                ctrl_close: ctrl_close().map_err(Error::SignalListen)?,
                ctrl_shutdown: ctrl_shutdown().map_err(Error::SignalListen)?,
            })
        }

        #[cfg(not(any(all(feature = "signal", unix), all(feature = "signal", windows))))]
        {
            Ok(Self {})
        }
    }

    async fn recv(&mut self) -> &'static str {
        #[cfg(all(feature = "signal", unix))]
        {
            tokio::select! {
                Some(()) = self.sigint.recv() => "SIGINT",
                Some(()) = self.sigterm.recv() => "SIGTERM",
                else => std::future::pending().await,
            }
        }

        #[cfg(all(feature = "signal", windows))]
        {
            tokio::select! {
                Some(()) = self.ctrl_c.recv() => "CTRL_C",
                Some(()) = self.ctrl_break.recv() => "CTRL_BREAK",
                Some(()) = self.ctrl_close.recv() => "CTRL_CLOSE",
                Some(()) = self.ctrl_shutdown.recv() => "CTRL_SHUTDOWN",
                else => std::future::pending().await,
            }
        }

        #[cfg(not(any(all(feature = "signal", unix), all(feature = "signal", windows))))]
        {
            let _ = self;
            std::future::pending::<&'static str>().await
        }
    }
}

/// A started application that can be stopped explicitly.
///
/// Dropping this without calling [`stop`](Self::stop) skips every OnStop hook,
/// so hold on to it for as long as the application should stay up.
///
/// The dependency container is dropped after build: singletons only stay alive
/// through values captured by hooks (or other `Clone` handles taken during
/// invoke). This is a wiring layer, not a live service locator. Build is not
/// transactional: a failed invoker may leave earlier constructors' side effects
/// applied for the duration of that failed `build` call.
#[must_use = "dropping a RunningApp skips all OnStop hooks; call stop() instead"]
pub struct RunningApp {
    inner: Option<BuiltApp>,
}

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

impl RunningApp {
    /// Run every OnStop hook, in reverse registration order.
    ///
    /// # Errors
    ///
    /// Returns an error when one or more OnStop hooks fail, or when the stop
    /// budget expires (remaining hooks are then abandoned).
    pub async fn stop(mut self) -> Result<()> {
        let inner = self
            .inner
            .take()
            .expect("RunningApp always contains a built application");
        inner.stop().await
    }
}

impl Drop for RunningApp {
    fn drop(&mut self) {
        if self.inner.is_some() {
            running_app_dropped();
            #[cfg(debug_assertions)]
            eprintln!("modrun: dropping RunningApp without stop(); OnStop hooks will not run");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn finish_interrupt_retains_failure_on_unwind_timeout() {
        let shutdown = Shutdowner::new();
        shutdown.fail();
        let err = finish_interrupt(
            &shutdown,
            Err(Error::UnwindTimeout(Duration::from_millis(30))),
        )
        .unwrap_err();
        match err {
            Error::CleanupAfterFailure { cleanup, earlier } => {
                assert!(
                    matches!(*cleanup, Error::UnwindTimeout(_)),
                    "cleanup was {cleanup}"
                );
                assert!(
                    matches!(*earlier, Error::TaskFailedDuringStart),
                    "earlier was {earlier}"
                );
            }
            other => panic!("expected CleanupAfterFailure, got {other}"),
        }
    }

    #[test]
    fn finish_interrupt_keeps_join_error() {
        let shutdown = Shutdowner::new();
        shutdown.fail();
        let err = finish_interrupt(&shutdown, Err(Error::hook("died"))).unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("died"), "{msg}");
        assert!(!msg.contains("cleanup failed"), "{msg}");
    }

    #[test]
    fn finish_interrupt_graceful_ok_is_ok() {
        let shutdown = Shutdowner::new();
        shutdown.shutdown();
        finish_interrupt(&shutdown, Ok(())).unwrap();
    }
}