1use std::collections::HashMap;
4use std::collections::VecDeque;
5use std::fs;
6use std::fs::File;
7use std::fs::OpenOptions;
8use std::io::BufRead;
9use std::io::BufReader;
10use std::io::BufWriter;
11use std::io::Write;
12use std::os::unix::io::AsRawFd;
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::sync::Condvar;
16use std::sync::Mutex;
17use std::sync::RwLock;
18use std::sync::atomic::AtomicU64;
19use std::sync::atomic::Ordering;
20use std::thread;
21use std::time::Duration;
22use std::time::Instant;
23
24use crossterm::cursor;
25use crossterm::queue;
26use crossterm::style;
27use crossterm::terminal;
28use tracing::warn;
29
30use bytes::Bytes;
31use chrono::DateTime;
32use chrono::TimeZone;
33use chrono::Utc;
34use crossbeam_channel as channel;
35use serde::Deserialize;
36use serde::Serialize;
37use sysinfo::Pid;
38use sysinfo::ProcessRefreshKind;
39use sysinfo::ProcessesToUpdate;
40use sysinfo::System;
41use sysinfo::UpdateKind;
42use uuid::Uuid;
43
44use crate::common::mutex_lock_or_recover;
45use crate::common::rwlock_read_or_recover;
46use crate::common::rwlock_write_or_recover;
47use crate::infra::terminal::CursorPosition;
48use crate::infra::terminal::PtyHandle;
49use crate::infra::terminal::ReadEvent;
50use crate::infra::terminal::key_to_escape_sequence;
51use crate::infra::terminal::render_screen;
52use crate::infra::terminal::render_screen_trimmed;
53use crate::usecases::ports::LivePreviewSnapshot;
54use crate::usecases::ports::StreamCursor;
55use crate::usecases::ports::StreamRead;
56use crate::usecases::ports::StreamWaiter;
57use crate::usecases::ports::StreamWaiterHandle;
58
59use super::pty_session::PtySession;
60use crate::domain::RestartOutput;
61use crate::infra::daemon::TerminalState;
62
63pub use crate::domain::session_types::SessionId;
64pub use crate::domain::session_types::SessionInfo;
65use crate::domain::session_types::TerminalSize;
66pub use crate::infra::daemon::SessionError;
67
68const DEFAULT_STREAM_MAX_BUFFER_BYTES: usize = 8 * 1024 * 1024;
69const COMMAND_TIMELINE_MAX_ENTRIES: usize = 512;
70pub(crate) const PUMP_FLUSH_TIMEOUT: Duration = Duration::from_millis(50);
71const SESSION_QUERY_LOCK_TIMEOUT: Duration = Duration::from_millis(100);
72const STARTUP_TERMINATE_TIMEOUT: Duration = Duration::from_millis(500);
73const STARTUP_KILL_TIMEOUT: Duration = Duration::from_millis(500);
74const STARTUP_KILL_POLL_INTERVAL: Duration = Duration::from_millis(25);
75const STARTUP_PID_START_TOLERANCE_SECS: i64 = 30;
76
77pub fn generate_session_id() -> SessionId {
78 SessionId::new_unchecked(Uuid::new_v4().to_string()[..8].to_string())
79}
80
81#[derive(Debug, Clone)]
82struct SessionLaunchSpec {
83 command: String,
84 args: Vec<String>,
85 cwd: Option<String>,
86 env: Option<HashMap<String, String>>,
87}
88
89struct StreamState {
90 buffer: VecDeque<Bytes>,
91 buffer_len: usize,
92 base_seq: u64,
93 next_seq: u64,
94 dropped_bytes: u64,
95 closed: bool,
96 error: Option<String>,
97}
98
99type WaitNotifiers = Arc<Mutex<Vec<(u64, channel::Sender<()>)>>>;
100
101struct StreamBuffer {
102 state: RwLock<StreamState>,
103 wait_lock: Mutex<()>,
104 cv: Condvar,
105 notifiers: WaitNotifiers,
106 next_notifier_id: AtomicU64,
107 max_bytes: usize,
108}
109
110#[derive(Clone)]
111pub struct StreamReader {
112 inner: Arc<StreamBuffer>,
113}
114
115impl StreamReader {
116 fn new(inner: Arc<StreamBuffer>) -> Self {
117 Self { inner }
118 }
119
120 pub fn read(
121 &self,
122 cursor: &mut StreamCursor,
123 max_bytes: usize,
124 timeout_ms: i32,
125 ) -> Result<StreamRead, SessionError> {
126 self.inner.read(cursor, max_bytes, timeout_ms)
127 }
128
129 pub fn subscribe(&self) -> StreamWaiterHandle {
130 self.inner.subscribe()
131 }
132}
133
134struct StreamWaiterImpl {
135 receiver: channel::Receiver<()>,
136 notifiers: WaitNotifiers,
137 notifier_id: u64,
138}
139
140impl StreamWaiter for StreamWaiterImpl {
141 fn wait(&self, timeout: Option<Duration>) -> bool {
142 match timeout {
143 Some(timeout) => self.receiver.recv_timeout(timeout).is_ok(),
144 None => self.receiver.recv().is_ok(),
145 }
146 }
147}
148
149impl Drop for StreamWaiterImpl {
150 fn drop(&mut self) {
151 let mut notifiers = self
152 .notifiers
153 .lock()
154 .unwrap_or_else(std::sync::PoisonError::into_inner);
155 notifiers.retain(|(id, _)| *id != self.notifier_id);
156 }
157}
158
159enum PumpCommand {
160 Flush(channel::Sender<()>),
161 Shutdown,
162}
163
164impl StreamBuffer {
165 fn new(max_bytes: usize) -> Self {
166 Self {
167 state: RwLock::new(StreamState {
168 buffer: VecDeque::new(),
169 buffer_len: 0,
170 base_seq: 0,
171 next_seq: 0,
172 dropped_bytes: 0,
173 closed: false,
174 error: None,
175 }),
176 wait_lock: Mutex::new(()),
177 cv: Condvar::new(),
178 notifiers: Arc::new(Mutex::new(Vec::new())),
179 next_notifier_id: AtomicU64::new(1),
180 max_bytes,
181 }
182 }
183
184 #[cfg(test)]
185 fn push(&self, data: &[u8]) {
186 if data.is_empty() {
187 return;
188 }
189 self.push_bytes(Bytes::copy_from_slice(data));
190 }
191
192 fn push_bytes(&self, data: Bytes) {
193 if data.is_empty() {
194 return;
195 }
196 let _wait_guard = self
197 .wait_lock
198 .lock()
199 .unwrap_or_else(std::sync::PoisonError::into_inner);
200 let mut state = self
201 .state
202 .write()
203 .unwrap_or_else(std::sync::PoisonError::into_inner);
204 state.buffer_len = state.buffer_len.saturating_add(data.len());
205 state.next_seq = state.next_seq.saturating_add(data.len() as u64);
206 state.buffer.push_back(data);
207
208 while state.buffer_len > self.max_bytes {
209 let excess = state.buffer_len - self.max_bytes;
210 let Some(chunk) = state.buffer.pop_front() else {
211 break;
212 };
213 if chunk.len() <= excess {
214 let len = chunk.len();
215 state.buffer_len = state.buffer_len.saturating_sub(len);
216 state.base_seq = state.base_seq.saturating_add(len as u64);
217 state.dropped_bytes = state.dropped_bytes.saturating_add(len as u64);
218 continue;
219 }
220
221 let keep = chunk.slice(excess..);
222 state.buffer.push_front(keep);
223 state.buffer_len = state.buffer_len.saturating_sub(excess);
224 state.base_seq = state.base_seq.saturating_add(excess as u64);
225 state.dropped_bytes = state.dropped_bytes.saturating_add(excess as u64);
226 break;
227 }
228 drop(state);
229 self.notify_listeners();
230 self.cv.notify_all();
231 }
232
233 fn close(&self, error: Option<String>) {
234 let _wait_guard = self
235 .wait_lock
236 .lock()
237 .unwrap_or_else(std::sync::PoisonError::into_inner);
238 let mut state = self
239 .state
240 .write()
241 .unwrap_or_else(std::sync::PoisonError::into_inner);
242 state.closed = true;
243 state.error = error;
244 drop(state);
245 self.notify_listeners();
246 self.cv.notify_all();
247 }
248
249 fn notify(&self) {
250 let _wait_guard = self
251 .wait_lock
252 .lock()
253 .unwrap_or_else(std::sync::PoisonError::into_inner);
254 self.notify_listeners();
255 self.cv.notify_all();
256 }
257
258 fn subscribe(&self) -> StreamWaiterHandle {
259 let (tx, rx) = channel::bounded(1);
260 let notifier_id = self.next_notifier_id.fetch_add(1, Ordering::Relaxed);
261 self.notify_listeners();
262 {
263 let mut notifiers = self
264 .notifiers
265 .lock()
266 .unwrap_or_else(std::sync::PoisonError::into_inner);
267 notifiers.push((notifier_id, tx));
268 }
269 Arc::new(StreamWaiterImpl {
270 receiver: rx,
271 notifiers: Arc::clone(&self.notifiers),
272 notifier_id,
273 })
274 }
275
276 fn latest_seq(&self) -> u64 {
277 let state = self
278 .state
279 .read()
280 .unwrap_or_else(std::sync::PoisonError::into_inner);
281 state.next_seq
282 }
283
284 fn notify_listeners(&self) {
285 let mut notifiers = self
286 .notifiers
287 .lock()
288 .unwrap_or_else(std::sync::PoisonError::into_inner);
289 notifiers.retain(|(_, sender)| match sender.try_send(()) {
290 Ok(()) => true,
291 Err(channel::TrySendError::Full(_)) => true,
292 Err(channel::TrySendError::Disconnected(_)) => false,
293 });
294 }
295
296 #[cfg(test)]
297 fn notifier_count(&self) -> usize {
298 self.notifiers
299 .lock()
300 .unwrap_or_else(std::sync::PoisonError::into_inner)
301 .len()
302 }
303
304 fn read(
305 &self,
306 cursor: &mut StreamCursor,
307 max_bytes: usize,
308 timeout_ms: i32,
309 ) -> Result<StreamRead, SessionError> {
310 let max_bytes = max_bytes.max(1);
311 let timeout = if timeout_ms < 0 {
312 None
313 } else {
314 Some(Duration::from_millis(timeout_ms as u64))
315 };
316
317 let mut guard = self
318 .wait_lock
319 .lock()
320 .unwrap_or_else(std::sync::PoisonError::into_inner);
321 loop {
322 let state = self
323 .state
324 .read()
325 .unwrap_or_else(std::sync::PoisonError::into_inner);
326 if state.next_seq > cursor.seq || state.closed {
327 break;
328 }
329 drop(state);
330
331 if let Some(wait) = timeout {
332 let (new_guard, result) = self
333 .cv
334 .wait_timeout(guard, wait)
335 .unwrap_or_else(std::sync::PoisonError::into_inner);
336 guard = new_guard;
337 if result.timed_out() {
338 break;
339 }
340 } else {
341 guard = self
342 .cv
343 .wait(guard)
344 .unwrap_or_else(std::sync::PoisonError::into_inner);
345 }
346 }
347 drop(guard);
348
349 let state = self
350 .state
351 .read()
352 .unwrap_or_else(std::sync::PoisonError::into_inner);
353 if let Some(error) = state.error.clone() {
354 return Err(SessionError::Terminal(
355 crate::usecases::ports::TerminalError::Read {
356 reason: error,
357 source: None,
358 },
359 ));
360 }
361
362 let latest_cursor = StreamCursor {
363 seq: state.next_seq,
364 };
365 let closed = state.closed;
366 let dropped_bytes = state.base_seq.saturating_sub(cursor.seq);
367
368 if cursor.seq < state.base_seq {
369 cursor.seq = state.base_seq;
370 }
371
372 let offset = (cursor.seq - state.base_seq) as usize;
373 let available = state.buffer_len.saturating_sub(offset);
374 let read_len = available.min(max_bytes);
375
376 let mut data = Vec::with_capacity(read_len);
377 if read_len > 0 {
378 let mut remaining = read_len;
379 let mut skip = offset;
380 for chunk in state.buffer.iter() {
381 if remaining == 0 {
382 break;
383 }
384 if skip >= chunk.len() {
385 skip -= chunk.len();
386 continue;
387 }
388 let start = skip;
389 let take = (chunk.len() - start).min(remaining);
390 data.extend_from_slice(&chunk[start..start + take]);
391 remaining -= take;
392 skip = 0;
393 }
394 }
395
396 cursor.seq = cursor.seq.saturating_add(read_len as u64);
397
398 Ok(StreamRead {
399 data,
400 next_cursor: *cursor,
401 latest_cursor,
402 dropped_bytes,
403 closed,
404 })
405 }
406}
407
408fn render_live_preview_init(
409 buffer: &crate::infra::terminal::ScreenBuffer,
410 cursor: CursorPosition,
411) -> String {
412 let mut out = Vec::new();
413 let _ = queue!(
414 out,
415 terminal::Clear(terminal::ClearType::All),
416 cursor::MoveTo(0, 0),
417 style::SetAttribute(style::Attribute::Reset),
418 style::ResetColor
419 );
420 let body = render_screen(buffer);
421 let _ = queue!(out, style::Print(body));
422 let _ = queue!(out, cursor::MoveTo(cursor.col, cursor.row));
423 if cursor.visible {
424 let _ = queue!(out, cursor::Show);
425 } else {
426 let _ = queue!(out, cursor::Hide);
427 }
428 String::from_utf8(out).unwrap_or_default()
429}
430
431fn spawn_pump(
432 session: Arc<Mutex<Session>>,
433 thread_name: String,
434) -> (channel::Sender<PumpCommand>, thread::JoinHandle<()>) {
435 const PUMP_COMMAND_CHANNEL_CAPACITY: usize = 64;
436 let (tx, rx) = channel::bounded(PUMP_COMMAND_CHANNEL_CAPACITY);
437 let pty_rx = {
438 let mut sess = mutex_lock_or_recover(&session);
439 sess.take_pty_rx()
440 }
441 .unwrap_or_else(|| {
442 let (_tx, rx) = channel::bounded(1);
443 rx
444 });
445 let payload = Arc::new(Mutex::new(Some((session, pty_rx, rx))));
446 let payload_for_thread = Arc::clone(&payload);
447 let join = match thread::Builder::new().name(thread_name).spawn(move || {
448 let Some((session, pty_rx, rx)) = payload_for_thread
449 .lock()
450 .unwrap_or_else(std::sync::PoisonError::into_inner)
451 .take()
452 else {
453 warn!("Session pump payload missing; pump thread exiting");
454 return;
455 };
456 pump_loop(session, pty_rx, rx);
457 }) {
458 Ok(handle) => handle,
459 Err(err) => {
460 warn!(
461 error = %err,
462 "Failed to spawn named session pump thread; falling back to unnamed thread"
463 );
464 match payload
465 .lock()
466 .unwrap_or_else(std::sync::PoisonError::into_inner)
467 .take()
468 {
469 Some((session, pty_rx, rx)) => {
470 thread::spawn(move || pump_loop(session, pty_rx, rx))
471 }
472 None => thread::spawn(|| {}),
473 }
474 }
475 };
476 (tx, join)
477}
478
479fn pump_loop(
480 session: Arc<Mutex<Session>>,
481 pty_rx: channel::Receiver<ReadEvent>,
482 rx: channel::Receiver<PumpCommand>,
483) {
484 loop {
485 channel::select! {
486 recv(rx) -> cmd => match cmd {
487 Ok(PumpCommand::Flush(ack)) => {
488 let mut should_continue = true;
489 if let Ok(mut sess) = session.lock() {
490 should_continue = sess.pump_drain_events(&pty_rx);
491 }
492 let _ = ack.send(());
493 if !should_continue {
494 return;
495 }
496 }
497 Ok(PumpCommand::Shutdown) | Err(_) => {
498 if let Ok(sess) = session.lock() {
499 sess.stream.close(None);
500 }
501 return;
502 }
503 },
504 recv(pty_rx) -> event => match event {
505 Ok(event) => {
506 let mut should_continue = true;
507 if let Ok(mut sess) = session.lock() {
508 should_continue = sess.handle_read_event(event);
509 }
510 if !should_continue {
511 return;
512 }
513 }
514 Err(_) => {
515 if let Ok(sess) = session.lock() {
516 sess.stream.close(None);
517 }
518 return;
519 }
520 }
521 }
522 }
523}
524
525#[derive(Clone, Copy)]
526enum ModifierKey {
527 Ctrl,
528 Alt,
529 Shift,
530 Meta,
531}
532
533impl ModifierKey {
534 fn from_str(key: &str) -> Option<Self> {
535 match key.to_lowercase().as_str() {
536 "ctrl" | "control" => Some(Self::Ctrl),
537 "alt" => Some(Self::Alt),
538 "shift" => Some(Self::Shift),
539 "meta" | "cmd" | "command" | "win" | "super" => Some(Self::Meta),
540 _ => None,
541 }
542 }
543}
544
545#[derive(Default)]
546struct ModifierState {
547 ctrl: bool,
548 alt: bool,
549 shift: bool,
550 meta: bool,
551}
552
553impl ModifierState {
554 fn set(&mut self, key: ModifierKey, value: bool) {
555 match key {
556 ModifierKey::Ctrl => self.ctrl = value,
557 ModifierKey::Alt => self.alt = value,
558 ModifierKey::Shift => self.shift = value,
559 ModifierKey::Meta => self.meta = value,
560 }
561 }
562
563 fn is_empty(&self) -> bool {
564 !(self.ctrl || self.alt || self.shift || self.meta)
565 }
566
567 fn has_alt_like(&self) -> bool {
568 self.alt || self.meta
569 }
570
571 fn keystroke_bytes(&self, key: &str) -> Result<Vec<u8>, SessionError> {
572 if self.is_empty() || key.contains('+') {
573 return key_to_escape_sequence(key)
574 .ok_or_else(|| SessionError::InvalidKey(key.to_string()));
575 }
576
577 if key.chars().count() == 1 {
578 let key_char = key
579 .chars()
580 .next()
581 .ok_or_else(|| SessionError::InvalidKey(key.to_string()))?;
582 return Ok(self.modified_key_char_bytes(key_char));
583 }
584
585 let base_key = if self.shift && key.eq_ignore_ascii_case("tab") {
586 "Shift+Tab"
587 } else {
588 key
589 };
590
591 let sequence = key_to_escape_sequence(base_key)
592 .ok_or_else(|| SessionError::InvalidKey(key.to_string()))?;
593 Ok(prefix_escape_if_needed(self.has_alt_like(), sequence))
594 }
595
596 fn typed_bytes(&self, text: &str) -> Vec<u8> {
597 if self.is_empty() {
598 return text.as_bytes().to_vec();
599 }
600
601 let mut bytes = Vec::with_capacity(text.len());
602 for ch in text.chars() {
603 bytes.extend(self.modified_text_char_bytes(ch));
604 }
605 bytes
606 }
607
608 fn modified_key_char_bytes(&self, ch: char) -> Vec<u8> {
609 let shifted = if self.shift { shifted_key_char(ch) } else { ch };
610 self.modified_char_bytes(shifted)
611 }
612
613 fn modified_text_char_bytes(&self, ch: char) -> Vec<u8> {
614 let shifted = if self.shift && ch.is_ascii_alphabetic() {
615 ch.to_ascii_uppercase()
616 } else {
617 ch
618 };
619 self.modified_char_bytes(shifted)
620 }
621
622 fn modified_char_bytes(&self, ch: char) -> Vec<u8> {
623 let bytes = if self.ctrl {
624 control_byte_for_char(ch)
625 .map(|byte| vec![byte])
626 .unwrap_or_else(|| ch.to_string().into_bytes())
627 } else {
628 ch.to_string().into_bytes()
629 };
630 prefix_escape_if_needed(self.has_alt_like(), bytes)
631 }
632}
633
634fn prefix_escape_if_needed(needs_escape_prefix: bool, bytes: Vec<u8>) -> Vec<u8> {
635 if !needs_escape_prefix {
636 return bytes;
637 }
638
639 let mut prefixed = Vec::with_capacity(bytes.len() + 1);
640 prefixed.push(0x1b);
641 prefixed.extend(bytes);
642 prefixed
643}
644
645fn shifted_key_char(ch: char) -> char {
646 match ch {
647 'a'..='z' => ch.to_ascii_uppercase(),
648 '1' => '!',
649 '2' => '@',
650 '3' => '#',
651 '4' => '$',
652 '5' => '%',
653 '6' => '^',
654 '7' => '&',
655 '8' => '*',
656 '9' => '(',
657 '0' => ')',
658 '-' => '_',
659 '=' => '+',
660 '[' => '{',
661 ']' => '}',
662 '\\' => '|',
663 ';' => ':',
664 '\'' => '"',
665 ',' => '<',
666 '.' => '>',
667 '/' => '?',
668 '`' => '~',
669 _ => ch,
670 }
671}
672
673fn control_byte_for_char(ch: char) -> Option<u8> {
674 match ch {
675 'a'..='z' => Some((ch as u8) - b'a' + 1),
676 'A'..='Z' => Some((ch as u8) - b'A' + 1),
677 '@' | ' ' => Some(0),
678 '[' => Some(27),
679 '\\' => Some(28),
680 ']' => Some(29),
681 '^' => Some(30),
682 '_' => Some(31),
683 '?' => Some(127),
684 _ => None,
685 }
686}
687
688#[derive(Debug, Clone, PartialEq, Eq)]
689pub struct CommandTimelineEntry {
690 pub seq: u64,
691 pub kind: String,
692 pub value: String,
693}
694
695#[derive(Default)]
696struct CommandTimeline {
697 entries: VecDeque<CommandTimelineEntry>,
698 base_seq: u64,
699 next_seq: u64,
700}
701
702impl CommandTimeline {
703 fn push(&mut self, kind: &str, value: String) {
704 let entry = CommandTimelineEntry {
705 seq: self.next_seq,
706 kind: kind.to_string(),
707 value,
708 };
709 self.entries.push_back(entry);
710 self.next_seq = self.next_seq.saturating_add(1);
711
712 while self.entries.len() > COMMAND_TIMELINE_MAX_ENTRIES {
713 let _ = self.entries.pop_front();
714 self.base_seq = self.base_seq.saturating_add(1);
715 }
716 }
717
718 fn read(&self, cursor: &mut u64, max_entries: usize) -> Vec<CommandTimelineEntry> {
719 if max_entries == 0 {
720 return Vec::new();
721 }
722
723 if *cursor < self.base_seq {
724 *cursor = self.base_seq;
725 }
726
727 let offset = cursor.saturating_sub(self.base_seq) as usize;
728 if offset >= self.entries.len() {
729 return Vec::new();
730 }
731
732 let take = (self.entries.len() - offset).min(max_entries);
733 let items = self
734 .entries
735 .iter()
736 .skip(offset)
737 .take(take)
738 .cloned()
739 .collect::<Vec<_>>();
740 *cursor = cursor.saturating_add(take as u64);
741 items
742 }
743}
744
745fn sanitize_command_timeline_value(value: &str) -> String {
746 const MAX_CHARS: usize = 160;
747 let normalized = value
748 .replace('\r', "\\r")
749 .replace('\n', "\\n")
750 .replace('\t', "\\t");
751 let mut chars = normalized.chars();
752 let clipped = chars.by_ref().take(MAX_CHARS).collect::<String>();
753 if chars.next().is_some() {
754 format!("{clipped}...")
755 } else {
756 clipped
757 }
758}
759
760pub struct Session {
761 pub id: SessionId,
762 pub command: String,
763 pub created_at: DateTime<Utc>,
764 launch: SessionLaunchSpec,
765 pty: PtySession,
766 terminal: TerminalState,
767 held_modifiers: ModifierState,
768 stream: Arc<StreamBuffer>,
769 command_timeline: CommandTimeline,
770 pty_rx: Option<channel::Receiver<ReadEvent>>,
771 pty_cursor: Arc<Mutex<StreamCursor>>,
772 pump_tx: Option<channel::Sender<PumpCommand>>,
773 pump_join: Option<thread::JoinHandle<()>>,
774}
775
776impl Session {
777 fn new(
778 id: SessionId,
779 launch: SessionLaunchSpec,
780 pty: PtyHandle,
781 size: TerminalSize,
782 stream_max_buffer_bytes: usize,
783 ) -> Self {
784 let stream = Arc::new(StreamBuffer::new(stream_max_buffer_bytes));
785 let mut pty = PtySession::new(pty);
786 let pty_rx = pty.take_read_rx();
787 Self {
788 id,
789 command: launch.command.clone(),
790 created_at: Utc::now(),
791 launch,
792 pty,
793 terminal: TerminalState::new(size),
794 held_modifiers: ModifierState::default(),
795 stream,
796 command_timeline: CommandTimeline::default(),
797 pty_rx,
798 pty_cursor: Arc::new(Mutex::new(StreamCursor::default())),
799 pump_tx: None,
800 pump_join: None,
801 }
802 }
803
804 fn record_command_timeline_entry(&mut self, kind: &str, value: String) {
805 self.command_timeline.push(kind, value);
806 self.stream.notify();
807 }
808
809 pub fn pid(&self) -> Option<u32> {
810 self.pty.pid()
811 }
812
813 pub fn is_running(&mut self) -> bool {
814 self.pty.is_running()
815 }
816
817 pub fn size(&self) -> TerminalSize {
818 self.terminal.size()
819 }
820
821 fn launch_spec(&self) -> SessionLaunchSpec {
822 self.launch.clone()
823 }
824
825 pub fn request_flush(&self) -> Option<channel::Receiver<()>> {
826 if let Some(tx) = self.pump_tx.as_ref() {
827 let (ack_tx, ack_rx) = channel::bounded(1);
828 if tx.send(PumpCommand::Flush(ack_tx)).is_ok() {
829 return Some(ack_rx);
830 }
831 }
832 None
833 }
834
835 pub fn screen_text(&self) -> String {
836 self.terminal.screen_text()
837 }
838
839 pub fn screen_render(&self) -> String {
840 let buffer = self.terminal.screen_buffer();
841 render_screen(&buffer)
842 }
843
844 pub fn screen_render_compact(&self) -> String {
845 let buffer = self.terminal.screen_buffer();
846 render_screen_trimmed(&buffer)
847 }
848
849 pub fn cursor(&self) -> CursorPosition {
850 self.terminal.cursor()
851 }
852
853 pub fn keystroke(&mut self, key: &str) -> Result<(), SessionError> {
854 let seq = self.held_modifiers.keystroke_bytes(key)?;
855 self.pty.write(&seq)?;
856 self.record_command_timeline_entry("press", key.to_string());
857 Ok(())
858 }
859
860 pub fn keydown(&mut self, key: &str) -> Result<(), SessionError> {
861 let modifier = ModifierKey::from_str(key).ok_or_else(|| {
862 SessionError::InvalidKey(format!(
863 "{key}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be held"
864 ))
865 })?;
866 self.held_modifiers.set(modifier, true);
867 self.record_command_timeline_entry("keydown", key.to_string());
868 Ok(())
869 }
870
871 pub fn keyup(&mut self, key: &str) -> Result<(), SessionError> {
872 let modifier = ModifierKey::from_str(key).ok_or_else(|| {
873 SessionError::InvalidKey(format!(
874 "{key}. Only modifier keys (Ctrl, Alt, Shift, Meta) can be released"
875 ))
876 })?;
877 self.held_modifiers.set(modifier, false);
878 self.record_command_timeline_entry("keyup", key.to_string());
879 Ok(())
880 }
881
882 pub fn type_text(&mut self, text: &str) -> Result<(), SessionError> {
883 if self.held_modifiers.is_empty() {
884 self.pty.write_str(text)?;
885 } else {
886 let bytes = self.held_modifiers.typed_bytes(text);
887 self.pty.write(&bytes)?;
888 }
889 self.record_command_timeline_entry("type", sanitize_command_timeline_value(text));
890 Ok(())
891 }
892
893 pub fn resize(&mut self, size: TerminalSize) -> Result<(), SessionError> {
894 self.pty.resize(size)?;
895 self.terminal.resize(size);
896 self.record_command_timeline_entry("resize", format!("{}x{}", size.cols(), size.rows()));
897 self.stream.notify();
898 Ok(())
899 }
900
901 pub fn kill(&mut self) -> Result<(), SessionError> {
902 self.pty.kill()?;
903 Ok(())
904 }
905
906 pub fn pty_write(&mut self, data: &[u8]) -> Result<(), SessionError> {
907 self.pty.write(data)?;
908 self.record_command_timeline_entry("write", format!("{} bytes", data.len()));
909 Ok(())
910 }
911
912 pub fn stream_read(
913 &self,
914 cursor: &mut StreamCursor,
915 max_bytes: usize,
916 timeout_ms: i32,
917 ) -> Result<StreamRead, SessionError> {
918 self.stream.read(cursor, max_bytes, timeout_ms)
919 }
920
921 pub fn stream_reader(&self) -> StreamReader {
922 StreamReader::new(Arc::clone(&self.stream))
923 }
924
925 pub fn command_timeline_read(
926 &self,
927 cursor: &mut u64,
928 max_entries: usize,
929 ) -> Vec<CommandTimelineEntry> {
930 self.command_timeline.read(cursor, max_entries)
931 }
932
933 pub fn stream_subscribe(&self) -> StreamWaiterHandle {
934 self.stream.subscribe()
935 }
936
937 pub fn pty_cursor_handle(&self) -> Arc<Mutex<StreamCursor>> {
938 Arc::clone(&self.pty_cursor)
939 }
940
941 fn take_pty_rx(&mut self) -> Option<channel::Receiver<ReadEvent>> {
942 self.pty_rx.take()
943 }
944
945 fn handle_read_event(&mut self, event: ReadEvent) -> bool {
946 match event {
947 ReadEvent::Data(data) => {
948 self.terminal.process(&data);
949 self.stream.push_bytes(Bytes::from(data));
950 true
951 }
952 ReadEvent::Eof => {
953 self.stream.close(None);
954 let _ = self.pty.is_running();
955 false
956 }
957 ReadEvent::Error(error) => {
958 self.stream.close(Some(error));
959 let _ = self.pty.is_running();
960 false
961 }
962 }
963 }
964
965 fn pump_drain_events(&mut self, pty_rx: &channel::Receiver<ReadEvent>) -> bool {
966 while let Ok(event) = pty_rx.try_recv() {
967 if !self.handle_read_event(event) {
968 return false;
969 }
970 }
971 true
972 }
973
974 fn attach_pump(&mut self, tx: channel::Sender<PumpCommand>, join: thread::JoinHandle<()>) {
975 self.pump_tx = Some(tx);
976 self.pump_join = Some(join);
977 }
978
979 fn shutdown_pump(&mut self) -> Option<thread::JoinHandle<()>> {
980 if let Some(tx) = self.pump_tx.take() {
981 let _ = tx.send(PumpCommand::Shutdown);
982 }
983 self.pump_join.take()
984 }
985
986 pub fn live_preview_snapshot(&self) -> LivePreviewSnapshot {
987 let size = self.terminal.size();
988 let buffer = self.terminal.screen_buffer();
989 let cursor = self.terminal.cursor();
990 let seq = render_live_preview_init(&buffer, cursor);
991 let stream_seq = self.stream.latest_seq();
992 LivePreviewSnapshot {
993 cols: size.cols(),
994 rows: size.rows(),
995 seq,
996 stream_seq,
997 }
998 }
999}
1000
1001pub struct SessionManager {
1002 sessions: RwLock<HashMap<SessionId, Arc<Mutex<Session>>>>,
1003 active_session: RwLock<Option<SessionId>>,
1004 persistence: SessionPersistence,
1005 max_sessions: usize,
1006 stream_max_buffer_bytes: usize,
1007}
1008
1009pub const DEFAULT_MAX_SESSIONS: usize = 16;
1010
1011impl SessionManager {
1012 pub fn new() -> Result<Self, SessionError> {
1013 Self::with_max_sessions(DEFAULT_MAX_SESSIONS)
1014 }
1015
1016 pub fn with_max_sessions(max_sessions: usize) -> Result<Self, SessionError> {
1017 Self::with_limits(max_sessions, DEFAULT_STREAM_MAX_BUFFER_BYTES)
1018 }
1019
1020 fn with_limits(
1021 max_sessions: usize,
1022 stream_max_buffer_bytes: usize,
1023 ) -> Result<Self, SessionError> {
1024 let persistence = SessionPersistence::new();
1025 persistence.cleanup_stale_sessions()?;
1026
1027 Ok(Self {
1028 sessions: RwLock::new(HashMap::new()),
1029 active_session: RwLock::new(None),
1030 persistence,
1031 max_sessions,
1032 stream_max_buffer_bytes,
1033 })
1034 }
1035
1036 pub fn with_test_limits(
1037 max_sessions: usize,
1038 stream_max_buffer_bytes: usize,
1039 ) -> Result<Self, SessionError> {
1040 Self::with_limits(max_sessions, stream_max_buffer_bytes)
1041 }
1042
1043 fn next_session_id(&self) -> SessionId {
1044 loop {
1045 let candidate = generate_session_id();
1046 let sessions = rwlock_read_or_recover(&self.sessions);
1047 if !sessions.contains_key(&candidate) {
1048 return candidate;
1049 }
1050 }
1051 }
1052
1053 fn persisted_session(
1054 session_id: &SessionId,
1055 command: &str,
1056 pid: u32,
1057 size: TerminalSize,
1058 ) -> PersistedSession {
1059 PersistedSession {
1060 id: session_id.to_string(),
1061 command: command.to_string(),
1062 pid,
1063 created_at: Utc::now().to_rfc3339(),
1064 size,
1065 }
1066 }
1067
1068 #[allow(clippy::too_many_arguments)]
1069 pub fn spawn(
1070 &self,
1071 command: &str,
1072 args: &[String],
1073 cwd: Option<&str>,
1074 env: Option<&HashMap<String, String>>,
1075 session_id: Option<SessionId>,
1076 size: TerminalSize,
1077 ) -> Result<(SessionId, u32), SessionError> {
1078 if let Some(ref requested_id) = session_id {
1079 let sessions = rwlock_read_or_recover(&self.sessions);
1080 if sessions.contains_key(requested_id) {
1081 return Err(SessionError::AlreadyExists(requested_id.to_string()));
1082 }
1083 }
1084
1085 {
1086 let sessions = rwlock_read_or_recover(&self.sessions);
1087 if sessions.len() >= self.max_sessions {
1088 return Err(SessionError::LimitReached(self.max_sessions));
1089 }
1090 }
1091
1092 let id = session_id.unwrap_or_else(|| self.next_session_id());
1093 let launch = SessionLaunchSpec {
1094 command: command.to_string(),
1095 args: args.to_vec(),
1096 cwd: cwd.map(str::to_string),
1097 env: env.cloned(),
1098 };
1099
1100 let mut pty = PtyHandle::spawn(
1101 &launch.command,
1102 &launch.args,
1103 launch.cwd.as_deref(),
1104 launch.env.as_ref(),
1105 size,
1106 )
1107 .map_err(|e| SessionError::Terminal(e.into_port_error()))?;
1108 let pid = pty.pid().unwrap_or(0);
1109
1110 let persisted = Self::persisted_session(&id, &launch.command, pid, size);
1111 if let Err(err) = self.persistence.add_session(persisted) {
1112 let _ = pty.kill();
1113 return Err(err);
1114 }
1115
1116 let session = Session::new(id.clone(), launch, pty, size, self.stream_max_buffer_bytes);
1117 let session = Arc::new(Mutex::new(session));
1118 let thread_name = format!("session-pump-{}", id.as_str());
1119 let (pump_tx, pump_join) = spawn_pump(Arc::clone(&session), thread_name);
1120 {
1121 let mut sess = mutex_lock_or_recover(&session);
1122 sess.attach_pump(pump_tx, pump_join);
1123 }
1124
1125 {
1126 let mut sessions = rwlock_write_or_recover(&self.sessions);
1127 sessions.insert(id.clone(), Arc::clone(&session));
1128 }
1129
1130 {
1131 let mut active = rwlock_write_or_recover(&self.active_session);
1132 *active = Some(id.clone());
1133 }
1134
1135 Ok((id, pid))
1136 }
1137
1138 pub fn get(&self, session_id: &SessionId) -> Result<Arc<Mutex<Session>>, SessionError> {
1139 let sessions = rwlock_read_or_recover(&self.sessions);
1140 sessions
1141 .get(session_id)
1142 .cloned()
1143 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))
1144 }
1145
1146 pub fn active(&self) -> Result<Arc<Mutex<Session>>, SessionError> {
1147 let active_id = {
1148 let active = rwlock_read_or_recover(&self.active_session);
1149 active.clone()
1150 };
1151
1152 match active_id {
1153 Some(id) => self.get(&id),
1154 None => Err(SessionError::NoActiveSession),
1155 }
1156 }
1157
1158 pub fn resolve(
1159 &self,
1160 session_id: Option<&SessionId>,
1161 ) -> Result<Arc<Mutex<Session>>, SessionError> {
1162 match session_id {
1163 Some(id) => self.get(id),
1164 None => {
1165 if let Ok(active_session) = self.active() {
1166 match self.session_running_state(&active_session) {
1167 Some(true) | None => return Ok(active_session),
1168 Some(false) => {}
1169 }
1170 }
1171
1172 if let Some((fallback_id, fallback_session)) = self.most_recent_running_session() {
1173 let mut active = rwlock_write_or_recover(&self.active_session);
1174 *active = Some(fallback_id);
1175 return Ok(fallback_session);
1176 }
1177
1178 let mut active = rwlock_write_or_recover(&self.active_session);
1179 *active = None;
1180 Err(SessionError::NoActiveSession)
1181 }
1182 }
1183 }
1184
1185 pub fn set_active(&self, session_id: &SessionId) -> Result<(), SessionError> {
1186 let sessions = rwlock_read_or_recover(&self.sessions);
1187 if !sessions.contains_key(session_id) {
1188 return Err(SessionError::NotFound(session_id.to_string()));
1189 }
1190 let mut active = rwlock_write_or_recover(&self.active_session);
1191 *active = Some(session_id.clone());
1192 Ok(())
1193 }
1194
1195 pub fn restart(&self, session_id: Option<&SessionId>) -> Result<RestartOutput, SessionError> {
1196 let session = self.resolve(session_id)?;
1197 let (old_session_id, launch, size) = {
1198 let sess = mutex_lock_or_recover(&session);
1199 (sess.id.clone(), sess.launch_spec(), sess.size())
1200 };
1201
1202 let new_session_id = self.next_session_id();
1203 let mut replacement_pty = PtyHandle::spawn(
1204 &launch.command,
1205 &launch.args,
1206 launch.cwd.as_deref(),
1207 launch.env.as_ref(),
1208 size,
1209 )
1210 .map_err(|e| SessionError::Terminal(e.into_port_error()))?;
1211 let pid = replacement_pty.pid().unwrap_or(0);
1212 let persisted = Self::persisted_session(&new_session_id, &launch.command, pid, size);
1213 if let Err(err) = self.persistence.add_session(persisted) {
1214 let _ = replacement_pty.kill();
1215 return Err(err);
1216 }
1217
1218 let old_join = {
1219 let mut sess = mutex_lock_or_recover(&session);
1220 let join = sess.shutdown_pump();
1221 if let Err(err) = sess.kill() {
1222 drop(sess);
1223 if let Some(join) = join {
1224 let _ = join.join();
1225 }
1226 let _ = replacement_pty.kill();
1227 if let Err(cleanup_err) = self.persistence.remove_session(&new_session_id) {
1228 warn!(
1229 session_id = %new_session_id,
1230 error = %cleanup_err,
1231 "Failed to remove pre-persisted replacement session after restart abort",
1232 );
1233 }
1234 return Err(err);
1235 }
1236 join
1237 };
1238 if let Some(join) = old_join {
1239 let _ = join.join();
1240 }
1241
1242 let new_session = Arc::new(Mutex::new(Session::new(
1243 new_session_id.clone(),
1244 launch.clone(),
1245 replacement_pty,
1246 size,
1247 self.stream_max_buffer_bytes,
1248 )));
1249 let thread_name = format!("session-pump-{}", new_session_id.as_str());
1250 let (pump_tx, pump_join) = spawn_pump(Arc::clone(&new_session), thread_name);
1251 {
1252 let mut sess = mutex_lock_or_recover(&new_session);
1253 sess.attach_pump(pump_tx, pump_join);
1254 }
1255
1256 {
1257 let mut sessions = rwlock_write_or_recover(&self.sessions);
1258 if sessions.remove(&old_session_id).is_none() {
1259 drop(sessions);
1260 let join = { mutex_lock_or_recover(&new_session).shutdown_pump() };
1261 let _ = mutex_lock_or_recover(&new_session).kill();
1262 if let Some(join) = join {
1263 let _ = join.join();
1264 }
1265 if let Err(cleanup_err) = self.persistence.remove_session(&new_session_id) {
1266 warn!(
1267 session_id = %new_session_id,
1268 error = %cleanup_err,
1269 "Failed to remove replacement session metadata after restart lookup failure",
1270 );
1271 }
1272 return Err(SessionError::NotFound(old_session_id.to_string()));
1273 }
1274 sessions.insert(new_session_id.clone(), Arc::clone(&new_session));
1275 }
1276
1277 {
1278 let mut active = rwlock_write_or_recover(&self.active_session);
1279 *active = Some(new_session_id.clone());
1280 }
1281
1282 self.persistence.remove_session(&old_session_id)?;
1283
1284 Ok(RestartOutput {
1285 old_session_id,
1286 new_session_id,
1287 command: launch.command,
1288 pid,
1289 })
1290 }
1291
1292 pub fn list(&self) -> Vec<SessionInfo> {
1293 use super::lock_helpers::acquire_session_lock;
1294
1295 let persisted_sessions = self
1296 .persistence
1297 .load()
1298 .into_iter()
1299 .map(|session| (session.id.clone(), session))
1300 .collect::<HashMap<_, _>>();
1301
1302 let session_refs: Vec<(SessionId, Arc<Mutex<Session>>)> = {
1303 let sessions = rwlock_read_or_recover(&self.sessions);
1304 sessions
1305 .iter()
1306 .map(|(id, session)| (id.clone(), Arc::clone(session)))
1307 .collect()
1308 };
1309
1310 session_refs
1311 .into_iter()
1312 .map(|(id, session)| {
1313 if let Some(mut sess) = acquire_session_lock(&session, SESSION_QUERY_LOCK_TIMEOUT) {
1314 SessionInfo {
1315 id,
1316 command: sess.command.clone(),
1317 pid: sess.pid().unwrap_or(0),
1318 running: sess.is_running(),
1319 created_at: sess.created_at.to_rfc3339(),
1320 size: sess.size(),
1321 }
1322 } else {
1323 let persisted = persisted_sessions.get(id.as_str());
1324 SessionInfo {
1325 id,
1326 command: persisted
1327 .map(|session| session.command.clone())
1328 .unwrap_or_else(|| "(locked)".to_string()),
1329 pid: persisted.map(|session| session.pid).unwrap_or(0),
1330 running: true,
1331 created_at: persisted
1332 .map(|session| session.created_at.clone())
1333 .unwrap_or_default(),
1334 size: persisted.map(|session| session.size).unwrap_or_default(),
1335 }
1336 }
1337 })
1338 .collect()
1339 }
1340
1341 pub fn kill(&self, session_id: &SessionId) -> Result<(), SessionError> {
1342 let (session, was_active) = {
1343 let sessions = rwlock_read_or_recover(&self.sessions);
1344 let session = sessions
1345 .get(session_id)
1346 .cloned()
1347 .ok_or_else(|| SessionError::NotFound(session_id.to_string()))?;
1348 let active = rwlock_read_or_recover(&self.active_session);
1349 let was_active = active.as_ref() == Some(session_id);
1350 (session, was_active)
1351 };
1352
1353 {
1354 let mut sess = mutex_lock_or_recover(&session);
1355 let join = sess.shutdown_pump();
1356 sess.kill()?;
1357 drop(sess);
1358 if let Some(join) = join {
1359 let _ = join.join();
1360 }
1361 }
1362
1363 self.persistence.remove_session(session_id)?;
1364
1365 {
1366 let mut sessions = rwlock_write_or_recover(&self.sessions);
1367 let _ = sessions.remove(session_id);
1368 }
1369
1370 if was_active {
1371 let fallback_id = self.most_recent_running_session().map(|(id, _)| id);
1372 let mut active = rwlock_write_or_recover(&self.active_session);
1373 *active = fallback_id;
1374 }
1375
1376 Ok(())
1377 }
1378
1379 pub fn session_count(&self) -> usize {
1380 rwlock_read_or_recover(&self.sessions).len()
1381 }
1382
1383 pub fn active_session_id(&self) -> Option<SessionId> {
1384 rwlock_read_or_recover(&self.active_session).clone()
1385 }
1386
1387 fn session_running_state(&self, session: &Arc<Mutex<Session>>) -> Option<bool> {
1388 use super::lock_helpers::acquire_session_lock;
1389
1390 acquire_session_lock(session, SESSION_QUERY_LOCK_TIMEOUT).map(|mut sess| sess.is_running())
1391 }
1392
1393 fn most_recent_running_session(&self) -> Option<(SessionId, Arc<Mutex<Session>>)> {
1394 use super::lock_helpers::acquire_session_lock;
1395
1396 let session_refs: Vec<(SessionId, Arc<Mutex<Session>>)> = {
1397 let sessions = rwlock_read_or_recover(&self.sessions);
1398 sessions
1399 .iter()
1400 .map(|(id, session)| (id.clone(), Arc::clone(session)))
1401 .collect()
1402 };
1403
1404 let mut selected: Option<(i64, SessionId, Arc<Mutex<Session>>)> = None;
1405
1406 for (id, session) in session_refs {
1407 let created_at = {
1408 let Some(mut sess) = acquire_session_lock(&session, SESSION_QUERY_LOCK_TIMEOUT)
1409 else {
1410 continue;
1411 };
1412
1413 if !sess.is_running() {
1414 None
1415 } else {
1416 Some(sess.created_at.timestamp_micros())
1417 }
1418 };
1419
1420 let Some(created_at) = created_at else {
1421 continue;
1422 };
1423 let replace = match selected.as_ref() {
1424 Some((best_created, best_id, _)) => {
1425 created_at > *best_created
1426 || (created_at == *best_created && id.as_str() > best_id.as_str())
1427 }
1428 None => true,
1429 };
1430
1431 if replace {
1432 selected = Some((created_at, id, session));
1433 }
1434 }
1435
1436 selected.map(|(_, id, session)| (id, session))
1437 }
1438}
1439
1440#[derive(Debug, Clone, Serialize, Deserialize)]
1441pub struct PersistedSession {
1442 pub id: String,
1443 pub command: String,
1444 pub pid: u32,
1445 pub created_at: String,
1446 #[serde(flatten)]
1447 pub size: TerminalSize,
1448}
1449
1450#[derive(Debug, Clone, Serialize, Deserialize)]
1451#[serde(tag = "type", rename_all = "snake_case")]
1452enum SessionEvent {
1453 Upsert { session: PersistedSession },
1454 Remove { session_id: String },
1455}
1456
1457#[derive(Default)]
1458struct SessionLogState {
1459 sessions: HashMap<String, PersistedSession>,
1460 unknown_records: usize,
1461}
1462
1463impl SessionLogState {
1464 fn into_sessions(self) -> Vec<PersistedSession> {
1465 self.sessions.into_values().collect()
1466 }
1467}
1468
1469pub struct SessionPersistence {
1470 path: PathBuf,
1471 lock_path: PathBuf,
1472}
1473
1474const SESSION_STORE_COMPACT_THRESHOLD_BYTES: u64 = 1_048_576;
1475
1476impl SessionPersistence {
1477 pub fn new() -> Self {
1478 let path = Self::sessions_file_path();
1479 let lock_path = path.with_extension("lock");
1480 Self { path, lock_path }
1481 }
1482
1483 fn sessions_file_path() -> PathBuf {
1484 if let Ok(path) = std::env::var("AGENT_TUI_SESSION_STORE") {
1485 let trimmed = path.trim();
1486 if !trimmed.is_empty() {
1487 return PathBuf::from(trimmed);
1488 }
1489 }
1490 let home = std::env::var("HOME")
1491 .map(PathBuf::from)
1492 .unwrap_or_else(|_| std::env::temp_dir());
1493 let dir = home.join(".agent-tui");
1494 dir.join("sessions.jsonl")
1495 }
1496
1497 fn legacy_sessions_file_path(&self) -> PathBuf {
1498 self.path.with_extension("json")
1499 }
1500
1501 fn io_to_persistence(operation: &str, e: std::io::Error) -> SessionError {
1502 let reason = e.to_string();
1503 SessionError::Persistence {
1504 operation: operation.to_string(),
1505 reason,
1506 source: Some(Box::new(e)),
1507 }
1508 }
1509
1510 fn ensure_dir(&self) -> Result<(), SessionError> {
1511 if let Some(parent) = self.path.parent() {
1512 fs::create_dir_all(parent).map_err(|e| {
1513 Self::io_to_persistence(
1514 "create_dir",
1515 std::io::Error::new(
1516 e.kind(),
1517 format!("Failed to create directory '{}': {}", parent.display(), e),
1518 ),
1519 )
1520 })?;
1521 }
1522 Ok(())
1523 }
1524
1525 fn acquire_lock(&self) -> Result<File, SessionError> {
1526 const PERSISTENCE_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
1527
1528 self.ensure_dir()?;
1529 let lock_file = OpenOptions::new()
1530 .write(true)
1531 .create(true)
1532 .truncate(false)
1533 .open(&self.lock_path)
1534 .map_err(|e| Self::io_to_persistence("open_lock", e))?;
1535
1536 let fd = lock_file.as_raw_fd();
1537 let start = Instant::now();
1538 let mut backoff = Duration::from_millis(1);
1539
1540 loop {
1541 let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
1545 if result == 0 {
1546 return Ok(lock_file);
1547 }
1548
1549 let err = std::io::Error::last_os_error();
1550 if err.raw_os_error() != Some(libc::EWOULDBLOCK)
1551 && err.raw_os_error() != Some(libc::EAGAIN)
1552 {
1553 return Err(Self::io_to_persistence("flock", err));
1554 }
1555
1556 if start.elapsed() > PERSISTENCE_LOCK_TIMEOUT {
1557 return Err(SessionError::Persistence {
1558 operation: "acquire_lock".to_string(),
1559 reason: "lock acquisition timed out after 5 seconds".to_string(),
1560 source: None,
1561 });
1562 }
1563
1564 std::thread::park_timeout(backoff);
1565 backoff = (backoff * 2).min(Duration::from_millis(100));
1566 }
1567 }
1568
1569 fn migrate_legacy_if_needed_locked(&self) -> Result<(), SessionError> {
1570 if self.path.exists() {
1571 return Ok(());
1572 }
1573 let legacy_path = self.legacy_sessions_file_path();
1574 if !legacy_path.exists() {
1575 return Ok(());
1576 }
1577 let legacy_file = File::open(&legacy_path).map_err(|e| SessionError::Persistence {
1578 operation: "open_legacy".to_string(),
1579 reason: format!(
1580 "Failed to open legacy sessions file '{}': {}",
1581 legacy_path.display(),
1582 e
1583 ),
1584 source: Some(Box::new(e)),
1585 })?;
1586 let reader = BufReader::new(legacy_file);
1587 let sessions: Vec<PersistedSession> = match serde_json::from_reader(reader) {
1588 Ok(parsed) => parsed,
1589 Err(e) => {
1590 warn!(
1591 path = %legacy_path.display(),
1592 error = %e,
1593 "Failed to parse legacy sessions file; skipping migration"
1594 );
1595 return Ok(());
1596 }
1597 };
1598
1599 self.ensure_dir()?;
1600 let mut file = OpenOptions::new()
1601 .create(true)
1602 .write(true)
1603 .truncate(true)
1604 .open(&self.path)
1605 .map_err(|e| Self::io_to_persistence("create_jsonl", e))?;
1606
1607 for session in sessions {
1608 let event = SessionEvent::Upsert { session };
1609 let line = serde_json::to_string(&event).map_err(|e| SessionError::Persistence {
1610 operation: "serialize_event".to_string(),
1611 reason: format!("Failed to serialize session event: {e}"),
1612 source: Some(Box::new(e)),
1613 })?;
1614 writeln!(file, "{line}").map_err(|e| Self::io_to_persistence("write_event", e))?;
1615 }
1616
1617 let backup_path = legacy_path.with_extension("json.bak");
1618 fs::rename(&legacy_path, &backup_path).map_err(|e| SessionError::Persistence {
1619 operation: "rename_legacy".to_string(),
1620 reason: format!(
1621 "Failed to rename legacy sessions file '{}' to '{}': {}",
1622 legacy_path.display(),
1623 backup_path.display(),
1624 e
1625 ),
1626 source: Some(Box::new(e)),
1627 })?;
1628 Ok(())
1629 }
1630
1631 fn load_log_state_unlocked(&self) -> SessionLogState {
1632 if !self.path.exists() {
1633 return SessionLogState::default();
1634 }
1635 let file = match File::open(&self.path) {
1636 Ok(file) => file,
1637 Err(e) => {
1638 warn!(
1639 path = %self.path.display(),
1640 error = %e,
1641 "Failed to open sessions log"
1642 );
1643 return SessionLogState::default();
1644 }
1645 };
1646
1647 let mut state = SessionLogState::default();
1648 let reader = BufReader::new(file);
1649 for line in reader.lines() {
1650 let line = match line {
1651 Ok(line) => line,
1652 Err(e) => {
1653 warn!(error = %e, "Failed to read session log line");
1654 state.unknown_records += 1;
1655 continue;
1656 }
1657 };
1658 let trimmed = line.trim();
1659 if trimmed.is_empty() {
1660 continue;
1661 }
1662 let event: SessionEvent = match serde_json::from_str(trimmed) {
1663 Ok(event) => event,
1664 Err(e) => {
1665 warn!(error = %e, "Failed to parse session log entry");
1666 state.unknown_records += 1;
1667 continue;
1668 }
1669 };
1670 match event {
1671 SessionEvent::Upsert { session } => {
1672 state.sessions.insert(session.id.clone(), session);
1673 }
1674 SessionEvent::Remove { session_id } => {
1675 state.sessions.remove(&session_id);
1676 }
1677 }
1678 }
1679
1680 state
1681 }
1682
1683 fn load_unlocked(&self) -> Vec<PersistedSession> {
1684 self.load_log_state_unlocked().into_sessions()
1685 }
1686
1687 fn write_event_unlocked(&self, event: &SessionEvent) -> Result<(), SessionError> {
1688 self.ensure_dir()?;
1689 let mut file = OpenOptions::new()
1690 .create(true)
1691 .append(true)
1692 .open(&self.path)
1693 .map_err(|e| Self::io_to_persistence("open_jsonl", e))?;
1694 let line = serde_json::to_string(event).map_err(|e| SessionError::Persistence {
1695 operation: "serialize_event".to_string(),
1696 reason: format!("Failed to serialize session event: {e}"),
1697 source: Some(Box::new(e)),
1698 })?;
1699 writeln!(file, "{line}").map_err(|e| Self::io_to_persistence("write_event", e))?;
1700 Ok(())
1701 }
1702
1703 fn save_unlocked(&self, sessions: &[PersistedSession]) -> Result<(), SessionError> {
1704 let temp_path = self.path.with_extension("jsonl.tmp");
1705 let file = File::create(&temp_path).map_err(|e| SessionError::Persistence {
1706 operation: "create_temp".to_string(),
1707 reason: format!(
1708 "Failed to create temp file '{}': {}",
1709 temp_path.display(),
1710 e
1711 ),
1712 source: Some(Box::new(e)),
1713 })?;
1714 let mut writer = BufWriter::new(file);
1715 for session in sessions {
1716 let event = SessionEvent::Upsert {
1717 session: session.clone(),
1718 };
1719 let line = serde_json::to_string(&event).map_err(|e| SessionError::Persistence {
1720 operation: "serialize_event".to_string(),
1721 reason: format!("Failed to serialize session event: {e}"),
1722 source: Some(Box::new(e)),
1723 })?;
1724 writeln!(writer, "{line}").map_err(|e| Self::io_to_persistence("write_event", e))?;
1725 }
1726 writer
1727 .flush()
1728 .map_err(|e| Self::io_to_persistence("flush_jsonl", e))?;
1729 fs::rename(&temp_path, &self.path).map_err(|e| SessionError::Persistence {
1730 operation: "rename".to_string(),
1731 reason: format!(
1732 "Failed to rename '{}' to '{}': {}",
1733 temp_path.display(),
1734 self.path.display(),
1735 e
1736 ),
1737 source: Some(Box::new(e)),
1738 })?;
1739 Ok(())
1740 }
1741
1742 fn maybe_compact_unlocked(&self) -> Result<(), SessionError> {
1743 let size = match fs::metadata(&self.path) {
1744 Ok(meta) => meta.len(),
1745 Err(_) => return Ok(()),
1746 };
1747 if size < SESSION_STORE_COMPACT_THRESHOLD_BYTES {
1748 return Ok(());
1749 }
1750 let state = self.load_log_state_unlocked();
1751 if state.unknown_records > 0 {
1752 warn!(
1753 path = %self.path.display(),
1754 unknown_records = state.unknown_records,
1755 "Skipping session log compaction because unknown records must be preserved"
1756 );
1757 return Ok(());
1758 }
1759 self.save_unlocked(&state.into_sessions())?;
1760 Ok(())
1761 }
1762
1763 pub fn load(&self) -> Vec<PersistedSession> {
1764 match self.acquire_lock() {
1765 Ok(_lock) => {
1766 let _ = self.migrate_legacy_if_needed_locked();
1767 self.load_unlocked()
1768 }
1769 Err(e) => {
1770 warn!(error = %e, "Failed to acquire lock for loading sessions");
1771 self.load_unlocked()
1772 }
1773 }
1774 }
1775
1776 pub fn save(&self, sessions: &[PersistedSession]) -> Result<(), SessionError> {
1777 let _lock = self.acquire_lock()?;
1778 self.migrate_legacy_if_needed_locked()?;
1779 let state = self.load_log_state_unlocked();
1780 if state.unknown_records > 0 {
1781 return Err(SessionError::Persistence {
1782 operation: "save".to_string(),
1783 reason: format!(
1784 "refusing to rewrite session log with {} unknown record(s)",
1785 state.unknown_records
1786 ),
1787 source: None,
1788 });
1789 }
1790 self.save_unlocked(sessions)
1791 }
1792
1793 pub fn add_session(&self, session: PersistedSession) -> Result<(), SessionError> {
1794 let _lock = self.acquire_lock()?;
1795 self.migrate_legacy_if_needed_locked()?;
1796 self.write_event_unlocked(&SessionEvent::Upsert { session })?;
1797 self.maybe_compact_unlocked()
1798 }
1799
1800 pub fn remove_session(&self, session_id: &str) -> Result<(), SessionError> {
1801 let _lock = self.acquire_lock()?;
1802 self.migrate_legacy_if_needed_locked()?;
1803 self.write_event_unlocked(&SessionEvent::Remove {
1804 session_id: session_id.to_string(),
1805 })?;
1806 self.maybe_compact_unlocked()
1807 }
1808
1809 pub fn cleanup_stale_sessions(&self) -> Result<usize, SessionError> {
1810 let _lock = self.acquire_lock()?;
1811 self.migrate_legacy_if_needed_locked()?;
1812 let state = self.load_log_state_unlocked();
1813 let unknown_records = state.unknown_records;
1814 let sessions = state.into_sessions();
1815 let mut cleaned = 0;
1816
1817 let mut active_sessions = Vec::new();
1818 let mut removed_session_ids = Vec::new();
1819 for session in sessions {
1820 if session.pid == 0 {
1821 cleaned += 1;
1822 removed_session_ids.push(session.id);
1823 continue;
1824 }
1825
1826 reap_child_if_any(session.pid);
1827 if !is_process_running(session.pid) {
1828 cleaned += 1;
1829 removed_session_ids.push(session.id);
1830 continue;
1831 }
1832
1833 match verify_persisted_session_identity(&session) {
1834 ProcessIdentity::Match => {
1835 let _ = terminate_process_group(session.pid);
1836 reap_child_if_any(session.pid);
1837 if !is_process_running(session.pid) {
1838 cleaned += 1;
1839 removed_session_ids.push(session.id);
1840 continue;
1841 }
1842
1843 warn!(
1844 session_id = %session.id,
1845 pid = session.pid,
1846 "Failed to terminate persisted session; leaving entry"
1847 );
1848 active_sessions.push(session);
1849 }
1850 ProcessIdentity::Mismatch => {
1851 warn!(
1852 session_id = %session.id,
1853 pid = session.pid,
1854 "Persisted PID does not match session identity; removing entry without terminating"
1855 );
1856 cleaned += 1;
1857 removed_session_ids.push(session.id);
1858 }
1859 ProcessIdentity::Unknown => {
1860 warn!(
1861 session_id = %session.id,
1862 pid = session.pid,
1863 "Unable to verify persisted PID identity; skipping termination"
1864 );
1865 active_sessions.push(session);
1866 }
1867 }
1868 }
1869
1870 if unknown_records > 0 {
1871 for session_id in removed_session_ids {
1872 self.write_event_unlocked(&SessionEvent::Remove { session_id })?;
1873 }
1874 } else {
1875 self.save_unlocked(&active_sessions)?;
1876 }
1877 Ok(cleaned)
1878 }
1879}
1880
1881#[cfg(test)]
1882mod stream_tests {
1883 use super::StreamBuffer;
1884 use super::StreamCursor;
1885 use std::sync::Arc;
1886 use std::thread;
1887 use std::time::Duration;
1888
1889 #[test]
1890 fn stream_read_returns_data_and_advances_cursor() {
1891 let buffer = StreamBuffer::new(16);
1892 let mut cursor = StreamCursor::default();
1893
1894 buffer.push(b"hello");
1895 let read = buffer
1896 .read(&mut cursor, 16, 0)
1897 .expect("stream read should succeed");
1898
1899 assert_eq!(read.data, b"hello");
1900 assert_eq!(cursor.seq, 5);
1901 assert_eq!(read.dropped_bytes, 0);
1902 assert_eq!(read.latest_cursor.seq, 5);
1903 assert!(!read.closed);
1904 }
1905
1906 #[test]
1907 fn stream_read_reports_drops_and_returns_latest_bytes() {
1908 let buffer = StreamBuffer::new(4);
1909 let mut cursor = StreamCursor::default();
1910
1911 buffer.push(b"abcdef");
1912 let read = buffer
1913 .read(&mut cursor, 10, 0)
1914 .expect("stream read should succeed");
1915
1916 assert_eq!(read.dropped_bytes, 2);
1917 assert_eq!(read.data, b"cdef");
1918 assert_eq!(cursor.seq, 6);
1919 assert_eq!(read.latest_cursor.seq, 6);
1920 assert!(!read.closed);
1921 }
1922
1923 #[test]
1924 fn stream_read_waits_until_data_or_timeout() {
1925 let buffer = Arc::new(StreamBuffer::new(16));
1926 let mut cursor = StreamCursor::default();
1927
1928 let buffer_clone = Arc::clone(&buffer);
1929 thread::spawn(move || {
1930 thread::park_timeout(Duration::from_millis(50));
1931 buffer_clone.push(b"ok");
1932 });
1933
1934 let read = buffer
1935 .read(&mut cursor, 16, 200)
1936 .expect("stream read should succeed");
1937 assert_eq!(read.data, b"ok");
1938 assert_eq!(cursor.seq, 2);
1939 assert_eq!(read.latest_cursor.seq, 2);
1940 assert!(!read.closed);
1941 }
1942
1943 #[test]
1944 fn stream_read_is_independent_per_cursor() {
1945 let buffer = StreamBuffer::new(16);
1946 let mut cursor_a = StreamCursor::default();
1947 let mut cursor_b = StreamCursor::default();
1948
1949 buffer.push(b"hello");
1950
1951 let read_a = buffer
1952 .read(&mut cursor_a, 2, 0)
1953 .expect("first stream read should succeed");
1954 let read_b = buffer
1955 .read(&mut cursor_b, 16, 0)
1956 .expect("second stream read should succeed");
1957
1958 assert_eq!(read_a.data, b"he");
1959 assert_eq!(read_b.data, b"hello");
1960 assert_eq!(cursor_a.seq, 2);
1961 assert_eq!(cursor_b.seq, 5);
1962 assert_eq!(read_a.latest_cursor.seq, 5);
1963 assert_eq!(read_b.latest_cursor.seq, 5);
1964 }
1965
1966 #[test]
1967 fn stream_subscribe_notifies_on_push() {
1968 let buffer = StreamBuffer::new(16);
1969 let subscription = buffer.subscribe();
1970 buffer.push(b"ping");
1971 assert!(subscription.wait(Some(Duration::from_millis(50))));
1972 }
1973
1974 #[test]
1975 fn stream_subscribe_notifies_on_close() {
1976 let buffer = StreamBuffer::new(16);
1977 let subscription = buffer.subscribe();
1978 buffer.close(None);
1979 assert!(subscription.wait(Some(Duration::from_millis(50))));
1980 }
1981
1982 #[test]
1983 fn stream_subscribe_drop_removes_notifier_without_extra_events() {
1984 let buffer = StreamBuffer::new(16);
1985 let subscription = buffer.subscribe();
1986 assert_eq!(buffer.notifier_count(), 1);
1987 drop(subscription);
1988 assert_eq!(
1989 buffer.notifier_count(),
1990 0,
1991 "dropped subscription should be removed immediately"
1992 );
1993 }
1994}
1995
1996#[cfg(test)]
1997mod pump_tests {
1998 use super::PUMP_FLUSH_TIMEOUT;
1999 use super::Session;
2000 use super::StreamCursor;
2001 use super::spawn_pump;
2002 use crate::common::mutex_lock_or_recover;
2003 use crate::domain::SessionId;
2004 use crate::domain::TerminalSize;
2005 use crate::infra::terminal::PtyHandle;
2006 use std::path::Path;
2007 use std::sync::Arc;
2008 use std::sync::Mutex;
2009 use std::time::Duration;
2010 use std::time::Instant;
2011
2012 #[cfg(unix)]
2013 fn run_pump_stream_output_case() {
2014 const OUTPUT_MARKER: &str = "hi";
2015 let args = vec!["-c".to_string(), "printf 'hi\\n'; sleep 0.02".to_string()];
2016 let shell = if Path::new("/bin/sh").exists() {
2017 "/bin/sh"
2018 } else {
2019 "sh"
2020 };
2021 let pty = PtyHandle::spawn(shell, &args, Some("/tmp"), None, TerminalSize::default())
2022 .expect("PTY should spawn");
2023 let session = Session::new(
2024 SessionId::try_new("test-session").expect("valid session id"),
2025 super::SessionLaunchSpec {
2026 command: "sh".to_string(),
2027 args,
2028 cwd: Some("/tmp".to_string()),
2029 env: None,
2030 },
2031 pty,
2032 TerminalSize::default(),
2033 super::DEFAULT_STREAM_MAX_BUFFER_BYTES,
2034 );
2035 let session = Arc::new(Mutex::new(session));
2036
2037 let (tx, join) = spawn_pump(Arc::clone(&session), "test-pump".to_string());
2038 {
2039 let mut guard = mutex_lock_or_recover(&session);
2040 guard.attach_pump(tx, join);
2041 }
2042
2043 let reader = { mutex_lock_or_recover(&session).stream_reader() };
2044 let mut cursor = StreamCursor::default();
2045 let deadline = Instant::now() + Duration::from_millis(1500);
2046 let mut collected = Vec::new();
2047
2048 while Instant::now() < deadline {
2049 let ack = { mutex_lock_or_recover(&session).request_flush() };
2050 if let Some(ack) = ack {
2051 let _ = ack.recv_timeout(PUMP_FLUSH_TIMEOUT);
2052 }
2053
2054 let read = reader
2055 .read(&mut cursor, 4096, 50)
2056 .expect("pump reader should succeed");
2057 if !read.data.is_empty() {
2058 collected.extend_from_slice(&read.data);
2059 }
2060 if String::from_utf8_lossy(&collected).contains(OUTPUT_MARKER) {
2061 break;
2062 }
2063 if read.closed && cursor.seq >= read.latest_cursor.seq {
2064 break;
2065 }
2066 }
2067
2068 assert!(
2069 String::from_utf8_lossy(&collected).contains(OUTPUT_MARKER),
2070 "stream buffer did not contain expected marker; collected={:?}",
2071 String::from_utf8_lossy(&collected)
2072 );
2073
2074 let join = { mutex_lock_or_recover(&session).shutdown_pump() };
2075 let _ = mutex_lock_or_recover(&session).kill();
2076 if let Some(join) = join {
2077 let _ = join.join();
2078 }
2079 }
2080
2081 #[cfg(unix)]
2082 #[test]
2083 fn session_pump_streams_output_into_buffer() {
2084 run_pump_stream_output_case();
2085 }
2086
2087 #[cfg(unix)]
2088 #[test]
2089 fn session_pump_streams_output_into_buffer_stress() {
2090 for _ in 0..20 {
2091 run_pump_stream_output_case();
2092 }
2093 }
2094}
2095
2096impl Default for SessionPersistence {
2097 fn default() -> Self {
2098 Self::new()
2099 }
2100}
2101
2102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2103enum ProcessIdentity {
2104 Match,
2105 Mismatch,
2106 Unknown,
2107}
2108
2109struct ProcessInfo {
2110 start_time: Option<DateTime<Utc>>,
2111 cmdline: Option<String>,
2112}
2113
2114fn verify_persisted_session_identity(session: &PersistedSession) -> ProcessIdentity {
2115 if session.pid == std::process::id() {
2116 return ProcessIdentity::Mismatch;
2117 }
2118
2119 let created_at = match DateTime::parse_from_rfc3339(&session.created_at) {
2120 Ok(parsed) => parsed.with_timezone(&Utc),
2121 Err(_) => return ProcessIdentity::Unknown,
2122 };
2123
2124 let info = match process_info(session.pid) {
2125 Some(info) => info,
2126 None => return ProcessIdentity::Unknown,
2127 };
2128
2129 let start_time = match info.start_time {
2130 Some(start_time) => start_time,
2131 None => return ProcessIdentity::Unknown,
2132 };
2133
2134 let delta_seconds = (start_time - created_at).num_seconds().abs();
2135 if delta_seconds > STARTUP_PID_START_TOLERANCE_SECS {
2136 return ProcessIdentity::Mismatch;
2137 }
2138
2139 if matches!(
2140 (info.cmdline.as_ref(), expected_command(&session.command)),
2141 (Some(cmdline), Some(expected)) if !cmdline.contains(expected)
2142 ) {
2143 return ProcessIdentity::Unknown;
2144 }
2145
2146 ProcessIdentity::Match
2147}
2148
2149fn expected_command(command: &str) -> Option<&str> {
2150 let trimmed = command.trim();
2151 if trimmed.is_empty() || trimmed == "(locked)" {
2152 None
2153 } else {
2154 Some(trimmed)
2155 }
2156}
2157
2158fn process_info(pid: u32) -> Option<ProcessInfo> {
2159 #[cfg(target_os = "linux")]
2160 {
2161 if let Some(info) = process_info_from_proc(pid) {
2162 return Some(info);
2163 }
2164 }
2165
2166 process_info_from_sysinfo(pid)
2167}
2168
2169#[cfg(target_os = "linux")]
2170fn process_info_from_proc(pid: u32) -> Option<ProcessInfo> {
2171 let stat_path = format!("/proc/{pid}/stat");
2172 let stat = fs::read_to_string(stat_path).ok()?;
2173 let start_ticks = parse_proc_start_time(&stat)?;
2174
2175 let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
2176 if ticks_per_second <= 0 {
2177 return None;
2178 }
2179
2180 let uptime = fs::read_to_string("/proc/uptime").ok()?;
2181 let uptime_secs: f64 = uptime.split_whitespace().next()?.parse().ok()?;
2182 let now = Utc::now();
2183 let boot_time = now - chrono::Duration::milliseconds((uptime_secs * 1000.0) as i64);
2184 let start_secs = start_ticks as f64 / ticks_per_second as f64;
2185 let start_time = boot_time + chrono::Duration::milliseconds((start_secs * 1000.0) as i64);
2186
2187 let cmdline_path = format!("/proc/{pid}/cmdline");
2188 let cmdline = fs::read(cmdline_path)
2189 .ok()
2190 .and_then(|bytes| parse_cmdline_bytes(&bytes));
2191
2192 Some(ProcessInfo {
2193 start_time: Some(start_time),
2194 cmdline,
2195 })
2196}
2197
2198#[cfg(target_os = "linux")]
2199fn parse_proc_start_time(stat: &str) -> Option<u64> {
2200 let end = stat.rfind(')')?;
2201 let after = stat.get(end + 2..)?;
2202 let mut fields = after.split_whitespace();
2203 let start_time = fields.nth(19)?;
2204 start_time.parse().ok()
2205}
2206
2207fn process_info_from_sysinfo(pid: u32) -> Option<ProcessInfo> {
2208 let pid = Pid::from_u32(pid);
2209 let refresh = ProcessRefreshKind::nothing()
2210 .with_cmd(UpdateKind::Always)
2211 .with_exe(UpdateKind::Always);
2212 let mut system = System::new();
2213 system.refresh_processes_specifics(ProcessesToUpdate::Some(&[pid]), true, refresh);
2214 let process = system.process(pid)?;
2215
2216 let cmd = process.cmd();
2217 let cmdline = if !cmd.is_empty() {
2218 let mut cmdline = String::new();
2219 for arg in cmd {
2220 if !cmdline.is_empty() {
2221 cmdline.push(' ');
2222 }
2223 cmdline.push_str(&arg.to_string_lossy());
2224 }
2225 Some(cmdline)
2226 } else {
2227 process
2228 .exe()
2229 .map(|path| path.to_string_lossy().into_owned())
2230 };
2231
2232 let start_time = if process.start_time() > 0 {
2233 let boot_time = System::boot_time();
2234 let timestamp = boot_time.saturating_add(process.start_time());
2235 Utc.timestamp_opt(timestamp as i64, 0).single()
2236 } else {
2237 None
2238 };
2239
2240 Some(ProcessInfo {
2241 start_time,
2242 cmdline,
2243 })
2244}
2245
2246fn parse_cmdline_bytes(bytes: &[u8]) -> Option<String> {
2247 if bytes.is_empty() {
2248 return None;
2249 }
2250 let decoded = String::from_utf8_lossy(bytes);
2251 let trimmed = decoded.trim_matches(|ch: char| ch == '\0' || ch.is_whitespace());
2252 if trimmed.is_empty() {
2253 None
2254 } else {
2255 let mut cmdline = String::with_capacity(trimmed.len());
2256 for ch in trimmed.chars() {
2257 if ch == '\0' {
2258 cmdline.push(' ');
2259 } else {
2260 cmdline.push(ch);
2261 }
2262 }
2263 Some(cmdline)
2264 }
2265}
2266
2267fn is_process_running(pid: u32) -> bool {
2268 let pid_t: libc::pid_t = match pid.try_into() {
2272 Ok(pid_t) => pid_t,
2273 Err(_) => return false,
2274 };
2275 unsafe { libc::kill(pid_t, 0) == 0 }
2276}
2277
2278#[cfg(unix)]
2279fn reap_child_if_any(pid: u32) {
2280 let pid_t: libc::pid_t = match pid.try_into() {
2281 Ok(pid_t) => pid_t,
2282 Err(_) => return,
2283 };
2284 let mut status: libc::c_int = 0;
2285 loop {
2286 let rc = unsafe { libc::waitpid(pid_t, &mut status, libc::WNOHANG) };
2288 if rc == -1 {
2289 let err = std::io::Error::last_os_error();
2290 if err.kind() == std::io::ErrorKind::Interrupted {
2291 continue;
2292 }
2293 }
2294 break;
2295 }
2296}
2297
2298#[cfg(not(unix))]
2299fn reap_child_if_any(_pid: u32) {}
2300
2301fn wait_for_process_exit(pid: u32, timeout: Duration) -> bool {
2302 let deadline = Instant::now() + timeout;
2303 loop {
2304 if !is_process_running(pid) {
2305 return true;
2306 }
2307 if Instant::now() >= deadline {
2308 return !is_process_running(pid);
2309 }
2310 std::thread::park_timeout(STARTUP_KILL_POLL_INTERVAL);
2311 }
2312}
2313
2314fn terminate_process_group(pid: u32) -> bool {
2315 let pid_t: libc::pid_t = match pid.try_into() {
2316 Ok(pid_t) => pid_t,
2317 Err(_) => return false,
2318 };
2319
2320 let rc = unsafe { libc::kill(-pid_t, libc::SIGTERM) };
2322 if rc == 0 && wait_for_process_exit(pid, STARTUP_TERMINATE_TIMEOUT) {
2323 return true;
2324 }
2325 if rc != 0 {
2326 let err = std::io::Error::last_os_error();
2327 if err.raw_os_error() == Some(libc::ESRCH) {
2328 return true;
2329 }
2330 }
2331
2332 let rc = unsafe { libc::kill(-pid_t, libc::SIGKILL) };
2333 if rc == 0 {
2334 return wait_for_process_exit(pid, STARTUP_KILL_TIMEOUT);
2335 }
2336 if rc != 0 {
2337 let err = std::io::Error::last_os_error();
2338 if err.raw_os_error() == Some(libc::ESRCH) {
2339 return true;
2340 }
2341 }
2342
2343 false
2344}
2345
2346impl From<&SessionInfo> for PersistedSession {
2347 fn from(info: &SessionInfo) -> Self {
2348 PersistedSession {
2349 id: info.id.to_string(),
2350 command: info.command.clone(),
2351 pid: info.pid,
2352 created_at: info.created_at.clone(),
2353 size: info.size,
2354 }
2355 }
2356}
2357
2358#[cfg(test)]
2359#[path = "session_tests.rs"]
2360mod tests;