Skip to main content

lean_ctx/core/
bounded_lock.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::sync::Arc;
3use std::time::Duration;
4use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
5
6const BASE_READ_TIMEOUT: Duration = Duration::from_secs(10);
7const BASE_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
8
9/// Spin interval between `try_*` attempts. Kept short enough for responsiveness
10/// but long enough to avoid busy-spinning on Windows where thread scheduling
11/// quanta are ~15ms.
12const SPIN_INTERVAL: Duration = Duration::from_millis(20);
13
14/// Determines how an operation handles a bounded-lock timeout.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
16pub enum LockFailBehavior {
17    /// Return a [`LockTimeoutError`] when the operation cannot safely continue.
18    ReturnError,
19    /// Return the wrapper's default value (`Ok(None)`) when the operation is optional.
20    #[default]
21    ReturnDefault,
22    /// Wait through one additional timeout window before applying the default value.
23    RetryOnce,
24}
25
26/// Details of a lock acquisition timeout returned by [`LockFailBehavior::ReturnError`].
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct LockTimeoutError {
29    /// Whether the timed-out acquisition was for a read or write guard.
30    pub operation: &'static str,
31    /// Caller-provided description of the operation that needed the lock.
32    pub context: String,
33    /// Timeout applied to the final acquisition attempt.
34    pub timeout: Duration,
35}
36
37impl std::fmt::Display for LockTimeoutError {
38    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(
40            formatter,
41            "bounded_lock: {} timeout ({}ms) for {}",
42            self.operation,
43            self.timeout.as_millis(),
44            self.context
45        )
46    }
47}
48
49impl std::error::Error for LockTimeoutError {}
50
51/// Acquire a read lock via a non-blocking spin loop with an adaptive timeout.
52///
53/// Unlike the previous `Handle::block_on` approach, this never parks a blocking
54/// thread waiting for the async runtime to make progress — eliminating the
55/// stall-under-load anti-pattern on Windows (#1018). The loop yields the thread
56/// between attempts so it does not starve other work on the blocking pool.
57///
58/// Returns `None` on timeout, preserving the historical default behavior.
59pub fn read<T: Send + Sync + 'static>(
60    lock: &Arc<RwLock<T>>,
61    context: &str,
62) -> Option<OwnedRwLockReadGuard<T>> {
63    read_with_behavior(lock, context, LockFailBehavior::default())
64        .ok()
65        .flatten()
66}
67
68/// Acquire a read lock within an explicit hard budget.
69///
70/// Use this on latency-critical request paths where adaptive I/O recovery must
71/// not turn a best-effort session update into a multi-second user-visible wait.
72pub fn read_for<T: Send + Sync + 'static>(
73    lock: &Arc<RwLock<T>>,
74    context: &str,
75    timeout: Duration,
76) -> Option<OwnedRwLockReadGuard<T>> {
77    acquire_with_behavior(
78        lock,
79        context,
80        "read",
81        timeout,
82        LockFailBehavior::ReturnDefault,
83        |lock| lock.clone().try_read_owned().ok(),
84    )
85    .ok()
86    .flatten()
87}
88
89/// Acquire a read lock with explicit timeout behavior for this operation.
90pub fn read_with_behavior<T: Send + Sync + 'static>(
91    lock: &Arc<RwLock<T>>,
92    context: &str,
93    behavior: LockFailBehavior,
94) -> Result<Option<OwnedRwLockReadGuard<T>>, LockTimeoutError> {
95    let timeout = crate::core::io_health::adaptive_timeout(BASE_READ_TIMEOUT);
96    acquire_with_behavior(lock, context, "read", timeout, behavior, |lock| {
97        lock.clone().try_read_owned().ok()
98    })
99}
100
101/// Acquire a write lock with explicit timeout behavior for this operation.
102pub fn write_with_behavior<T: Send + Sync + 'static>(
103    lock: &Arc<RwLock<T>>,
104    context: &str,
105    behavior: LockFailBehavior,
106) -> Result<Option<OwnedRwLockWriteGuard<T>>, LockTimeoutError> {
107    let timeout = crate::core::io_health::adaptive_timeout(BASE_WRITE_TIMEOUT);
108    acquire_with_behavior(lock, context, "write", timeout, behavior, |lock| {
109        lock.clone().try_write_owned().ok()
110    })
111}
112
113fn acquire_with_behavior<T, Guard, Acquire>(
114    lock: &Arc<RwLock<T>>,
115    context: &str,
116    operation: &'static str,
117    timeout: Duration,
118    behavior: LockFailBehavior,
119    acquire: Acquire,
120) -> Result<Option<Guard>, LockTimeoutError>
121where
122    Acquire: Fn(&Arc<RwLock<T>>) -> Option<Guard>,
123{
124    let attempts = if behavior == LockFailBehavior::RetryOnce {
125        2
126    } else {
127        1
128    };
129
130    for attempt in 1..=attempts {
131        let deadline = std::time::Instant::now() + timeout;
132
133        loop {
134            if let Some(guard) = acquire(lock) {
135                return Ok(Some(guard));
136            }
137            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
138            if !remaining.is_zero() {
139                std::thread::sleep(remaining.min(SPIN_INTERVAL));
140                continue;
141            }
142
143            crate::core::io_health::record_freeze();
144            if attempt < attempts {
145                tracing::warn!(
146                    "bounded_lock: {operation} timeout ({}ms) for {context}; retrying once",
147                    timeout.as_millis()
148                );
149                break;
150            }
151
152            let error = LockTimeoutError {
153                operation,
154                context: context.to_owned(),
155                timeout,
156            };
157            tracing::warn!("{error}; degrading gracefully");
158            return match behavior {
159                LockFailBehavior::ReturnError => Err(error),
160                LockFailBehavior::ReturnDefault | LockFailBehavior::RetryOnce => Ok(None),
161            };
162        }
163    }
164
165    unreachable!("RetryOnce returns after its second acquisition attempt")
166}
167
168/// Acquire a write lock via a non-blocking spin loop with an adaptive timeout.
169/// See `read()` for design rationale (#1018).
170///
171/// Returns `None` on timeout, preserving the historical default behavior.
172pub fn write<T: Send + Sync + 'static>(
173    lock: &Arc<RwLock<T>>,
174    context: &str,
175) -> Option<OwnedRwLockWriteGuard<T>> {
176    write_with_behavior(lock, context, LockFailBehavior::default())
177        .ok()
178        .flatten()
179}
180
181/// Acquire a write lock within an explicit hard budget.
182///
183/// This is the write counterpart to [`read_for`]. A timeout must degrade the
184/// optional bookkeeping, never keep an interactive tool request queued.
185pub fn write_for<T: Send + Sync + 'static>(
186    lock: &Arc<RwLock<T>>,
187    context: &str,
188    timeout: Duration,
189) -> Option<OwnedRwLockWriteGuard<T>> {
190    acquire_with_behavior(
191        lock,
192        context,
193        "write",
194        timeout,
195        LockFailBehavior::ReturnDefault,
196        |lock| lock.clone().try_write_owned().ok(),
197    )
198    .ok()
199    .flatten()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::sync::atomic::{AtomicUsize, Ordering};
206
207    #[test]
208    fn read_succeeds_on_uncontested_lock() {
209        let lock = Arc::new(RwLock::new(42u32));
210        let guard = read(&lock, "test").expect("uncontested read must succeed");
211        assert_eq!(*guard, 42);
212    }
213
214    #[test]
215    fn write_succeeds_on_uncontested_lock() {
216        let lock = Arc::new(RwLock::new(0u32));
217        let mut guard = write(&lock, "test").expect("uncontested write must succeed");
218        *guard = 7;
219        assert_eq!(*guard, 7);
220    }
221
222    #[test]
223    fn multiple_readers_concurrent() {
224        let lock = Arc::new(RwLock::new(99u32));
225        let g1 = read(&lock, "r1").expect("first reader");
226        let g2 = read(&lock, "r2").expect("second reader");
227        assert_eq!(*g1, 99);
228        assert_eq!(*g2, 99);
229    }
230
231    #[test]
232    fn write_excludes_readers() {
233        let lock = Arc::new(RwLock::new(0u32));
234        let _hold = lock.clone().try_write_owned().unwrap();
235        assert!(lock.clone().try_read_owned().is_err());
236    }
237
238    #[test]
239    fn explicit_budget_never_waits_for_adaptive_default() {
240        let lock = Arc::new(RwLock::new(()));
241        let _hold = lock.clone().try_write_owned().expect("hold write lock");
242        assert!(read_for(&lock, "interactive read", Duration::ZERO).is_none());
243        assert!(write_for(&lock, "interactive write", Duration::ZERO).is_none());
244    }
245
246    #[test]
247    fn return_error_reports_lock_timeout() {
248        let lock = Arc::new(RwLock::new(()));
249        let result: Result<Option<()>, _> = acquire_with_behavior(
250            &lock,
251            "required operation",
252            "read",
253            Duration::ZERO,
254            LockFailBehavior::ReturnError,
255            |_| None,
256        );
257
258        let error = result.expect_err("ReturnError must expose the timeout");
259        assert_eq!(error.operation, "read");
260        assert_eq!(error.context, "required operation");
261    }
262
263    #[test]
264    fn return_default_suppresses_lock_timeout() {
265        let lock = Arc::new(RwLock::new(()));
266        let result: Result<Option<()>, _> = acquire_with_behavior(
267            &lock,
268            "optional operation",
269            "read",
270            Duration::ZERO,
271            LockFailBehavior::ReturnDefault,
272            |_| None,
273        );
274
275        assert_eq!(result, Ok(None));
276    }
277
278    #[test]
279    fn retry_once_makes_two_acquisition_attempts() {
280        let lock = Arc::new(RwLock::new(()));
281        let attempts = AtomicUsize::new(0);
282        let result: Result<Option<()>, _> = acquire_with_behavior(
283            &lock,
284            "retryable operation",
285            "read",
286            Duration::ZERO,
287            LockFailBehavior::RetryOnce,
288            |_| {
289                attempts.fetch_add(1, Ordering::Relaxed);
290                None
291            },
292        );
293
294        assert_eq!(result, Ok(None));
295        assert_eq!(attempts.load(Ordering::Relaxed), 2);
296    }
297}