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