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 get_running_info(&mut self, owner_thread_id: &str) -> String {
244 self.prune_finished_shells();
245
246 if !self.shells.values().any(|entry| entry.owner_thread_id == owner_thread_id) {
247 return "No command running in background.\n".to_string();
248 }
249
250 let mut running = Vec::new();
251 for (id, entry) in &self.shells {
252 if entry.owner_thread_id != owner_thread_id {
253 continue;
254 }
255 if let Ok(guard) = entry.shell.try_lock() {
256 if let Some(bash) = guard.as_ref() {
257 if bash.command_running {
258 running
259 .push(format!("Command: {}, bg_command_id: {}", bash.last_command, id));
260 }
261 }
262 } else {
263 running.push(format!("Command: <busy>, bg_command_id: {id}"));
264 }
265 }
266
267 if running.is_empty() {
268 "No command running in background.\n".to_string()
269 } else {
270 format!("Following background commands are attached:\n{}\n", running.join("\n"))
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::{BackgroundShellEntry, BackgroundShellManager, ExitedShellInfo};
278 use crate::errors::WinxError;
279 use std::path::PathBuf;
280 use std::sync::Arc;
281 use std::time::Instant;
282 use tokio::sync::Mutex;
283
284 fn empty_entry(owner_thread_id: &str) -> BackgroundShellEntry {
285 BackgroundShellEntry {
286 owner_thread_id: owner_thread_id.to_string(),
287 shell: Arc::new(Mutex::new(None)),
288 }
289 }
290
291 fn tombstone(owner_thread_id: &str) -> ExitedShellInfo {
292 ExitedShellInfo {
293 owner_thread_id: owner_thread_id.to_string(),
294 last_command: "true".to_string(),
295 final_output: Arc::from("done"),
296 exit_code: Some(0),
297 cwd: PathBuf::from("/tmp"),
298 output_truncated: false,
299 scratch_path: None,
300 exited_at: Instant::now(),
301 }
302 }
303
304 #[test]
305 fn background_capacity_is_bounded_per_thread_and_globally() {
306 let mut manager = BackgroundShellManager::new();
307 for index in 0..BackgroundShellManager::MAX_SHELLS_PER_THREAD {
308 manager.shells.insert(format!("owned-{index}"), empty_entry("owner"));
309 }
310 assert!(matches!(
311 manager.ensure_capacity("owner"),
312 Err(WinxError::CommandExecutionError(message)) if message.contains("this thread")
313 ));
314 assert!(manager.ensure_capacity("another-owner").is_ok());
315
316 for index in manager.shells.len()..BackgroundShellManager::MAX_SHELLS {
317 manager
318 .shells
319 .insert(format!("global-{index}"), empty_entry(&format!("owner-{index}")));
320 }
321 assert!(matches!(
322 manager.ensure_capacity("new-owner"),
323 Err(WinxError::CommandExecutionError(message)) if message.contains("Global")
324 ));
325 }
326
327 #[test]
328 fn tombstone_capacity_is_bounded_per_thread_and_globally() {
329 let mut manager = BackgroundShellManager::new();
330 for index in 0..BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD + 3 {
331 manager.insert_tombstone(format!("owned-{index}"), tombstone("owner"));
332 }
333 assert_eq!(
334 manager.tombstones.values().filter(|entry| entry.owner_thread_id == "owner").count(),
335 BackgroundShellManager::MAX_TOMBSTONES_PER_THREAD
336 );
337
338 for index in 0..BackgroundShellManager::MAX_TOMBSTONES + 5 {
339 manager.insert_tombstone(format!("global-{index}"), tombstone(&format!("o-{index}")));
340 }
341 assert_eq!(manager.tombstones.len(), BackgroundShellManager::MAX_TOMBSTONES);
342 }
343}