monoloop_loop/transaction/bootstrap.rs
1//! Runtime bootstrap inputs (Transaction Runtime v2).
2
3use super::channel_registry::ChannelRegistry;
4use super::host_tools::HostToolRegistry;
5use monoloop_contracts::TransactionLimits;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::sync::watch;
10
11/// Test/prod gate that defers the supervisor's `Stopped` transition until
12/// [`StoppedGate::release`] (v2 §22.5 TimedOut determinism).
13///
14/// Uses [`watch`] so a release that races ahead of the waiter is not lost
15/// (unlike `Notify`, which can miss a wakeup between check and await).
16#[derive(Debug)]
17pub struct StoppedGate {
18 tx: watch::Sender<bool>,
19 rx: watch::Receiver<bool>,
20}
21
22impl Default for StoppedGate {
23 fn default() -> Self {
24 let (tx, rx) = watch::channel(false);
25 Self { tx, rx }
26 }
27}
28
29impl StoppedGate {
30 /// New unreleased gate.
31 pub fn new() -> Self {
32 Self::default()
33 }
34
35 /// Allow the supervisor to enter `Stopped`.
36 pub fn release(&self) {
37 let _ = self.tx.send(true);
38 }
39
40 /// Wait until [`Self::release`] has been called.
41 pub async fn wait_released(&self) {
42 let mut rx = self.rx.clone();
43 // `wait_for` observes the current value first — no lost-wakeup race.
44 let _ = rx.wait_for(|released| *released).await;
45 }
46}
47
48/// Test-only gate that pauses supervisor drain of the start queue (D-040).
49///
50/// While held, `Start` commands remain queued so admission can observe
51/// start-queue-full rollback without the supervisor racing to drain.
52#[derive(Debug, Default)]
53pub struct StartHoldGate {
54 held: AtomicBool,
55}
56
57impl StartHoldGate {
58 /// New gate, initially not holding (start drain enabled).
59 pub fn new() -> Self {
60 Self {
61 held: AtomicBool::new(false),
62 }
63 }
64
65 /// Pause start-queue drain.
66 pub fn hold(&self) {
67 self.held.store(true, Ordering::SeqCst);
68 }
69
70 /// Resume start-queue drain.
71 pub fn release(&self) {
72 self.held.store(false, Ordering::SeqCst);
73 }
74
75 /// Whether start drain is currently paused.
76 pub fn is_held(&self) -> bool {
77 self.held.load(Ordering::SeqCst)
78 }
79}
80
81/// Test-only gate that pauses supervisor drain of the control queue (§23).
82///
83/// While held, Cancel / ForceTerminate / BeginShutdown remain queued so
84/// `TransactionLimits.max_actor_commands` plus-one can observe
85/// `ControlCapacityExceeded` without the preferential control drain racing.
86#[derive(Debug, Default)]
87pub struct ControlHoldGate {
88 held: AtomicBool,
89}
90
91impl ControlHoldGate {
92 /// New gate, initially not holding (control drain enabled).
93 pub fn new() -> Self {
94 Self {
95 held: AtomicBool::new(false),
96 }
97 }
98
99 /// Pause control-queue drain.
100 pub fn hold(&self) {
101 self.held.store(true, Ordering::SeqCst);
102 }
103
104 /// Resume control-queue drain.
105 pub fn release(&self) {
106 self.held.store(false, Ordering::SeqCst);
107 }
108
109 /// Whether control drain is currently paused.
110 pub fn is_held(&self) -> bool {
111 self.held.load(Ordering::SeqCst)
112 }
113}
114
115/// Test-only gate that pauses the Finalizer between Seal and completion send (§22.2).
116///
117/// Proves shutdown / hard-grace cannot drop the ledger row or the one completion
118/// attempt while Seal has already run.
119#[derive(Debug)]
120pub struct FinalizerHoldGate {
121 released: AtomicBool,
122 notify: tokio::sync::Notify,
123}
124
125impl Default for FinalizerHoldGate {
126 fn default() -> Self {
127 Self {
128 released: AtomicBool::new(false),
129 notify: tokio::sync::Notify::new(),
130 }
131 }
132}
133
134impl FinalizerHoldGate {
135 /// New gate; Finalizer blocks after Seal until [`Self::release`].
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 /// Allow Finalizer to publish completion.
141 pub fn release(&self) {
142 self.released.store(true, Ordering::SeqCst);
143 self.notify.notify_waiters();
144 }
145
146 /// Wait until release (used by Finalizer).
147 pub async fn wait_released(&self) {
148 loop {
149 if self.released.load(Ordering::SeqCst) {
150 return;
151 }
152 // Subscribe before re-check to avoid lost wakeup.
153 let notified = self.notify.notified();
154 if self.released.load(Ordering::SeqCst) {
155 return;
156 }
157 notified.await;
158 }
159 }
160}
161
162/// Test-only inject: TaskSupervisor-owned JoinOnly-style work (§22.4 / Law 23 / M5.4).
163///
164/// Registers a `RuntimeService` that parks the worker thread until
165/// [`JoinOnlySpillInject::release`] (abort cannot join a non-awaiting park —
166/// same shape as the §22.3 sacrificial). Proves `wait_stopped` stays Quiescing
167/// with `owned_tasks > 0`, then reaches Stopped after release.
168/// Production leaves [`RuntimeConfig::inject_join_only_spill`] as `None`.
169///
170/// Name retains “Spill” for API stability; ownership is TaskSupervisor, not
171/// [`crate::transaction::dispatcher::OrphanToolPermitSet`].
172#[derive(Debug)]
173pub struct JoinOnlySpillInject {
174 entered: AtomicBool,
175 released: AtomicBool,
176 parked_thread: std::sync::Mutex<Option<std::thread::Thread>>,
177}
178
179impl Default for JoinOnlySpillInject {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185impl JoinOnlySpillInject {
186 /// New inject; JoinOnly wait starts blocked until [`Self::release`].
187 pub fn new() -> Self {
188 Self {
189 entered: AtomicBool::new(false),
190 released: AtomicBool::new(false),
191 parked_thread: std::sync::Mutex::new(None),
192 }
193 }
194
195 /// True once the supervised task has entered its park loop.
196 pub fn is_entered(&self) -> bool {
197 self.entered.load(Ordering::SeqCst)
198 }
199
200 pub(crate) fn is_released(&self) -> bool {
201 self.released.load(Ordering::SeqCst)
202 }
203
204 pub(crate) fn mark_entered(&self) {
205 self.entered.store(true, Ordering::SeqCst);
206 }
207
208 pub(crate) fn store_parked_thread(&self, thread: std::thread::Thread) {
209 *self.parked_thread.lock().unwrap_or_else(|e| e.into_inner()) = Some(thread);
210 }
211
212 /// Allow the supervised JoinOnly task to finish (unblocks Stopped).
213 pub fn release(&self) {
214 self.released.store(true, Ordering::SeqCst);
215 if let Some(thread) = self
216 .parked_thread
217 .lock()
218 .unwrap_or_else(|e| e.into_inner())
219 .take()
220 {
221 thread.unpark();
222 }
223 }
224}
225
226/// Runtime-wide configuration validated at startup.
227#[derive(Clone, Debug)]
228pub struct RuntimeConfig {
229 /// Transaction / event / callback bounds.
230 pub transaction_limits: TransactionLimits,
231 /// When true, bind a loopback MCP gateway as TaskSupervisor RuntimeService.
232 pub enable_mcp_listener: bool,
233 /// Maximum time to wait for graceful drain during shutdown when not specified.
234 pub default_shutdown_deadline: Duration,
235 /// When `Some`, supervisor defers drain-complete until the gate is released.
236 /// Production leaves this `None`; §22.5 TimedOut proofs set it.
237 pub block_stopped: Option<Arc<StoppedGate>>,
238 /// When `Some`, supervisor skips draining `Start` while the gate is held.
239 /// Production leaves this `None`; D-040 parked-Start proofs set it.
240 pub hold_start: Option<Arc<StartHoldGate>>,
241 /// When `Some`, supervisor skips draining control while the gate is held.
242 /// Production leaves this `None`; §23 `max_actor_commands` proofs set it.
243 pub hold_control: Option<Arc<ControlHoldGate>>,
244 /// Override start-queue capacity (tests). `None` ⇒ `max_active_transactions`.
245 /// Use a value smaller than reservation capacity to prove start-full rollback
246 /// while the reservation pool still has headroom (D-040 / §22.1).
247 pub start_queue_capacity: Option<usize>,
248 /// When `Some`, Finalizer waits after Seal before completion send (§22.2).
249 /// Production leaves this `None`.
250 pub hold_finalizer_after_seal: Option<Arc<FinalizerHoldGate>>,
251 /// When `Some`, the executor OS thread waits here after supervisor drain and
252 /// before `shutdown_timeout` (D-049). Production leaves this `None`.
253 pub hold_executor_teardown: Option<Arc<StoppedGate>>,
254 /// When `Some`, supervisor registers a never-awaiting `RuntimeService` that
255 /// stores `true` on this flag immediately before parking (§22.3 sacrificial).
256 /// Production leaves this `None`.
257 pub inject_non_yielding_service: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
258 /// When `Some`, supervisor registers TaskSupervisor-owned JoinOnly-style
259 /// work at start (Stopped-vs-owned-task proof). Production leaves this `None`.
260 pub inject_join_only_spill: Option<Arc<JoinOnlySpillInject>>,
261}
262
263impl Default for RuntimeConfig {
264 fn default() -> Self {
265 Self {
266 transaction_limits: TransactionLimits::default(),
267 // Default off; hosts that need MCP set true at bootstrap.
268 enable_mcp_listener: false,
269 default_shutdown_deadline: Duration::from_secs(30),
270 block_stopped: None,
271 hold_start: None,
272 hold_control: None,
273 start_queue_capacity: None,
274 hold_finalizer_after_seal: None,
275 hold_executor_teardown: None,
276 inject_non_yielding_service: None,
277 inject_join_only_spill: None,
278 }
279 }
280}
281
282impl RuntimeConfig {
283 /// Validate non-zero and consistent bounds.
284 pub fn validate(&self) -> Result<(), super::StartupError> {
285 self.transaction_limits.validate().map_err(|e| match e {
286 monoloop_contracts::LimitsError::ZeroCapacity(f) => {
287 super::StartupError::InvalidConfig(f)
288 }
289 monoloop_contracts::LimitsError::Inconsistent(_) => {
290 super::StartupError::InvalidConfig("inconsistent transaction limits")
291 }
292 })?;
293 if self.default_shutdown_deadline.is_zero() {
294 return Err(super::StartupError::InvalidConfig(
295 "default_shutdown_deadline",
296 ));
297 }
298 // Reject durations that cannot form an Instant absolute deadline
299 // (`Instant + Duration` would panic for Duration::MAX-class values).
300 const MAX_TX_DEADLINE: std::time::Duration =
301 std::time::Duration::from_secs(365 * 24 * 3600);
302 if self.transaction_limits.transaction_deadline > MAX_TX_DEADLINE
303 || std::time::Instant::now()
304 .checked_add(self.transaction_limits.transaction_deadline)
305 .is_none()
306 {
307 return Err(super::StartupError::InvalidConfig(
308 "transaction_deadline exceeds Instant-representable bound",
309 ));
310 }
311 if let Some(cap) = self.start_queue_capacity {
312 if cap == 0 {
313 return Err(super::StartupError::InvalidConfig(
314 "start_queue_capacity must be nonzero when set",
315 ));
316 }
317 }
318 Ok(())
319 }
320}
321
322/// Production bootstrap for [`super::lifecycle::StartedRuntime::start`].
323///
324/// The runtime constructs and owns its Tokio executor (v2 §7.2). There is no
325/// external `Handle` on this struct.
326pub struct RuntimeBootstrap {
327 /// Limits and feature flags.
328 pub config: RuntimeConfig,
329 /// Immutable Channel bindings (factories realized at start).
330 pub channels: ChannelRegistry,
331 /// Immutable host tool shell (empty allowed).
332 pub tools: HostToolRegistry,
333}