Skip to main content

oxihuman_core/
async_signal.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Async signal primitive for cooperative, thread-safe signaling.
5//!
6//! [`AsyncSignal`] wraps an `Arc<(Mutex<SignalInner>, Condvar)>` so that multiple
7//! threads can share the same signal instance by cloning it, while still being able
8//! to block-wait on `signal_wait` / `signal_wait_timeout`.
9
10use std::sync::{Arc, Condvar, Mutex};
11use std::time::Duration;
12
13/// Internal state protected by the mutex.
14#[derive(Debug)]
15struct SignalInner {
16    is_set: bool,
17    count: usize,
18}
19
20impl SignalInner {
21    fn new() -> Self {
22        Self {
23            is_set: false,
24            count: 0,
25        }
26    }
27}
28
29/// Thread-safe signal primitive.
30///
31/// Cloning an [`AsyncSignal`] produces a handle that shares the same underlying
32/// state — both the original and every clone observe the same `is_set` value and
33/// wait on the same condition variable.
34#[derive(Debug, Clone)]
35pub struct AsyncSignal {
36    name: String,
37    inner: Arc<(Mutex<SignalInner>, Condvar)>,
38}
39
40// ──────────────────────────────────────────────────────────────────────────────
41// Helpers
42// ──────────────────────────────────────────────────────────────────────────────
43
44/// Acquire the mutex, recovering from poisoning by taking the inner value.
45#[inline]
46fn lock_inner(pair: &Arc<(Mutex<SignalInner>, Condvar)>) -> std::sync::MutexGuard<'_, SignalInner> {
47    pair.0
48        .lock()
49        .unwrap_or_else(|poisoned| poisoned.into_inner())
50}
51
52// ──────────────────────────────────────────────────────────────────────────────
53// Public API
54// ──────────────────────────────────────────────────────────────────────────────
55
56/// Construct a new, unset [`AsyncSignal`] with the given name.
57pub fn new_async_signal(name: &str) -> AsyncSignal {
58    AsyncSignal {
59        name: name.to_string(),
60        inner: Arc::new((Mutex::new(SignalInner::new()), Condvar::new())),
61    }
62}
63
64/// Set the signal, increment the count, and wake every thread blocked in
65/// `signal_wait` or `signal_wait_timeout`.
66pub fn signal_set(sig: &AsyncSignal) {
67    let (mutex, condvar) = sig.inner.as_ref();
68    let mut guard = mutex
69        .lock()
70        .unwrap_or_else(|poisoned| poisoned.into_inner());
71    guard.is_set = true;
72    guard.count += 1;
73    drop(guard);
74    condvar.notify_all();
75}
76
77/// Clear the signal.  Threads that are already blocked in `signal_wait` will
78/// continue to block until the signal is set again.
79pub fn signal_reset(sig: &AsyncSignal) {
80    let mut guard = lock_inner(&sig.inner);
81    guard.is_set = false;
82}
83
84/// Block the calling thread until `sig` is set.
85///
86/// Returns `true` once the signal has been observed as set.  If the signal is
87/// already set when this function is called it returns immediately.
88pub fn signal_wait(sig: &AsyncSignal) -> bool {
89    let (mutex, condvar) = sig.inner.as_ref();
90    let mut guard = mutex
91        .lock()
92        .unwrap_or_else(|poisoned| poisoned.into_inner());
93    while !guard.is_set {
94        guard = condvar
95            .wait(guard)
96            .unwrap_or_else(|poisoned| poisoned.into_inner());
97    }
98    true
99}
100
101/// Block the calling thread for at most `timeout_ms` milliseconds waiting for
102/// `sig` to be set.
103///
104/// Returns `true` if the signal was observed as set before the timeout expired,
105/// or `false` if the timeout elapsed first.
106pub fn signal_wait_timeout(sig: &AsyncSignal, timeout_ms: u64) -> bool {
107    let (mutex, condvar) = sig.inner.as_ref();
108    let timeout = Duration::from_millis(timeout_ms);
109    let mut guard = mutex
110        .lock()
111        .unwrap_or_else(|poisoned| poisoned.into_inner());
112    // Short-circuit if already set.
113    if guard.is_set {
114        return true;
115    }
116    let result = condvar
117        .wait_timeout_while(guard, timeout, |inner| !inner.is_set)
118        .unwrap_or_else(|poisoned| poisoned.into_inner());
119    // result.0 is the MutexGuard, result.1.timed_out() is true when we hit the deadline.
120    guard = result.0;
121    guard.is_set
122}
123
124/// Non-blocking poll — returns the current `is_set` value without blocking.
125pub fn signal_is_set(sig: &AsyncSignal) -> bool {
126    lock_inner(&sig.inner).is_set
127}
128
129/// Compatibility shim (non-blocking poll).  Prefer [`signal_is_set`].
130#[deprecated(
131    since = "0.1.3",
132    note = "use signal_is_set for non-blocking poll or signal_wait to block"
133)]
134pub fn signal_wait_stub(sig: &AsyncSignal) -> bool {
135    signal_is_set(sig)
136}
137
138/// Return a clone of the signal's name.
139pub fn signal_name_as(sig: &AsyncSignal) -> String {
140    sig.name.clone()
141}
142
143/// Return the number of times the signal has been set.
144pub fn signal_count_as(sig: &AsyncSignal) -> usize {
145    lock_inner(&sig.inner).count
146}
147
148/// Serialize the signal state to a minimal JSON string.
149pub fn signal_to_json(sig: &AsyncSignal) -> String {
150    let guard = lock_inner(&sig.inner);
151    format!(
152        r#"{{"name":"{}","is_set":{},"count":{}}}"#,
153        sig.name, guard.is_set, guard.count
154    )
155}
156
157// ──────────────────────────────────────────────────────────────────────────────
158// Tests
159// ──────────────────────────────────────────────────────────────────────────────
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use std::sync::atomic::{AtomicBool, Ordering};
165    use std::thread;
166    use std::time::Instant;
167
168    // ── existing tests updated for new API ────────────────────────────────────
169
170    #[test]
171    fn test_new_async_signal() {
172        let s = new_async_signal("test");
173        assert_eq!(signal_name_as(&s), "test");
174        assert!(!signal_is_set(&s));
175    }
176
177    #[test]
178    fn test_signal_set_and_check() {
179        let s = new_async_signal("sig");
180        signal_set(&s);
181        assert!(signal_is_set(&s));
182    }
183
184    #[test]
185    fn test_signal_reset() {
186        let s = new_async_signal("sig");
187        signal_set(&s);
188        signal_reset(&s);
189        assert!(!signal_is_set(&s));
190    }
191
192    #[test]
193    #[allow(deprecated)]
194    fn test_signal_wait_stub_deprecated() {
195        let s = new_async_signal("sig");
196        assert!(!signal_wait_stub(&s));
197        signal_set(&s);
198        assert!(signal_wait_stub(&s));
199    }
200
201    #[test]
202    fn test_signal_count() {
203        let s = new_async_signal("sig");
204        signal_set(&s);
205        signal_set(&s);
206        assert_eq!(signal_count_as(&s), 2);
207    }
208
209    #[test]
210    fn test_signal_name() {
211        let s = new_async_signal("my_signal");
212        assert_eq!(signal_name_as(&s), "my_signal");
213    }
214
215    #[test]
216    fn test_signal_to_json() {
217        let s = new_async_signal("j");
218        let json = signal_to_json(&s);
219        assert!(json.contains("\"name\":\"j\""));
220        assert!(json.contains("\"is_set\":false"));
221    }
222
223    #[test]
224    fn test_signal_to_json_after_set() {
225        let s = new_async_signal("j");
226        signal_set(&s);
227        let json = signal_to_json(&s);
228        assert!(json.contains("\"is_set\":true"));
229        assert!(json.contains("\"count\":1"));
230    }
231
232    #[test]
233    fn test_multiple_reset_cycles() {
234        let s = new_async_signal("cycle");
235        signal_set(&s);
236        signal_reset(&s);
237        signal_set(&s);
238        assert!(signal_is_set(&s));
239        assert_eq!(signal_count_as(&s), 2);
240    }
241
242    #[test]
243    fn test_initial_count_zero() {
244        let s = new_async_signal("z");
245        assert_eq!(signal_count_as(&s), 0);
246    }
247
248    // ── new blocking-wait tests ───────────────────────────────────────────────
249
250    /// Spawns a thread that calls `signal_set` after a short delay.
251    /// `signal_wait` must block until that set happens and then return `true`.
252    #[test]
253    fn test_signal_wait_blocks_until_set() {
254        let sig = new_async_signal("blocking");
255        let sig_thread = sig.clone();
256
257        // Flipped to `true` by the setter thread immediately before it calls
258        // `signal_set`. Because `signal_set` and `signal_wait` synchronise
259        // through the same mutex, observing the signal as set establishes a
260        // happens-before edge: once `signal_wait` returns we are guaranteed to
261        // read this as `true`. That makes the "did not return early" check
262        // deterministic, instead of relying on a racy elapsed-time lower bound
263        // (which flaked under heavy parallel load when the main thread was
264        // descheduled before it could record the start instant).
265        let setter_fired = Arc::new(AtomicBool::new(false));
266        let setter_fired_thread = Arc::clone(&setter_fired);
267
268        let handle = thread::spawn(move || {
269            // Brief delay so the waiter is very likely parked in `signal_wait`
270            // first — exercises the blocking path, but is not required for the
271            // correctness of the assertions below.
272            thread::sleep(Duration::from_millis(10));
273            setter_fired_thread.store(true, Ordering::Release);
274            signal_set(&sig_thread);
275        });
276
277        let t0 = Instant::now();
278        let result = signal_wait(&sig);
279        let elapsed = t0.elapsed();
280
281        handle.join().expect("thread panicked");
282
283        assert!(result, "signal_wait must return true");
284        // Deterministic ordering check: `signal_wait` cannot have returned
285        // before the setter ran `signal_set`, so the store above is visible.
286        assert!(
287            setter_fired.load(Ordering::Acquire),
288            "signal_wait returned before the setter called signal_set"
289        );
290        // Liveness guard: must wake up, not deadlock. Generous bound so it
291        // never fires from mere scheduler jitter under heavy parallel load.
292        assert!(
293            elapsed < Duration::from_secs(5),
294            "signal_wait took too long: {elapsed:?}"
295        );
296    }
297
298    /// `signal_wait_timeout` on a permanently-unset signal must return `false`.
299    #[test]
300    fn test_signal_wait_timeout_fires() {
301        let sig = new_async_signal("timeout_miss");
302        let t0 = Instant::now();
303        let result = signal_wait_timeout(&sig, 50);
304        let elapsed = t0.elapsed();
305
306        assert!(!result, "must return false on timeout");
307        // Must have waited at least ~50 ms (give 20 ms of scheduler slack).
308        assert!(
309            elapsed >= Duration::from_millis(30),
310            "returned too early: {elapsed:?}"
311        );
312        // Must not have spun for an excessive amount of time.
313        assert!(
314            elapsed < Duration::from_millis(2_000),
315            "took too long: {elapsed:?}"
316        );
317    }
318
319    /// `signal_wait_timeout` returns `true` when the signal is set before the
320    /// deadline.
321    #[test]
322    fn test_signal_wait_timeout_succeeds() {
323        let sig = new_async_signal("timeout_hit");
324        let sig_thread = sig.clone();
325
326        let handle = thread::spawn(move || {
327            thread::sleep(Duration::from_millis(20));
328            signal_set(&sig_thread);
329        });
330
331        // 5 s deadline — returns as soon as the 20 ms set fires; huge margin avoids load-induced flakiness.
332        let result = signal_wait_timeout(&sig, 5_000);
333        handle.join().expect("thread panicked");
334
335        assert!(result, "must return true when signaled before timeout");
336    }
337
338    /// Setting on a clone must be visible from the original, and vice-versa.
339    #[test]
340    fn test_signal_clone_shared() {
341        let original = new_async_signal("shared");
342        let clone = original.clone();
343
344        // Set via the clone — original must see it immediately.
345        signal_set(&clone);
346        assert!(signal_is_set(&original), "original must see set via clone");
347        assert_eq!(signal_count_as(&original), 1);
348
349        // Reset via the original — clone must see it.
350        signal_reset(&original);
351        assert!(!signal_is_set(&clone), "clone must see reset via original");
352
353        // Set via original — clone must see it.
354        signal_set(&original);
355        assert!(signal_is_set(&clone), "clone must see set via original");
356        assert_eq!(signal_count_as(&clone), 2);
357    }
358
359    /// Multiple waiters should all unblock when the signal is set.
360    #[test]
361    fn test_signal_wait_multiple_waiters() {
362        let sig = new_async_signal("multi");
363        let n_threads = 4_usize;
364
365        let handles: Vec<_> = (0..n_threads)
366            .map(|_| {
367                let s = sig.clone();
368                thread::spawn(move || signal_wait(&s))
369            })
370            .collect();
371
372        // Let all threads reach their wait before we fire.
373        thread::sleep(Duration::from_millis(20));
374        signal_set(&sig);
375
376        let results: Vec<bool> = handles
377            .into_iter()
378            .map(|h| h.join().expect("thread panicked"))
379            .collect();
380
381        assert!(
382            results.iter().all(|&r| r),
383            "all waiters must return true: {results:?}"
384        );
385    }
386}