agentd/signals.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Signal handling + the self-pipe wakeup. RFC 0003 §signals, RFC 0011 §signals.
3//!
4//! Handlers are async-signal-safe — they only touch atomics and `write()` one
5//! byte to a **self-pipe** so a blocked reactor wakes promptly (`SA_RESTART`
6//! is deliberately off, so blocked syscalls also return `EINTR`). The reactor
7//! selects on `wakeup_fd()` alongside its channels; on wake it checks the
8//! flags and drains the pipe.
9//!
10//! - `SIGTERM`/`SIGINT` → one-way `DRAINING` (a second sets `FORCE`).
11//! - `SIGCHLD` → set the child-exit flag (the reactor runs `reap::reap_pending`).
12//! - `SIGPIPE` → ignored, so the supervisor never dies writing to a dead child.
13
14#[cfg(unix)]
15mod imp {
16 use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
17
18 static DRAINING: AtomicBool = AtomicBool::new(false);
19 static FORCE: AtomicBool = AtomicBool::new(false);
20 static CHILD_EXIT: AtomicBool = AtomicBool::new(false);
21 // Hot-reload request latch (RFC 0017 §5.2). The SIGHUP handler sets it +
22 // wakes the reactor; the reactive supervisor consults `reload_requested()`
23 // on its next tick (after `health::tick()`, like `draining()`) and runs the
24 // validate-first/quiesce/apply choreography, then `clear_reload()`s it. A
25 // SIGHUP while DRAINING is ignored (drain wins — checked at the consult site),
26 // so this latch can be set-but-never-honoured during a drain, which is fine:
27 // the process is exiting. The handler is registered ONLY under the
28 // `hot-reload` feature; without it SIGHUP keeps its default disposition.
29 static RELOAD: AtomicBool = AtomicBool::new(false);
30 // Trigger-attribution latch for the `config.reload_requested` event (RFC 0017
31 // §5.6 — `{trigger:"sighup"|"watch"}`). The inotify file-watch thread
32 // (RFC 0017 §5.2) sets BOTH `RELOAD` and this flag via
33 // `request_reload_from_watch()`; the reactive apply step reads-and-clears it
34 // with `take_reload_was_watch()` to pick the trigger string, DEFAULTING to
35 // "sighup" when unset (the SIGHUP handler / `request_reload()` never set it).
36 // The watcher is a normal thread (not a signal handler), so a plain atomic
37 // store is fine — no async-signal-safety constraint here.
38 static RELOAD_FROM_WATCH: AtomicBool = AtomicBool::new(false);
39 // Reload-in-progress guard (RFC 0017 §5.3 step 3). Set while the reactive
40 // supervisor is APPLYING a validated reload; the served `subagent.spawn`
41 // chokepoint consults it and returns a transient "reload in progress" error to
42 // NEW spawns (mirrors the `draining` guard, but transient — cleared in step 6).
43 // Like PAUSED/LAME_DUCK it rides here (not a feature-gated module) so it is one
44 // process-global truth the served surface reads without a feature dependency;
45 // it is only ever SET by the `hot-reload` reactive apply step.
46 static RELOADING: AtomicBool = AtomicBool::new(false);
47 // Lame-duck override (RFC 0015 §4.2): a one-way-per-call readiness override
48 // toward NotReady, flipped by the `a2a.LameDuck` admin method — NOT a signal.
49 // It rides here (not in a feature-gated module) so it is one process-global
50 // truth consulted by BOTH the `/readyz` probe (obs::serve, `metrics`) and the
51 // served control surface (mcp::server, `a2a`), with neither feature depending
52 // on the other. Distinct from `DRAINING`: lame-duck never exits.
53 static LAME_DUCK: AtomicBool = AtomicBool::new(false);
54 // Tree-wide pause state (RFC 0015 §4.3): set by the `a2a.Pause` admin method,
55 // cleared by `a2a.Resume`. Like `LAME_DUCK`, it rides here (not a feature-gated
56 // module) so it is one process-global truth read by BOTH the served operator
57 // surface (`agentd://inventory`, `serve-mcp`) and the `agentd_paused` gauge
58 // (`metrics`), with neither feature depending on the other. Distinct from
59 // DRAINING/LAME_DUCK: pause freezes the agentic loops only — never exits, never
60 // touches readiness (the supervisor reactor and liveness heartbeat run on).
61 static PAUSED: AtomicBool = AtomicBool::new(false);
62 // Intelligence all-endpoints-down latch (RFC 0018 §6). The model loop runs in
63 // a re-exec'd CHILD process that owns its own intel client + circuit-breaker /
64 // failover state; the supervisor has NO LLM and no live view of that breaker
65 // state. The child therefore reports its reachability UPWARD (an edge-triggered
66 // `AgentMsg::IntelHealth` at the breaker/failover seam — on entering all-down
67 // and on recovering); the supervisor latches it HERE so the readiness probe,
68 // the `agentd_intel_all_down` gauge, and the `agentd://intelligence`/`capacity`
69 // bodies all read ONE truth without a feature dependency (it rides here, not in
70 // a feature-gated module, exactly like LAME_DUCK/PAUSED).
71 //
72 // SEMANTICS (be honest): this is EVENTUALLY-CONSISTENT, last-child-experience.
73 // A fresh subagent spawn starts with FRESH breakers (all CLOSED), so the latched
74 // flag reflects the MOST RECENT child's intel reachability and persists between
75 // reactions — it is the right "should the fleet route work to this pod" signal,
76 // but it is NOT a continuous supervisor-side probe of the endpoints. There is no
77 // model loop in the supervisor to probe with; the truth comes from whichever
78 // child last exercised the endpoints. Distinct from DRAINING/LAME_DUCK (which an
79 // operator/SIGTERM set): this is set by the data path (a child's failover).
80 static INTEL_ALL_DOWN: AtomicBool = AtomicBool::new(false);
81 // Self-pipe fds (-1 until install()). The write end is touched from signal
82 // handlers; the read end is what the reactor waits on.
83 static WAKE_R: AtomicI32 = AtomicI32::new(-1);
84 static WAKE_W: AtomicI32 = AtomicI32::new(-1);
85
86 /// Async-signal-safe: write one byte to the self-pipe. A full/again pipe is
87 /// fine — the reactor only needs *a* readable byte to wake.
88 fn wake() {
89 let w = WAKE_W.load(Ordering::Relaxed);
90 if w >= 0 {
91 let b = [0u8; 1];
92 unsafe {
93 libc::write(w, b.as_ptr() as *const libc::c_void, 1);
94 }
95 }
96 }
97
98 extern "C" fn on_term(_sig: libc::c_int) {
99 if DRAINING.swap(true, Ordering::SeqCst) {
100 FORCE.store(true, Ordering::SeqCst);
101 }
102 wake();
103 }
104
105 extern "C" fn on_chld(_sig: libc::c_int) {
106 CHILD_EXIT.store(true, Ordering::SeqCst);
107 wake();
108 }
109
110 /// Async-signal-safe SIGHUP handler (RFC 0017 §5.2): set the RELOAD latch +
111 /// wake the reactor. Exactly the SIGTERM pattern (one atomic store + one
112 /// self-pipe byte); the heavy lifting (re-load, validate, apply) runs on the
113 /// reactor thread, never here. Registered only under the `hot-reload` feature.
114 #[cfg(feature = "hot-reload")]
115 extern "C" fn on_hup(_sig: libc::c_int) {
116 RELOAD.store(true, Ordering::SeqCst);
117 wake();
118 }
119
120 fn set_handler(sig: libc::c_int, handler: libc::sighandler_t, flags: libc::c_int) {
121 unsafe {
122 let mut sa: libc::sigaction = std::mem::zeroed();
123 sa.sa_sigaction = handler;
124 libc::sigemptyset(&mut sa.sa_mask);
125 sa.sa_flags = flags; // never SA_RESTART
126 libc::sigaction(sig, &sa, std::ptr::null_mut());
127 }
128 }
129
130 fn make_self_pipe() {
131 if WAKE_R.load(Ordering::SeqCst) >= 0 {
132 return; // already created
133 }
134 let mut fds = [0 as libc::c_int; 2];
135 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
136 return;
137 }
138 for &fd in &fds {
139 unsafe {
140 let fl = libc::fcntl(fd, libc::F_GETFL);
141 libc::fcntl(fd, libc::F_SETFL, fl | libc::O_NONBLOCK);
142 let fdfl = libc::fcntl(fd, libc::F_GETFD);
143 libc::fcntl(fd, libc::F_SETFD, fdfl | libc::FD_CLOEXEC);
144 }
145 }
146 WAKE_R.store(fds[0], Ordering::SeqCst);
147 WAKE_W.store(fds[1], Ordering::SeqCst);
148 }
149
150 pub fn install() {
151 make_self_pipe();
152 let term = on_term as extern "C" fn(libc::c_int) as libc::sighandler_t;
153 let chld = on_chld as extern "C" fn(libc::c_int) as libc::sighandler_t;
154 set_handler(libc::SIGTERM, term, 0);
155 set_handler(libc::SIGINT, term, 0);
156 // SA_NOCLDSTOP: only fire on child *termination*, not stop/continue.
157 set_handler(libc::SIGCHLD, chld, libc::SA_NOCLDSTOP);
158 set_handler(libc::SIGPIPE, libc::SIG_IGN, 0);
159 // SIGHUP → hot reload (RFC 0017 §5.2), only when the feature is built.
160 // Without it SIGHUP keeps its default disposition (terminate) — exactly
161 // the RFC 0011 §4.1 signal table (this is the *one* amendment, gated).
162 #[cfg(feature = "hot-reload")]
163 {
164 let hup = on_hup as extern "C" fn(libc::c_int) as libc::sighandler_t;
165 set_handler(libc::SIGHUP, hup, 0);
166 }
167 }
168
169 pub fn draining() -> bool {
170 DRAINING.load(Ordering::SeqCst)
171 }
172 pub fn force() -> bool {
173 FORCE.load(Ordering::SeqCst)
174 }
175
176 /// Programmatically request a graceful drain (the `drain` operator tool,
177 /// RFC 0015 §4.1) — the SAME one-way latch SIGTERM sets, plus a wakeup so a
178 /// blocked reactor begins the drain choreography promptly. Idempotent and
179 /// monotonic: a request after drain has begun is a no-op that never escalates
180 /// to FORCE (force remains the *second signal*, RFC 0011 §4.3).
181 pub fn request_drain() {
182 DRAINING.store(true, Ordering::SeqCst);
183 // Reuse the signal-handler wakeup so the reactor leaves its blocking
184 // select and runs the drain state machine (RFC 0011 §4.2).
185 wake();
186 }
187
188 pub fn lame_duck() -> bool {
189 LAME_DUCK.load(Ordering::SeqCst)
190 }
191
192 /// Set/clear the lame-duck readiness override (RFC 0015 §4.2). `true` forces
193 /// `/readyz` NotReady while the supervisor keeps running; `false` clears the
194 /// override (readiness then reflects the genuine computed state). No drain,
195 /// no exit, reversible.
196 pub fn set_lame_duck(on: bool) {
197 LAME_DUCK.store(on, Ordering::SeqCst);
198 }
199
200 pub fn paused() -> bool {
201 PAUSED.load(Ordering::SeqCst)
202 }
203
204 /// Set/clear the instance-wide pause state (the `pause`/`resume` operator
205 /// tools, RFC 0015 §4.3). Reporting-only: the per-session pause channels do
206 /// the actual loop suspension; this flag is the single truth `agentd://inventory`
207 /// and `agentd_paused` read. Reversible; never exits, never touches readiness.
208 pub fn set_paused(on: bool) {
209 PAUSED.store(on, Ordering::SeqCst);
210 }
211
212 pub fn intel_all_down() -> bool {
213 INTEL_ALL_DOWN.load(Ordering::SeqCst)
214 }
215
216 /// Latch the intelligence all-endpoints-down state from a child's upward
217 /// `AgentMsg::IntelHealth` report (RFC 0018 §6). Returns `true` iff the value
218 /// TRANSITIONED (so the supervisor fires the `agentd://intelligence`
219 /// notify-then-read exactly on a breaker enter/exit, not on every report).
220 /// Eventually-consistent / last-child-experience — see the static's doc above.
221 pub fn set_intel_all_down(on: bool) -> bool {
222 INTEL_ALL_DOWN.swap(on, Ordering::SeqCst) != on
223 }
224
225 /// Take and clear the SIGCHLD flag — the reactor then runs the waitpid loop.
226 pub fn take_child_exit() -> bool {
227 CHILD_EXIT.swap(false, Ordering::SeqCst)
228 }
229
230 /// Has a hot reload been requested (SIGHUP, RFC 0017 §5.2)? Read by the
231 /// reactive supervisor's tick; cleared with `clear_reload()` once the reload
232 /// routine has run (whether it applied or was rejected — both consume the
233 /// request). Always readable, but only ever SET under the `hot-reload`
234 /// feature (the handler is the only setter besides `request_reload`).
235 pub fn reload_requested() -> bool {
236 RELOAD.load(Ordering::SeqCst)
237 }
238
239 /// Clear the hot-reload latch (after the reload routine has run, or when a
240 /// drain supersedes it). Idempotent.
241 pub fn clear_reload() {
242 RELOAD.store(false, Ordering::SeqCst);
243 }
244
245 /// Programmatically request a hot reload (parity with `request_drain` — for
246 /// a future `reload` operator tool / tests), plus a reactor wakeup. Honoured
247 /// only by a `hot-reload` build's reactive loop; a no-feature build never
248 /// consults the latch, so this is inert there.
249 pub fn request_reload() {
250 RELOAD.store(true, Ordering::SeqCst);
251 wake();
252 }
253
254 /// Request a hot reload attributed to the file-watch trigger (RFC 0017 §5.2):
255 /// set the SAME RELOAD latch SIGHUP/`request_reload` do, PLUS the
256 /// watch-attribution flag the apply step reads for the `config.reload_requested`
257 /// `{trigger:"watch"}` event (§5.6). Called by the inotify watcher thread; a
258 /// reactor wakeup follows. Inert on a build without the reactive reload loop.
259 pub fn request_reload_from_watch() {
260 RELOAD_FROM_WATCH.store(true, Ordering::SeqCst);
261 RELOAD.store(true, Ordering::SeqCst);
262 wake();
263 }
264
265 /// Take-and-clear the watch-attribution flag: `true` if the pending reload was
266 /// set by the file-watch trigger (RFC 0017 §5.2), `false` (the default) for
267 /// SIGHUP / a programmatic `request_reload`. The apply step calls this once per
268 /// reload to pick the `config.reload_requested` `trigger` string (§5.6).
269 pub fn take_reload_was_watch() -> bool {
270 RELOAD_FROM_WATCH.swap(false, Ordering::SeqCst)
271 }
272
273 /// Is a validated reload mid-apply (RFC 0017 §5.3 step 3)? The served
274 /// `subagent.spawn` chokepoint reads this and transiently refuses NEW spawns.
275 pub fn reloading() -> bool {
276 RELOADING.load(Ordering::SeqCst)
277 }
278
279 /// Set/clear the reload-in-progress guard (the reactive apply step brackets
280 /// its reloadable-diff application with `set_reloading(true)`/`(false)`).
281 pub fn set_reloading(on: bool) {
282 RELOADING.store(on, Ordering::SeqCst);
283 }
284
285 pub fn wakeup_fd() -> i32 {
286 WAKE_R.load(Ordering::SeqCst)
287 }
288
289 /// Drain all pending wakeup bytes (the pipe is edge-ish; we level it).
290 pub fn drain_wakeup() {
291 let r = WAKE_R.load(Ordering::SeqCst);
292 if r < 0 {
293 return;
294 }
295 let mut buf = [0u8; 64];
296 loop {
297 let n = unsafe { libc::read(r, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
298 if n <= 0 {
299 break; // EAGAIN (drained) or error
300 }
301 }
302 }
303
304 /// Test-only: clear the one-way `DRAINING`/`FORCE` latches (production has no
305 /// clear — drain is monotonic for a process's life). The signals test guard
306 /// uses this so a draining test cannot poison readiness for later tests that
307 /// share this process (cargo runs tests multithreaded in one binary).
308 #[cfg(test)]
309 pub fn clear_drain_for_test() {
310 DRAINING.store(false, Ordering::SeqCst);
311 FORCE.store(false, Ordering::SeqCst);
312 }
313}
314
315#[cfg(not(unix))]
316mod imp {
317 pub fn install() {}
318 #[cfg(test)]
319 pub fn clear_drain_for_test() {}
320 pub fn draining() -> bool {
321 false
322 }
323 pub fn force() -> bool {
324 false
325 }
326 pub fn request_drain() {}
327 pub fn lame_duck() -> bool {
328 false
329 }
330 pub fn set_lame_duck(_on: bool) {}
331 pub fn paused() -> bool {
332 false
333 }
334 pub fn set_paused(_on: bool) {}
335 pub fn intel_all_down() -> bool {
336 false
337 }
338 pub fn set_intel_all_down(_on: bool) -> bool {
339 false
340 }
341 pub fn take_child_exit() -> bool {
342 false
343 }
344 pub fn reload_requested() -> bool {
345 false
346 }
347 pub fn clear_reload() {}
348 pub fn request_reload() {}
349 pub fn request_reload_from_watch() {}
350 pub fn take_reload_was_watch() -> bool {
351 false
352 }
353 pub fn reloading() -> bool {
354 false
355 }
356 pub fn set_reloading(_on: bool) {}
357 pub fn wakeup_fd() -> i32 {
358 -1
359 }
360 pub fn drain_wakeup() {}
361}
362
363/// Install SIGTERM/SIGINT/SIGCHLD/SIGPIPE handlers + the self-pipe. Call once
364/// at supervisor startup.
365pub fn install() {
366 imp::install();
367}
368
369/// Has a graceful drain been requested (first SIGTERM/SIGINT)?
370pub fn draining() -> bool {
371 imp::draining()
372}
373
374/// Has a forced shutdown been requested (second SIGTERM/SIGINT)?
375pub fn force() -> bool {
376 imp::force()
377}
378
379/// Request a graceful drain programmatically (the `drain` operator tool,
380/// RFC 0015 §4.1) — the same one-way `DRAINING` latch SIGTERM sets, plus a
381/// reactor wakeup. Idempotent/monotonic; never escalates to FORCE.
382pub fn request_drain() {
383 imp::request_drain()
384}
385
386/// Is the lame-duck readiness override active (RFC 0015 §4.2)? When true,
387/// `/readyz` reports NotReady even though the supervisor keeps running.
388pub fn lame_duck() -> bool {
389 imp::lame_duck()
390}
391
392/// Set or clear the lame-duck readiness override (the `lame-duck` operator tool,
393/// RFC 0015 §4.2). `true` overrides readiness toward NotReady; `false` clears it.
394pub fn set_lame_duck(on: bool) {
395 imp::set_lame_duck(on)
396}
397
398/// Is the instance-wide pause active (RFC 0015 §4.3)? When true, the agentic
399/// loops are suspended at their turn boundaries; the supervisor and readiness
400/// are unaffected.
401pub fn paused() -> bool {
402 imp::paused()
403}
404
405/// Set or clear the instance-wide pause state (the `pause`/`resume` operator
406/// tools, RFC 0015 §4.3). Reporting truth for `agentd://inventory` + the
407/// `agentd_paused` gauge; the per-session pause channels do the suspension.
408pub fn set_paused(on: bool) {
409 imp::set_paused(on)
410}
411
412/// Is the intelligence channel all-endpoints-down (RFC 0018 §6)? The latched,
413/// EVENTUALLY-CONSISTENT last-child-experience truth a child reports up via
414/// `AgentMsg::IntelHealth` — read by `/readyz` (flips NotReady), the
415/// `agentd_intel_all_down` gauge, and the `agentd://intelligence`/`capacity`
416/// bodies. NOT a live supervisor-side probe (there is no model loop in the
417/// supervisor): it reflects whichever child last exercised the endpoints.
418pub fn intel_all_down() -> bool {
419 imp::intel_all_down()
420}
421
422/// Latch the intelligence all-endpoints-down state from a child's `AgentMsg::
423/// IntelHealth` report (RFC 0018 §6). Returns `true` iff the value TRANSITIONED,
424/// so the supervisor can fire the `agentd://intelligence` notify exactly on a
425/// breaker enter/exit. Eventually-consistent / last-child-experience: a fresh
426/// spawn has fresh breakers, so this reflects the most recent child's reachability
427/// and persists between reactions — the right "route work here?" signal, not a
428/// continuous probe.
429pub fn set_intel_all_down(on: bool) -> bool {
430 imp::set_intel_all_down(on)
431}
432
433/// Take-and-clear the SIGCHLD flag — true if a child exited since last checked.
434pub fn take_child_exit() -> bool {
435 imp::take_child_exit()
436}
437
438/// Has a hot reload been requested (SIGHUP, RFC 0017 §5.2)? The reactive
439/// supervisor consults this each tick; a drain supersedes it (the caller checks
440/// `draining()` first). Always `false` on a build without the `hot-reload`
441/// feature (the handler that sets it is feature-gated).
442pub fn reload_requested() -> bool {
443 imp::reload_requested()
444}
445
446/// Clear the hot-reload latch once the reload routine has run (applied or
447/// rejected), or when a drain supersedes the request. Idempotent.
448pub fn clear_reload() {
449 imp::clear_reload()
450}
451
452/// Programmatically request a hot reload (the same RELOAD latch SIGHUP sets) +
453/// a reactor wakeup. Parity with `request_drain`; honoured only by a
454/// `hot-reload` build's reactive loop.
455pub fn request_reload() {
456 imp::request_reload()
457}
458
459/// Request a hot reload attributed to the **file-watch** trigger (RFC 0017 §5.2):
460/// the same RELOAD latch SIGHUP/`request_reload` set, plus the watch-attribution
461/// flag the apply step reads for the `config.reload_requested{trigger:"watch"}`
462/// event (§5.6). Called by the inotify watcher thread (`config-watch`).
463pub fn request_reload_from_watch() {
464 imp::request_reload_from_watch()
465}
466
467/// Take-and-clear the watch-attribution flag — `true` if the pending reload came
468/// from the file-watch trigger (RFC 0017 §5.2), `false` (the default) for SIGHUP
469/// or a programmatic `request_reload`. The reactive apply step calls this once per
470/// reload to label the `config.reload_requested` `trigger` (§5.6).
471pub fn take_reload_was_watch() -> bool {
472 imp::take_reload_was_watch()
473}
474
475/// Is a validated reload mid-apply (RFC 0017 §5.3 step 3)? The served
476/// `subagent.spawn` chokepoint reads this to transiently refuse NEW spawns while
477/// the reloadable diff is being applied. Always `false` off the `hot-reload` path
478/// (only the reactive apply step ever sets it).
479pub fn reloading() -> bool {
480 imp::reloading()
481}
482
483/// Set or clear the reload-in-progress guard. The reactive apply step brackets
484/// its reloadable-diff application with `set_reloading(true)` then `(false)`.
485pub fn set_reloading(on: bool) {
486 imp::set_reloading(on)
487}
488
489/// The read end of the self-pipe — the reactor waits on it for prompt wakeups.
490/// Returns -1 before `install()` (or on non-Unix).
491pub fn wakeup_fd() -> i32 {
492 imp::wakeup_fd()
493}
494
495/// Drain pending wakeup bytes after a wake.
496pub fn drain_wakeup() {
497 imp::drain_wakeup()
498}
499
500// ── Test isolation for the process-global signal state ──────────────────────
501// `DRAINING` is a one-way latch and `PAUSED`/`LAME_DUCK`/`RELOADING`/
502// `INTEL_ALL_DOWN` are process-global, so tests that touch them race + poison
503// each other when cargo
504// runs them in parallel within one test binary (e.g. a drain test leaves
505// `DRAINING` set, breaking every later readiness assertion). Every test that
506// reads OR writes this state takes `test_guard()`: it serializes them on one
507// mutex and resets the state to a clean slate for the test body.
508#[cfg(test)]
509static SIGNALS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
510
511/// Reset every process-global signal latch to its initial (unset) state.
512/// Test-only; called under the [`test_guard`] lock.
513#[cfg(test)]
514pub fn reset_for_test() {
515 imp::clear_drain_for_test();
516 set_lame_duck(false);
517 set_paused(false);
518 set_reloading(false);
519 clear_reload();
520 // The intel all-down latch is process-global too (set by a child's IntelHealth
521 // report); clear it so an all-down readiness/gauge test cannot poison a later
522 // readiness test sharing this process.
523 let _ = set_intel_all_down(false);
524 // Clear the watch-attribution latch too (set by `request_reload_from_watch`),
525 // so a watcher test cannot leak `trigger:"watch"` into a later reload test.
526 let _ = take_reload_was_watch();
527}
528
529/// RAII guard from [`test_guard`]. Resets the signal state on BOTH acquire and
530/// drop — the drop reset runs while the mutex is still held (the inner
531/// `MutexGuard` field drops after this `Drop::drop`), so a test that latches
532/// `DRAINING` cannot leak it to the next test between lock-release and the next
533/// acquire's reset.
534#[cfg(test)]
535pub struct SignalsTestGuard(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
536
537#[cfg(test)]
538impl Drop for SignalsTestGuard {
539 fn drop(&mut self) {
540 reset_for_test();
541 }
542}
543
544/// Serialize + clean-slate a test that touches the process-global signal state.
545/// `let _g = crate::signals::test_guard();` at the top of the test, held for the
546/// whole body, so no other signals-touching test interleaves. State is reset on
547/// entry AND on drop (under the lock), so nothing leaks across tests. Recovers a
548/// poisoned lock (a panicking test should not wedge the rest of the suite).
549#[cfg(test)]
550pub fn test_guard() -> SignalsTestGuard {
551 let g = SIGNALS_TEST_LOCK
552 .lock()
553 .unwrap_or_else(|poison| poison.into_inner());
554 reset_for_test();
555 SignalsTestGuard(g)
556}