1use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11pub const DEFAULT: Duration = Duration::from_secs(30);
13
14struct State {
15 deadline: Instant,
16 stage: Mutex<String>,
17 failed: Mutex<Option<String>>,
18}
19
20tokio::task_local! {
21 static CALL: Arc<State>;
22}
23
24thread_local! {
25 static THREAD: std::cell::RefCell<Option<Arc<State>>> =
26 const { std::cell::RefCell::new(None) };
27}
28
29pub struct ThreadGuard {
31 previous: Option<Arc<State>>,
32}
33
34impl Drop for ThreadGuard {
35 fn drop(&mut self) {
36 let previous = self.previous.take();
37 THREAD.with(|slot| *slot.borrow_mut() = previous);
38 }
39}
40
41pub struct Ticket(Arc<State>);
43
44impl Ticket {
45 #[must_use]
47 pub fn bind(self) -> ThreadGuard {
48 bind_thread(self.0)
49 }
50}
51
52fn bind_thread(state: Arc<State>) -> ThreadGuard {
53 let previous = THREAD.with(|slot| slot.replace(Some(state)));
54 ThreadGuard { previous }
55}
56
57fn current() -> Option<Arc<State>> {
58 if let Some(state) = THREAD.with(|slot| slot.borrow().clone()) {
59 return Some(state);
60 }
61 CALL.try_with(Arc::clone).ok()
62}
63
64fn locked<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
65 mutex
66 .lock()
67 .unwrap_or_else(std::sync::PoisonError::into_inner)
68}
69
70#[must_use]
72pub fn budget() -> Duration {
73 if let Some(ms) = positive("LJOS_MCP_TIMEOUT_MS") {
74 return Duration::from_millis(ms);
75 }
76 if let Some(secs) = positive("LJOS_MCP_TIMEOUT") {
77 return Duration::from_secs(secs);
78 }
79 DEFAULT
80}
81
82fn positive(key: &str) -> Option<u64> {
83 std::env::var(key)
84 .ok()
85 .and_then(|value| value.trim().parse().ok())
86 .filter(|n| *n > 0)
87}
88
89#[must_use]
91pub fn armed() -> bool {
92 current().is_some()
93}
94
95#[must_use]
97pub fn deadline_instant() -> Option<Instant> {
98 current().map(|state| state.deadline)
99}
100
101#[must_use]
103pub fn remaining() -> Option<Duration> {
104 deadline_instant().map(|deadline| deadline.saturating_duration_since(Instant::now()))
105}
106
107#[must_use]
109pub fn ticket() -> Option<Ticket> {
110 current().map(Ticket)
111}
112
113pub fn note(what: &str) {
115 if let Some(state) = current() {
116 *locked(&state.stage) = what.to_string();
117 }
118}
119
120pub fn stage(what: &str) -> StageGuard {
122 let previous = current().map(|state| locked(&state.stage).clone());
123 note(what);
124 StageGuard { previous }
125}
126
127pub struct StageGuard {
129 previous: Option<String>,
130}
131
132impl Drop for StageGuard {
133 fn drop(&mut self) {
134 if remaining().is_some_and(|left| left.is_zero()) {
135 return;
136 }
137 if let Some(previous) = self.previous.take() {
138 note(&previous);
139 }
140 }
141}
142
143pub fn mark_failed(line: impl Into<String>) {
145 let line = line.into();
146 if let Some(state) = current() {
147 let mut slot = locked(&state.failed);
148 if slot.is_none() {
149 *slot = Some(line);
150 }
151 }
152}
153
154#[must_use]
156pub fn failure() -> Option<String> {
157 current().and_then(|state| locked(&state.failed).clone())
158}
159
160#[must_use]
162pub fn timeout_line() -> String {
163 let what = current()
164 .map(|state| locked(&state.stage).clone())
165 .filter(|what| !what.is_empty())
166 .unwrap_or_else(|| "tool".to_string());
167 format!("{what} timed out")
168}
169
170pub async fn scope<F: std::future::Future>(tool: &str, fut: F) -> F::Output {
172 let state = Arc::new(State {
173 deadline: Instant::now() + budget(),
174 stage: Mutex::new(tool.to_string()),
175 failed: Mutex::new(None),
176 });
177 CALL.scope(state, fut).await
178}
179
180pub async fn blocking<T: Send + 'static>(
182 f: impl FnOnce() -> T + Send + 'static,
183) -> Result<T, tokio::task::JoinError> {
184 let ticket = ticket();
185 tokio::task::spawn_blocking(move || {
186 let _guard = ticket.map(Ticket::bind);
187 f()
188 })
189 .await
190}
191
192#[cfg(test)]
194#[must_use]
195pub(crate) fn arm(total: Duration) -> ThreadGuard {
196 bind_thread(Arc::new(State {
197 deadline: Instant::now() + total,
198 stage: Mutex::new(String::new()),
199 failed: Mutex::new(None),
200 }))
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn the_tool_budget_is_thirty_seconds_unless_configured() {
209 let _g = crate::tests::env_guard();
210 unsafe {
211 std::env::remove_var("LJOS_MCP_TIMEOUT");
212 std::env::remove_var("LJOS_MCP_TIMEOUT_MS");
213 }
214 assert_eq!(budget(), Duration::from_secs(30));
215 unsafe { std::env::set_var("LJOS_MCP_TIMEOUT", "12") };
216 assert_eq!(budget(), Duration::from_secs(12));
217 unsafe { std::env::set_var("LJOS_MCP_TIMEOUT_MS", "250") };
218 assert_eq!(budget(), Duration::from_millis(250));
219 }
220
221 #[test]
223 fn a_silent_packsetd_stops_inside_the_tool_budget() {
224 let _g = crate::tests::env_guard();
225 let home = tempfile::tempdir().unwrap();
226 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
227 let addr = listener.local_addr().unwrap();
228 std::thread::spawn(move || {
229 for stream in listener.incoming() {
230 let Ok(mut stream) = stream else { break };
231 let mut buf = [0u8; 1024];
232 let _ = std::io::Read::read(&mut stream, &mut buf);
233 std::thread::sleep(Duration::from_secs(30));
234 }
235 });
236 unsafe {
237 std::env::set_var("HOME", home.path());
238 std::env::set_var("PACKSET_URL", format!("http://{addr}"));
239 std::env::remove_var("PACKSET_TOKEN");
240 std::env::remove_var("PACKSET_TOKEN_FILE");
241 std::env::remove_var("PACKSET_TIMEOUT_MS");
242 std::env::remove_var("LJOS_IN_HOOK");
243 }
244 let _bound = arm(Duration::from_millis(400));
245 let started = Instant::now();
246 let err = crate::pack_link::PacksetClient::from_env()
247 .expect("url")
248 .status(None)
249 .expect_err("a silent writer answers");
250 assert!(
251 started.elapsed() < Duration::from_secs(2),
252 "waited {:?}",
253 started.elapsed()
254 );
255 let text = err.to_string();
256 assert!(text.contains("packsetd timed out"), "{text}");
257 }
258
259 #[test]
261 fn a_hung_subprocess_stops_inside_the_tool_budget() {
262 let _g = crate::tests::env_guard();
263 let _bound = arm(Duration::from_millis(400));
264 let started = Instant::now();
265 let err = crate::run_captured("sh", &["-c", "exec sleep 30"]).unwrap_err();
266 assert!(
267 started.elapsed() < Duration::from_secs(2),
268 "habitat child waited {:?}",
269 started.elapsed()
270 );
271 assert!(err.to_string().contains("sh timed out"), "{err}");
272
273 let _bound = arm(Duration::from_millis(400));
274 let started = Instant::now();
275 let mut cmd = std::process::Command::new("sh");
276 cmd.args(["-c", "exec sleep 30"]);
277 let err = crate::bounded_output(&mut cmd).unwrap_err();
278 assert!(
279 started.elapsed() < Duration::from_secs(2),
280 "git-shaped child waited {:?}",
281 started.elapsed()
282 );
283 assert!(err.to_string().contains("sh timed out"), "{err}");
284 }
285
286 #[test]
288 fn a_held_lock_stops_inside_the_tool_budget() {
289 let _g = crate::tests::env_guard();
290 let dir = tempfile::tempdir().unwrap();
291 let path = dir.path().join("held.lock");
292 let held = std::fs::OpenOptions::new()
293 .create(true)
294 .append(true)
295 .open(&path)
296 .unwrap();
297 {
298 use std::os::unix::io::AsRawFd;
299 assert_eq!(unsafe { libc::flock(held.as_raw_fd(), libc::LOCK_EX) }, 0);
300 }
301 let _bound = arm(Duration::from_millis(300));
302 let started = Instant::now();
303 let _lock = crate::CommitLock::acquire(&path);
304 assert!(
305 started.elapsed() < Duration::from_secs(2),
306 "lock waited {:?}",
307 started.elapsed()
308 );
309 assert_eq!(failure().as_deref(), Some("lock timed out"));
310 }
311}