Skip to main content

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}