Skip to main content

taskers_runtime/
session.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    env, fs,
4    io::{self, BufRead, BufReader, Read, Write},
5    os::unix::io::AsRawFd,
6    os::unix::net::{UnixListener, UnixStream},
7    path::{Path, PathBuf},
8    sync::{
9        Arc, Mutex,
10        atomic::{AtomicU64, Ordering},
11        mpsc,
12    },
13    thread,
14};
15
16use anyhow::{Context, Result, anyhow, bail};
17use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
18use serde::{Deserialize, Serialize};
19
20use crate::{CommandSpec, PtySession, SignalStreamParser};
21
22const MAX_TRANSCRIPT_BYTES: usize = 262_144;
23const OUTPUT_CHUNK_BYTES: usize = 8192;
24const ATTACH_ENV_KEYS: &[&str] = &[
25    "HOME",
26    "PATH",
27    "SHELL",
28    "TERM",
29    "TERMINFO",
30    "TERMINFO_DIRS",
31    "COLORTERM",
32    "TERM_PROGRAM",
33    "TASKERS_AGENT_SESSION_ID",
34    "TASKERS_CTL_PATH",
35    "TASKERS_DISABLE_SHELL_INTEGRATION",
36    "TASKERS_EMBEDDED",
37    "TASKERS_PANE_ID",
38    "TASKERS_REAL_SHELL",
39    "TASKERS_SHELL_INTEGRATION_DIR",
40    "TASKERS_SOCKET",
41    "TASKERS_SURFACE_ID",
42    "TASKERS_TERMINAL_SESSION_ID",
43    "TASKERS_TERMINAL_SOCKET",
44    "TASKERS_USER_BASHRC",
45    "TASKERS_USER_ZDOTDIR",
46    "TASKERS_WORKSPACE_ID",
47    "TASKERS_TTY_NAME",
48    "ZDOTDIR",
49];
50
51#[derive(Debug, Clone)]
52pub struct TerminalSessionClient {
53    socket_path: PathBuf,
54}
55
56impl TerminalSessionClient {
57    pub fn new(socket_path: PathBuf) -> Self {
58        Self { socket_path }
59    }
60
61    pub fn socket_path(&self) -> &Path {
62        &self.socket_path
63    }
64
65    pub fn ping(&self) -> Result<()> {
66        let mut stream = self.connect()?;
67        write_request(&mut stream, &SessionRequest::Ping)?;
68        match read_event(&mut BufReader::new(stream))? {
69            SessionEvent::Pong => Ok(()),
70            other => bail!("unexpected ping response: {other:?}"),
71        }
72    }
73
74    pub fn has_session(&self, session_id: &str) -> Result<bool> {
75        let mut stream = self.connect()?;
76        write_request(
77            &mut stream,
78            &SessionRequest::HasSession {
79                session_id: session_id.into(),
80            },
81        )?;
82        match read_event(&mut BufReader::new(stream))? {
83            SessionEvent::Exists { exists } => Ok(exists),
84            SessionEvent::Error { message } => Err(anyhow!(message)),
85            other => bail!("unexpected session lookup response: {other:?}"),
86        }
87    }
88
89    pub fn terminate_session(&self, session_id: &str) -> Result<()> {
90        let mut stream = self.connect()?;
91        write_request(
92            &mut stream,
93            &SessionRequest::Terminate {
94                session_id: session_id.into(),
95            },
96        )?;
97        match read_event(&mut BufReader::new(stream))? {
98            SessionEvent::Ack => Ok(()),
99            SessionEvent::Error { message } => Err(anyhow!(message)),
100            other => bail!("unexpected terminate response: {other:?}"),
101        }
102    }
103
104    pub fn list_sessions(&self) -> Result<Vec<String>> {
105        let mut stream = self.connect()?;
106        write_request(&mut stream, &SessionRequest::ListSessions)?;
107        match read_event(&mut BufReader::new(stream))? {
108            SessionEvent::SessionList { session_ids } => Ok(session_ids),
109            SessionEvent::Error { message } => Err(anyhow!(message)),
110            other => bail!("unexpected session list response: {other:?}"),
111        }
112    }
113
114    pub fn attach_or_create(&self, session_id: &str, shell_args: &[String]) -> Result<()> {
115        let _terminal_mode = TerminalModeGuard::new()?;
116        let (mut cols, mut rows) = terminal_size().unwrap_or((120, 40));
117        let mut stream = self.connect()?;
118        let attach = SessionRequest::Attach {
119            session_id: session_id.into(),
120            cols,
121            rows,
122            cwd: env::current_dir()
123                .ok()
124                .map(|path| path.display().to_string()),
125            shell_args: shell_args.to_vec(),
126            env: collect_attach_env(),
127        };
128        write_request(&mut stream, &attach)?;
129        stream
130            .set_nonblocking(true)
131            .context("failed to set terminal session socket nonblocking")?;
132
133        let stdin = io::stdin();
134        let stdin_fd = stdin.as_raw_fd();
135        let socket_fd = stream.as_raw_fd();
136        let mut stdin = stdin.lock();
137        let mut stdout = io::stdout().lock();
138        let mut input_buffer = [0u8; 4096];
139        let mut socket_buffer = Vec::new();
140
141        loop {
142            if let Some((next_cols, next_rows)) = terminal_size() {
143                if next_cols != cols || next_rows != rows {
144                    cols = next_cols;
145                    rows = next_rows;
146                    write_request(&mut stream, &SessionRequest::Resize { cols, rows })?;
147                }
148            }
149
150            let mut pollfds = [
151                libc::pollfd {
152                    fd: stdin_fd,
153                    events: libc::POLLIN | libc::POLLHUP | libc::POLLERR,
154                    revents: 0,
155                },
156                libc::pollfd {
157                    fd: socket_fd,
158                    events: libc::POLLIN | libc::POLLERR | libc::POLLHUP,
159                    revents: 0,
160                },
161            ];
162            let poll_result = unsafe { libc::poll(pollfds.as_mut_ptr(), pollfds.len() as _, 100) };
163            if poll_result < 0 {
164                let error = io::Error::last_os_error();
165                if error.kind() == io::ErrorKind::Interrupted {
166                    continue;
167                }
168                return Err(error).context("terminal session poll failed");
169            }
170
171            if (pollfds[1].revents & (libc::POLLERR | libc::POLLHUP)) != 0 {
172                break;
173            }
174            if (pollfds[1].revents & libc::POLLIN) != 0 {
175                if pump_session_events(&mut stream, &mut socket_buffer, &mut stdout)? {
176                    break;
177                }
178            }
179            if (pollfds[0].revents & (libc::POLLERR | libc::POLLHUP)) != 0 {
180                let _ = write_request(&mut stream, &SessionRequest::Detach);
181                break;
182            }
183            if (pollfds[0].revents & libc::POLLIN) != 0 {
184                let bytes_read = stdin
185                    .read(&mut input_buffer)
186                    .context("failed to read terminal stdin")?;
187                if bytes_read == 0 {
188                    let _ = write_request(&mut stream, &SessionRequest::Detach);
189                    break;
190                }
191                write_request(
192                    &mut stream,
193                    &SessionRequest::Input {
194                        data_b64: BASE64.encode(&input_buffer[..bytes_read]),
195                    },
196                )?;
197            }
198        }
199
200        Ok(())
201    }
202
203    fn connect(&self) -> Result<UnixStream> {
204        UnixStream::connect(&self.socket_path).with_context(|| {
205            format!(
206                "failed to connect to terminal session socket {}",
207                self.socket_path.display()
208            )
209        })
210    }
211}
212
213#[derive(Clone)]
214pub struct TerminalSessionDaemon {
215    sessions: Arc<Mutex<HashMap<String, SessionState>>>,
216    next_client_id: Arc<AtomicU64>,
217}
218
219struct SessionState {
220    pty: Arc<Mutex<PtySession>>,
221    transcript: Vec<u8>,
222    needs_redraw: bool,
223    clients: HashMap<u64, mpsc::Sender<SessionEvent>>,
224}
225
226impl TerminalSessionDaemon {
227    pub fn new() -> Self {
228        Self {
229            sessions: Arc::new(Mutex::new(HashMap::new())),
230            next_client_id: Arc::new(AtomicU64::new(1)),
231        }
232    }
233
234    pub fn serve(&self, socket_path: &Path) -> Result<()> {
235        if let Some(parent) = socket_path.parent() {
236            fs::create_dir_all(parent).with_context(|| {
237                format!(
238                    "failed to create terminal session socket directory {}",
239                    parent.display()
240                )
241            })?;
242        }
243        if socket_path.exists() {
244            fs::remove_file(socket_path).with_context(|| {
245                format!("failed to remove stale socket {}", socket_path.display())
246            })?;
247        }
248
249        let listener = UnixListener::bind(socket_path)
250            .with_context(|| format!("failed to bind {}", socket_path.display()))?;
251        set_private_socket_permissions(socket_path)?;
252        for stream in listener.incoming() {
253            let stream = match stream {
254                Ok(stream) => stream,
255                Err(error) => {
256                    eprintln!("terminal session accept failed: {error}");
257                    continue;
258                }
259            };
260            let daemon = self.clone();
261            thread::spawn(move || {
262                if let Err(error) = daemon.handle_connection(stream) {
263                    eprintln!("terminal session connection failed: {error:?}");
264                }
265            });
266        }
267        Ok(())
268    }
269
270    fn handle_connection(&self, mut stream: UnixStream) -> Result<()> {
271        ensure_peer_is_owner(&stream)?;
272        let mut reader = BufReader::new(
273            stream
274                .try_clone()
275                .context("failed to clone terminal session stream")?,
276        );
277        let request = read_request(&mut reader)?;
278        match request {
279            SessionRequest::Ping => {
280                write_event(&mut stream, &SessionEvent::Pong)?;
281            }
282            SessionRequest::ListSessions => {
283                let session_ids = self
284                    .sessions
285                    .lock()
286                    .expect("session daemon mutex poisoned")
287                    .keys()
288                    .cloned()
289                    .collect::<Vec<_>>();
290                write_event(&mut stream, &SessionEvent::SessionList { session_ids })?;
291            }
292            SessionRequest::HasSession { session_id } => {
293                let exists = self
294                    .sessions
295                    .lock()
296                    .expect("session daemon mutex poisoned")
297                    .contains_key(&session_id);
298                write_event(&mut stream, &SessionEvent::Exists { exists })?;
299            }
300            SessionRequest::Terminate { session_id } => {
301                let state = self
302                    .sessions
303                    .lock()
304                    .expect("session daemon mutex poisoned")
305                    .remove(&session_id);
306                if let Some(state) = state {
307                    state
308                        .pty
309                        .lock()
310                        .expect("pty session mutex poisoned")
311                        .kill()
312                        .ok();
313                }
314                write_event(&mut stream, &SessionEvent::Ack)?;
315            }
316            SessionRequest::Attach {
317                session_id,
318                cols,
319                rows,
320                cwd,
321                shell_args,
322                env,
323            } => {
324                self.attach_client(stream, session_id, cols, rows, cwd, shell_args, env)?;
325            }
326            other => bail!("unexpected initial request: {other:?}"),
327        }
328        Ok(())
329    }
330
331    fn attach_client(
332        &self,
333        stream: UnixStream,
334        session_id: String,
335        cols: u16,
336        rows: u16,
337        cwd: Option<String>,
338        shell_args: Vec<String>,
339        env: BTreeMap<String, String>,
340    ) -> Result<()> {
341        let created = self.ensure_session(&session_id, cols, rows, cwd, shell_args, env)?;
342
343        let client_id = self.next_client_id.fetch_add(1, Ordering::Relaxed);
344        let (tx, rx) = mpsc::channel::<SessionEvent>();
345        let (transcript, redraw_after_attach, pty) = {
346            let mut sessions = self.sessions.lock().expect("session daemon mutex poisoned");
347            let session = sessions
348                .get_mut(&session_id)
349                .ok_or_else(|| anyhow!("session {session_id} was not created"))?;
350            let redraw_after_attach =
351                !created && session.needs_redraw && session.transcript.is_empty();
352            session.clients.insert(client_id, tx.clone());
353            if redraw_after_attach {
354                session.needs_redraw = false;
355            }
356            (
357                session.transcript.clone(),
358                redraw_after_attach,
359                Arc::clone(&session.pty),
360            )
361        };
362
363        tx.send(SessionEvent::Attached).ok();
364        queue_output(&tx, &transcript);
365        if redraw_after_attach {
366            let _ = pty
367                .lock()
368                .expect("pty session mutex poisoned")
369                .write_all(b"\x0c");
370        }
371
372        let writer_stream = stream
373            .try_clone()
374            .context("failed to clone client stream")?;
375        let writer = thread::spawn(move || -> Result<()> {
376            let mut writer = writer_stream;
377            while let Ok(event) = rx.recv() {
378                write_event(&mut writer, &event)?;
379            }
380            Ok(())
381        });
382
383        let mut reader = BufReader::new(stream);
384        loop {
385            match read_request(&mut reader) {
386                Ok(SessionRequest::Input { data_b64 }) => {
387                    let bytes = BASE64
388                        .decode(data_b64)
389                        .context("failed to decode session input")?;
390                    let pty = {
391                        let sessions = self.sessions.lock().expect("session daemon mutex poisoned");
392                        sessions
393                            .get(&session_id)
394                            .map(|session| Arc::clone(&session.pty))
395                            .ok_or_else(|| anyhow!("session {session_id} vanished"))?
396                    };
397                    pty.lock()
398                        .expect("pty session mutex poisoned")
399                        .write_all(&bytes)
400                        .context("failed to write session input")?;
401                }
402                Ok(SessionRequest::Resize { cols, rows }) => {
403                    let pty = {
404                        let sessions = self.sessions.lock().expect("session daemon mutex poisoned");
405                        sessions
406                            .get(&session_id)
407                            .map(|session| Arc::clone(&session.pty))
408                            .ok_or_else(|| anyhow!("session {session_id} vanished"))?
409                    };
410                    pty.lock()
411                        .expect("pty session mutex poisoned")
412                        .resize(cols, rows)
413                        .context("failed to resize session")?;
414                }
415                Ok(SessionRequest::Detach) => break,
416                Ok(other) => bail!("unexpected attach request: {other:?}"),
417                Err(error) => {
418                    if is_unexpected_eof(&error) {
419                        break;
420                    }
421                    return Err(error);
422                }
423            }
424        }
425
426        {
427            let mut sessions = self.sessions.lock().expect("session daemon mutex poisoned");
428            if let Some(session) = sessions.get_mut(&session_id) {
429                session.clients.remove(&client_id);
430                if session.clients.is_empty() {
431                    session.transcript.clear();
432                    session.needs_redraw = true;
433                }
434            }
435        }
436        drop(tx);
437        writer
438            .join()
439            .map_err(|_| anyhow!("client writer panicked"))??;
440        Ok(())
441    }
442
443    fn ensure_session(
444        &self,
445        session_id: &str,
446        cols: u16,
447        rows: u16,
448        cwd: Option<String>,
449        shell_args: Vec<String>,
450        env: BTreeMap<String, String>,
451    ) -> Result<bool> {
452        let mut sessions = self.sessions.lock().expect("session daemon mutex poisoned");
453        if sessions.contains_key(session_id) {
454            return Ok(false);
455        }
456
457        let spec = build_command_spec(cols, rows, cwd, shell_args, env)?;
458        let spawned = PtySession::spawn(&spec)?;
459        let pty = Arc::new(Mutex::new(spawned.session));
460        let reader = spawned.reader;
461        sessions.insert(
462            session_id.into(),
463            SessionState {
464                pty: Arc::clone(&pty),
465                transcript: Vec::new(),
466                needs_redraw: false,
467                clients: HashMap::new(),
468            },
469        );
470        drop(sessions);
471        self.spawn_reader(session_id.to_string(), reader);
472        Ok(true)
473    }
474
475    fn spawn_reader(&self, session_id: String, mut reader: crate::PtyReader) {
476        let sessions = Arc::clone(&self.sessions);
477        thread::spawn(move || {
478            let mut buffer = [0u8; 4096];
479            let mut signal_parser = SignalStreamParser::default();
480            loop {
481                let bytes_read = match reader.read_into(&mut buffer) {
482                    Ok(0) => break,
483                    Ok(bytes_read) => bytes_read,
484                    Err(_) => break,
485                };
486                let chunk = &buffer[..bytes_read];
487                let mut clients = Vec::new();
488                {
489                    let mut all_sessions = sessions.lock().expect("session daemon mutex poisoned");
490                    let Some(session) = all_sessions.get_mut(&session_id) else {
491                        break;
492                    };
493                    session.transcript.extend_from_slice(chunk);
494                    trim_transcript(&mut session.transcript);
495                    clients.extend(session.clients.values().cloned());
496                }
497
498                for _event in signal_parser.push_events(&String::from_utf8_lossy(chunk)) {}
499                for client in clients {
500                    queue_output(&client, chunk);
501                }
502            }
503
504            let clients = {
505                let mut all_sessions = sessions.lock().expect("session daemon mutex poisoned");
506                all_sessions
507                    .remove(&session_id)
508                    .map(|session| session.clients.into_values().collect::<Vec<_>>())
509                    .unwrap_or_default()
510            };
511            for client in clients {
512                client.send(SessionEvent::Closed).ok();
513            }
514        });
515    }
516}
517
518fn build_command_spec(
519    cols: u16,
520    rows: u16,
521    cwd: Option<String>,
522    mut shell_args: Vec<String>,
523    mut env_map: BTreeMap<String, String>,
524) -> Result<CommandSpec> {
525    let integration_dir = env_map
526        .get("TASKERS_SHELL_INTEGRATION_DIR")
527        .cloned()
528        .ok_or_else(|| anyhow!("missing TASKERS_SHELL_INTEGRATION_DIR"))?;
529    env_map.insert("TASKERS_SESSION_CHILD".into(), "1".into());
530    let wrapper_path = PathBuf::from(integration_dir).join("taskers-shell-wrapper.sh");
531    let mut args = vec![wrapper_path.display().to_string()];
532    args.append(&mut shell_args);
533    env_map
534        .entry("TERM".into())
535        .or_insert_with(|| "xterm-256color".into());
536
537    Ok(CommandSpec {
538        program: "sh".into(),
539        args,
540        cwd: cwd.map(PathBuf::from),
541        env: env_map,
542        cols,
543        rows,
544    })
545}
546
547fn collect_attach_env() -> BTreeMap<String, String> {
548    let mut env_map = BTreeMap::new();
549    for key in ATTACH_ENV_KEYS {
550        if let Ok(value) = env::var(key) {
551            env_map.insert((*key).into(), value);
552        }
553    }
554    env_map
555}
556
557fn trim_transcript(transcript: &mut Vec<u8>) {
558    if transcript.len() <= MAX_TRANSCRIPT_BYTES {
559        return;
560    }
561    let drop_len = transcript.len() - MAX_TRANSCRIPT_BYTES;
562    transcript.drain(..drop_len);
563}
564
565fn queue_output(sender: &mpsc::Sender<SessionEvent>, bytes: &[u8]) {
566    for chunk in bytes.chunks(OUTPUT_CHUNK_BYTES) {
567        sender
568            .send(SessionEvent::Output {
569                data_b64: BASE64.encode(chunk),
570            })
571            .ok();
572    }
573}
574
575fn write_request(stream: &mut UnixStream, request: &SessionRequest) -> Result<()> {
576    let data = serde_json::to_vec(request).context("failed to serialize session request")?;
577    stream
578        .write_all(&data)
579        .context("failed to write session request")?;
580    stream
581        .write_all(b"\n")
582        .context("failed to terminate session request")?;
583    stream.flush().ok();
584    Ok(())
585}
586
587fn write_event(stream: &mut UnixStream, event: &SessionEvent) -> Result<()> {
588    let data = serde_json::to_vec(event).context("failed to serialize session event")?;
589    stream
590        .write_all(&data)
591        .context("failed to write session event")?;
592    stream
593        .write_all(b"\n")
594        .context("failed to terminate session event")?;
595    stream.flush().ok();
596    Ok(())
597}
598
599fn read_request(reader: &mut BufReader<UnixStream>) -> Result<SessionRequest> {
600    let mut line = String::new();
601    let bytes = reader
602        .read_line(&mut line)
603        .context("failed to read session request")?;
604    if bytes == 0 {
605        bail!("unexpected EOF while reading session request");
606    }
607    serde_json::from_str(line.trim_end()).context("failed to parse session request")
608}
609
610fn read_event(reader: &mut BufReader<UnixStream>) -> Result<SessionEvent> {
611    let mut line = String::new();
612    let bytes = reader
613        .read_line(&mut line)
614        .context("failed to read session event")?;
615    if bytes == 0 {
616        bail!("unexpected EOF while reading session event");
617    }
618    serde_json::from_str(line.trim_end()).context("failed to parse session event")
619}
620
621fn is_unexpected_eof(error: &anyhow::Error) -> bool {
622    error
623        .to_string()
624        .contains("unexpected EOF while reading session request")
625}
626
627fn set_private_socket_permissions(socket_path: &Path) -> Result<()> {
628    use std::os::unix::fs::PermissionsExt;
629
630    let permissions = fs::Permissions::from_mode(0o600);
631    fs::set_permissions(socket_path, permissions).with_context(|| {
632        format!(
633            "failed to set private permissions on terminal socket {}",
634            socket_path.display()
635        )
636    })
637}
638
639fn ensure_peer_is_owner(stream: &UnixStream) -> Result<()> {
640    #[cfg(not(target_os = "linux"))]
641    {
642        let _ = stream;
643        return Ok(());
644    }
645
646    #[cfg(target_os = "linux")]
647    {
648        let expected_uid = unsafe { libc::geteuid() };
649        let mut credentials = libc::ucred {
650            pid: 0,
651            uid: 0,
652            gid: 0,
653        };
654        let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
655        let result = unsafe {
656            libc::getsockopt(
657                stream.as_raw_fd(),
658                libc::SOL_SOCKET,
659                libc::SO_PEERCRED,
660                (&mut credentials as *mut libc::ucred).cast(),
661                &mut len,
662            )
663        };
664        if result != 0 {
665            return Err(io::Error::last_os_error()).context("failed to read peer credentials");
666        }
667        if credentials.uid != expected_uid {
668            bail!(
669                "rejecting terminal session client from uid {} (expected {})",
670                credentials.uid,
671                expected_uid
672            );
673        }
674        Ok(())
675    }
676}
677
678fn pump_session_events(
679    stream: &mut UnixStream,
680    pending: &mut Vec<u8>,
681    stdout: &mut impl Write,
682) -> Result<bool> {
683    let mut buffer = [0u8; 4096];
684    loop {
685        match stream.read(&mut buffer) {
686            Ok(0) => return Ok(true),
687            Ok(bytes_read) => pending.extend_from_slice(&buffer[..bytes_read]),
688            Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
689            Err(error) => return Err(error).context("failed to read terminal session socket"),
690        }
691    }
692
693    while let Some(newline) = pending.iter().position(|byte| *byte == b'\n') {
694        let line = pending.drain(..=newline).collect::<Vec<_>>();
695        let event = serde_json::from_slice::<SessionEvent>(&line[..line.len() - 1])
696            .context("failed to parse session event")?;
697        match event {
698            SessionEvent::Attached => {}
699            SessionEvent::Output { data_b64 } => {
700                let bytes = BASE64
701                    .decode(data_b64)
702                    .context("failed to decode sidecar output")?;
703                stdout
704                    .write_all(&bytes)
705                    .context("failed to write sidecar output")?;
706                stdout.flush().ok();
707            }
708            SessionEvent::Closed => return Ok(true),
709            SessionEvent::Error { message } => return Err(anyhow!(message)),
710            other => bail!("unexpected attach event: {other:?}"),
711        }
712    }
713
714    Ok(false)
715}
716
717struct TerminalModeGuard {
718    fd: i32,
719    original: libc::termios,
720}
721
722impl TerminalModeGuard {
723    fn new() -> Result<Option<Self>> {
724        let stdin = io::stdin();
725        let fd = stdin.as_raw_fd();
726        let is_tty = unsafe { libc::isatty(fd) } == 1;
727        if !is_tty {
728            return Ok(None);
729        }
730
731        let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
732        let get_result = unsafe { libc::tcgetattr(fd, &mut termios) };
733        if get_result != 0 {
734            bail!("failed to read terminal mode");
735        }
736        let original = termios;
737        unsafe {
738            libc::cfmakeraw(&mut termios);
739        }
740        let set_result = unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) };
741        if set_result != 0 {
742            bail!("failed to set terminal raw mode");
743        }
744
745        Ok(Some(Self { fd, original }))
746    }
747}
748
749impl Drop for TerminalModeGuard {
750    fn drop(&mut self) {
751        unsafe {
752            libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
753        }
754    }
755}
756
757fn terminal_size() -> Option<(u16, u16)> {
758    let stdout = io::stdout();
759    let fd = stdout.as_raw_fd();
760    let mut winsize = libc::winsize {
761        ws_row: 0,
762        ws_col: 0,
763        ws_xpixel: 0,
764        ws_ypixel: 0,
765    };
766    let result = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut winsize) };
767    if result != 0 || winsize.ws_col == 0 || winsize.ws_row == 0 {
768        return None;
769    }
770    Some((winsize.ws_col, winsize.ws_row))
771}
772
773#[derive(Debug, Serialize, Deserialize)]
774#[serde(tag = "type", rename_all = "snake_case")]
775enum SessionRequest {
776    Ping,
777    ListSessions,
778    HasSession {
779        session_id: String,
780    },
781    Terminate {
782        session_id: String,
783    },
784    Attach {
785        session_id: String,
786        cols: u16,
787        rows: u16,
788        cwd: Option<String>,
789        shell_args: Vec<String>,
790        env: BTreeMap<String, String>,
791    },
792    Input {
793        data_b64: String,
794    },
795    Resize {
796        cols: u16,
797        rows: u16,
798    },
799    Detach,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
803#[serde(tag = "type", rename_all = "snake_case")]
804enum SessionEvent {
805    Pong,
806    SessionList { session_ids: Vec<String> },
807    Exists { exists: bool },
808    Ack,
809    Attached,
810    Output { data_b64: String },
811    Closed,
812    Error { message: String },
813}
814
815#[cfg(test)]
816mod tests {
817    use super::{collect_attach_env, terminal_size};
818
819    #[test]
820    fn collect_attach_env_preserves_terminal_session_identity() {
821        unsafe {
822            std::env::set_var("TASKERS_TERMINAL_SESSION_ID", "session-123");
823        }
824        let env = collect_attach_env();
825        assert_eq!(
826            env.get("TASKERS_TERMINAL_SESSION_ID").map(String::as_str),
827            Some("session-123")
828        );
829        unsafe {
830            std::env::remove_var("TASKERS_TERMINAL_SESSION_ID");
831        }
832    }
833
834    #[test]
835    fn terminal_size_helper_returns_none_or_positive_dimensions() {
836        match terminal_size() {
837            Some((cols, rows)) => {
838                assert!(cols > 0);
839                assert!(rows > 0);
840            }
841            None => {}
842        }
843    }
844}