1use rand::RngExt;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::sync::Mutex;
9use tracing::info;
10
11use crate::errors::{Result, WinxError};
12use crate::state::pty::{PtyShell, SharedPtyShell};
13
14#[derive(Debug, Clone)]
18pub struct ExitedShellInfo {
19 pub owner_thread_id: String,
20 pub last_command: String,
21 pub final_output: Arc<str>,
22 pub exit_code: Option<i32>,
23 pub cwd: PathBuf,
24 pub output_truncated: bool,
25 pub scratch_path: Option<PathBuf>,
26 pub exited_at: Instant,
27 pub workflow: Option<crate::runtime::workflow::RetainedWorkflow>,
28}
29
30#[derive(Debug)]
31struct BackgroundShellEntry {
32 owner_thread_id: String,
33 shell: SharedPtyShell,
34}
35
36#[derive(Debug, Default)]
38pub struct BackgroundShellManager {
39 shells: HashMap<String, BackgroundShellEntry>,
40 tombstones: HashMap<String, ExitedShellInfo>,
43}
44
45impl BackgroundShellManager {
46 const TOMBSTONE_TTL: Duration = Duration::from_secs(300);
48 const MAX_SHELLS: usize = 32;
49 const MAX_SHELLS_PER_THREAD: usize = 8;
50 const MAX_TOMBSTONES: usize = 32;
51 const MAX_TOMBSTONES_PER_THREAD: usize = 8;
52
53 pub fn new() -> Self {
55 Self { shells: HashMap::new(), tombstones: HashMap::new() }
56 }
57
58 pub fn register_shell(&mut self, owner_thread_id: &str, shell: PtyShell) -> Result<String> {
66 self.prune_finished_shells();
67 self.ensure_capacity(owner_thread_id)?;
68
69 let cid = format!("{:016x}", rand::rng().random::<u64>());
70 self.shells.insert(
71 cid.clone(),
72 BackgroundShellEntry {
73 owner_thread_id: owner_thread_id.to_string(),
74 shell: Arc::new(Mutex::new(Some(shell))),
75 },
76 );
77 info!("Started background shell with id: {}", cid);
78 Ok(cid)
79 }
80
81 fn ensure_capacity(&self, owner_thread_id: &str) -> Result<()> {
82 let owned =
83 self.shells.values().filter(|entry| entry.owner_thread_id == owner_thread_id).count();
84 if owned >= Self::MAX_SHELLS_PER_THREAD {
85 return Err(WinxError::CommandExecutionError(format!(
86 "Background shell limit reached for this thread ({}). Reuse or stop an existing shell.",
87 Self::MAX_SHELLS_PER_THREAD
88 )));
89 }
90 if self.shells.len() >= Self::MAX_SHELLS {
91 return Err(WinxError::CommandExecutionError(format!(
92 "Global background shell limit reached ({}). Wait for an existing shell to exit.",
93 Self::MAX_SHELLS
94 )));
95 }
96 Ok(())
97 }
98
99 pub fn get_shell(&self, owner_thread_id: &str, bg_command_id: &str) -> Option<SharedPtyShell> {
101 self.shells
102 .get(bg_command_id)
103 .filter(|entry| entry.owner_thread_id == owner_thread_id)
104 .map(|entry| entry.shell.clone())
105 }
106
107 pub fn remove_shell(&mut self, bg_command_id: &str) -> bool {
109 if let Some(entry) = self.shells.remove(bg_command_id) {
110 if let Ok(mut guard) = entry.shell.try_lock() {
111 *guard = None;
112 }
113 info!("Removed background shell: {}", bg_command_id);
114 true
115 } else {
116 false
117 }
118 }
119
120 pub(crate) fn prune_finished_shells(&mut self) {
121 let now = Instant::now();
123 self.tombstones.retain(|_, info| now.duration_since(info.exited_at) < Self::TOMBSTONE_TTL);
124
125 let mut finished: Vec<(String, Option<ExitedShellInfo>)> = Vec::new();
126
127 for (id, entry) in &self.shells {
128 if Arc::strong_count(&entry.shell) > 1 {
133 continue;
134 }
135 let Ok(mut guard) = entry.shell.try_lock() else {
136 continue;
137 };
138
139 let Some(shell) = guard.as_mut() else {
140 finished.push((id.clone(), None));
141 continue;
142 };
143
144 if !shell.is_alive() {
145 let tombstone = Self::exited_shell_info(&entry.owner_thread_id, shell, now);
146 finished.push((id.clone(), Some(tombstone)));
147 continue;
148 }
149
150 if shell.last_command.is_empty() {
155 continue;
156 }
157
158 if shell.command_running {
159 shell.poll_output_nonblocking();
163 }
164
165 if !shell.command_running {
166 let tombstone = Self::exited_shell_info(&entry.owner_thread_id, shell, now);
167 finished.push((id.clone(), Some(tombstone)));
168 }
169 }
170
171 for (id, tombstone) in finished {
172 self.remove_shell(&id);
173 if let Some(info) = tombstone {
174 self.insert_tombstone(id, info);
175 }
176 }
177 }
178
179 fn exited_shell_info(
180 owner_thread_id: &str,
181 shell: &mut PtyShell,
182 now: Instant,
183 ) -> ExitedShellInfo {
184 let activity_seq = shell.poll_workflow_output();
185 ExitedShellInfo {
186 owner_thread_id: owner_thread_id.to_string(),
187 last_command: shell.last_command.clone(),
188 final_output: Arc::from(std::mem::take(&mut shell.output_buffer)),
189 exit_code: shell.last_exit_code,
190 cwd: shell.current_cwd().to_path_buf(),
191 output_truncated: shell.output_truncated,
192 scratch_path: shell.scratch_path().map(Path::to_path_buf),
193 exited_at: now,
194 workflow: Some(shell.retained_workflow(activity_seq)),
195 }
196 }
197
198 fn insert_tombstone(&mut self, id: String, info: ExitedShellInfo) {
199 let owner = info.owner_thread_id.clone();
200 self.tombstones.insert(id, info);
201
202 while self.tombstones.values().filter(|entry| entry.owner_thread_id == owner).count()
203 > Self::MAX_TOMBSTONES_PER_THREAD
204 {
205 let oldest = self
206 .tombstones
207 .iter()
208 .filter(|(_, entry)| entry.owner_thread_id == owner)
209 .min_by_key(|(_, entry)| entry.exited_at)
210 .map(|(id, _)| id.clone());
211 if let Some(id) = oldest {
212 self.tombstones.remove(&id);
213 }
214 }
215
216 while self.tombstones.len() > Self::MAX_TOMBSTONES {
217 let oldest = self
218 .tombstones
219 .iter()
220 .min_by_key(|(_, entry)| entry.exited_at)
221 .map(|(id, _)| id.clone());
222 if let Some(id) = oldest {
223 self.tombstones.remove(&id);
224 }
225 }
226 }
227
228 pub fn peek_tombstone(
235 &self,
236 owner_thread_id: &str,
237 bg_command_id: &str,
238 ) -> Option<ExitedShellInfo> {
239 self.tombstones
240 .get(bg_command_id)
241 .filter(|entry| entry.owner_thread_id == owner_thread_id)
242 .cloned()
243 }
244
245 pub fn ids_for(&self, owner_thread_id: &str) -> Vec<String> {
249 let mut ids: Vec<String> = self
250 .shells
251 .iter()
252 .filter(|(_, entry)| entry.owner_thread_id == owner_thread_id)
253 .map(|(id, _)| id.clone())
254 .collect();
255 ids.sort();
256 ids
257 }
258
259 pub fn get_running_info(&mut self, owner_thread_id: &str) -> String {
260 self.prune_finished_shells();
261
262 if !self.shells.values().any(|entry| entry.owner_thread_id == owner_thread_id) {
263 return "No command running in background.\n".to_string();
264 }
265
266 let mut running = Vec::new();
267 for (id, entry) in &self.shells {
268 if entry.owner_thread_id != owner_thread_id {
269 continue;
270 }
271 if let Ok(guard) = entry.shell.try_lock() {
272 if let Some(bash) = guard.as_ref() {
273 if bash.command_running {
274 running.push(format!(
275 "Command: {}, bg_command_id: {}",
276 one_line_command(&bash.last_command),
277 id
278 ));
279 }
280 }
281 } else {
282 running.push(format!("Command: <busy>, bg_command_id: {id}"));
283 }
284 }
285
286 if running.is_empty() {
287 "No command running in background.\n".to_string()
288 } else {
289 format!("Following background commands are attached:\n{}\n", running.join("\n"))
290 }
291 }
292}
293
294fn one_line_command(command: &str) -> String {
298 command.chars().fold(String::new(), |mut output, character| {
299 match character {
300 '\n' => output.push_str("\\n"),
301 '\r' => output.push_str("\\r"),
302 '\t' => output.push_str("\\t"),
303 character if character.is_control() => {
304 use std::fmt::Write as _;
305 let _ = write!(output, "\\u{{{:x}}}", u32::from(character));
306 }
307 character => output.push(character),
308 }
309 output
310 })
311}
312
313#[cfg(test)]
314mod tests {
315 use super::{one_line_command, BackgroundShellEntry, BackgroundShellManager, ExitedShellInfo};
316 use crate::errors::WinxError;
317 use std::path::PathBuf;
318 use std::sync::Arc;
319 use std::time::Instant;
320 use tokio::sync::Mutex;
321
322 fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
323 BackgroundShellEntry {
324 owner_thread_id: owner_thread_id.to_string(),
325 shell: Arc::new(Mutex::new(None)),
326 }
327 }
328
329 fn tombstone(owner_thread_id: &str) -> ExitedShellInfo {
330 ExitedShellInfo {
331 owner_thread_id: owner_thread_id.to_string(),
332 last_command: "true".to_string(),
333 final_output: Arc::from("done"),
334 exit_code: Some(0),
335 cwd: PathBuf::from("/tmp"),
336 output_truncated: false,
337 scratch_path: None,
338 exited_at: Instant::now(),
339 workflow: None,
340 }
341 }
342
343 #[test]
344 fn command_summary_escapes_multiline_status_lookalikes() {
345 let command = "sleep 5\n\n---\n\nstatus = process exited";
346 let summary = one_line_command(command);
347 assert!(!summary.contains('\n'));
348 assert!(summary.contains("\\n\\n---\\n\\nstatus = process exited"));
349 }
350
351 #[test]
352 fn background_capacity_is_bounded_per_thread_and_globally() {
353 let mut manager = BackgroundShellManager::new();
354 for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
355 manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
356 }
357 assert!(matches!(
358 manager.ensure_capacity("owner"),
359 Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
360 ));
361 assert!(manager.ensure_capacity("another-owner").is_ok());
362
363 for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
364 manager
365 .shells
366 .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
367 }
368 assert!(matches!(
369 manager.ensure_capacity("new-owner"),
370 Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
371 ));
372 }
373
374 #[test]
375 fn tombstone_capacity_is_bounded_per_thread_and_globally() {
376 let mut manager = BackgroundShellManager::new();
377 for index in 0..BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD + 3 {
378 manager.insert_tombstone(format!("owned-{index}"), tombstone("owner"));
379 }
380 assert_eq!(
381 manager.tombstones.values().filter(|entry| entry.owner_thread_id == "owner").count(),
382 BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD
383 );
384
385 for index in 0..BackgroundShellManager::MAX_TOMBSTONES + 5 {
386 manager.insert_tombstone(format!("global-{index}"), tombstone(&format!("o-{index}")));
387 }
388 assert_eq!(manager.tombstones.len(), BackgroundShellManager::MAX_TOMBSTONES);
389 }
390}