Skip to main content

agent_tui_infra/infra/daemon/
session.rs

1//! Daemon session runtime.
2
3use 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            // SAFETY: `flock` is safe to call with a valid file descriptor obtained from
1542            // `as_raw_fd()`. The file remains open throughout this loop, ensuring the fd
1543            // is valid. LOCK_EX | LOCK_NB requests an exclusive, non-blocking lock.
1544            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    // SAFETY: `kill` with signal 0 performs a permission check without sending any signal.
2269    // This is a standard POSIX idiom to check if a process exists. The pid is validated
2270    // before converting to pid_t.
2271    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        // SAFETY: waitpid is safe with a valid pid and status pointer.
2287        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    // SAFETY: negative pid targets the process group created for the session leader.
2321    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;