dure 0.1.0

Detachable Windows console sessions that outlive the terminal
Documentation
//! `dure resume`.

use std::io::{self, Write};

use ohno::AppError;

use crate::attach::attach;
use crate::detect::{DetectOutcome, auto_detect};
use crate::gc::{live_sessions, require_live_session};
use crate::list_fmt::format_list;
use crate::pal::local_console::LocalConsole;
use crate::pal::processes::Processes;
use crate::pal::session_store::SessionStore;
use crate::pal::transport::Transport;
use crate::session_id::SessionId;
use crate::session_record::SessionRecord;
use crate::trace::{Trace, trace};
use crate::types::Outcome;
use crate::wall_clock::unix_now_ms;
use crate::{
    CanonicalizeError, CurrentDirectoryError, NoLiveSessionsError, PalFailedError,
    PromptFailedError, SessionNotFoundError, parse_prompted_id,
};

/// Attach using auto-detect or an explicit id.
pub(crate) fn execute<S, P, T, C>(
    store: &S,
    processes: &P,
    transport: &T,
    console: &C,
    id: Option<SessionId>,
    trace: Trace,
) -> Result<Outcome, AppError>
where
    S: SessionStore,
    P: Processes,
    T: Transport + Clone + Send + Sync + 'static,
    C: LocalConsole + Clone + Send + Sync + 'static,
{
    let record = match id {
        Some(id) => {
            trace!(
                trace,
                "session {id} was named on the command line, so auto-detect is skipped"
            );
            require_live_session(store, processes, id, trace)?
        }
        None => {
            let live = live_sessions(store, processes, trace)?;
            let id = resolve_auto(store, console, &live, trace)?;
            live.into_iter()
                .find(|record| record.session_id() == id)
                .ok_or_else(|| AppError::from(SessionNotFoundError::for_id(id)))?
        }
    };
    trace!(
        trace,
        "attaching to session {} on {}",
        record.session_id(),
        record.pipe_name
    );
    attach(transport, console, &record.pipe_name, record.session_id())
}

fn resolve_auto<S, C>(
    store: &S,
    console: &C,
    live: &[SessionRecord],
    trace: Trace,
) -> Result<SessionId, AppError>
where
    S: SessionStore,
    C: LocalConsole,
{
    let cwd = store
        .current_dir()
        .map_err(|_error| CurrentDirectoryError::new())?;
    let cwd = store
        .canonicalize(&cwd)
        .map_err(|_error| CanonicalizeError::new(cwd))?;
    match auto_detect(live, &cwd, trace) {
        DetectOutcome::None => Err(NoLiveSessionsError::new().into()),
        DetectOutcome::Unique(id) => Ok(id),
        DetectOutcome::Ambiguous(sessions) => {
            println!("{}", format_list(&sessions, unix_now_ms()));
            if !console.stdin_is_terminal() {
                return Err(PromptFailedError::new().into());
            }
            // The read below blocks, so say what is being waited for.
            print!("Session id to resume: ");
            io::stdout()
                .flush()
                .map_err(|_error| PalFailedError::new())?;
            let line = console
                .read_prompt_line()
                .map_err(|_error| PromptFailedError::new())?;
            parse_prompted_id(&line).map_err(AppError::from)
        }
    }
}

#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
    use std::path::PathBuf;
    use std::thread;

    use super::*;
    use crate::pal::error::{PalError, PalErrorKind};
    use crate::pal::local_console::{LocalConsoleFacade, MockLocalConsole};
    use crate::pal::processes::{MockProcesses, ProcessLiveness};
    use crate::pal::pseudoconsole::WindowSize;
    use crate::pal::session_store::{FsSessionStore, SessionStore};
    use crate::pal::transport::MemoryTransport;
    use crate::protocol::Message;
    use crate::session_id::SessionId;
    use crate::session_record::ProcessIdentity;
    use crate::{InvalidSessionIdError, PromptFailedError};

    /// Publishes two sessions whose launch directories never match the current
    /// directory, so auto-detect reports an ambiguous result.
    fn publish_ambiguous_sessions(store: &FsSessionStore) {
        for name in ["one", "two"] {
            let id = store.allocate_id(&ProcessIdentity::for_test(1)).unwrap();
            store
                .publish(&SessionRecord {
                    id: id.get(),
                    supervisor_pid: 10,
                    supervisor_creation_time: 100,
                    pipe_name: name.to_string(),
                    launch_directory: PathBuf::from(format!("/nowhere/{name}")),
                    command: vec!["app.exe".to_string()],
                    started_at_unix_ms: 1,
                    attached: false,
                })
                .unwrap();
        }
    }

    #[test]
    // Talks to the real operating system: the session store is a real directory.
    #[cfg_attr(miri, ignore)]
    fn no_live_sessions_fails() {
        let dir = tempfile::TempDir::new().unwrap();
        let store = FsSessionStore::new(dir.path().to_path_buf());
        let processes = MockProcesses::new();
        let transport = MemoryTransport::new();
        let console = LocalConsoleFacade::from_mock(MockLocalConsole::new());
        execute(
            &store,
            &processes,
            &transport,
            &console,
            None,
            Trace::default(),
        )
        .unwrap_err();
    }

    #[test]
    // Talks to the real operating system: the session store is a real directory.
    #[cfg_attr(miri, ignore)]
    fn missing_explicit_id_fails() {
        let dir = tempfile::TempDir::new().unwrap();
        let store = FsSessionStore::new(dir.path().to_path_buf());
        let processes = MockProcesses::new();
        let transport = MemoryTransport::new();
        let console = LocalConsoleFacade::from_mock(MockLocalConsole::new());
        let id = SessionId::from_u32(9).unwrap();
        execute(
            &store,
            &processes,
            &transport,
            &console,
            Some(id),
            Trace::default(),
        )
        .unwrap_err();
    }

    #[test]
    // Talks to the real operating system: the session store is a real directory and
    // the relay threads' blocking recv is guarded by a watchdog thread.
    #[cfg_attr(miri, ignore)]
    fn unique_launch_directory_attaches() {
        testing::with_watchdog(|| {
            let dir = tempfile::TempDir::new().unwrap();
            let store = FsSessionStore::new(dir.path().to_path_buf());
            let cwd = store.current_dir().unwrap();
            let launch_directory = store.canonicalize(&cwd).unwrap();
            let id = store.allocate_id(&ProcessIdentity::for_test(1)).unwrap();
            let pipe = "resume-unique";
            store
                .publish(&SessionRecord {
                    id: id.get(),
                    supervisor_pid: 10,
                    supervisor_creation_time: 100,
                    pipe_name: pipe.to_string(),
                    launch_directory,
                    command: vec!["app.exe".to_string()],
                    started_at_unix_ms: 1,
                    attached: false,
                })
                .unwrap();
            let mut processes = MockProcesses::new();
            processes
                .expect_probe()
                .returning(|_| ProcessLiveness::Live);
            let transport = MemoryTransport::new();
            let listener = transport.listen(pipe).unwrap();
            thread::spawn({
                let transport = transport.clone();
                move || {
                    let conn = transport.accept(listener).unwrap();
                    _ = transport.recv(conn);
                    _ = transport.send(conn, &Message::Attached { session_id: id });
                    _ = transport.send(conn, &Message::AppExited { status: 0 });
                }
            });
            let mut console = MockLocalConsole::new();
            console.expect_has_console().return_const(true);
            console.expect_disable_ctrl_c_handler().returning(|| Ok(()));
            console.expect_enter_raw_relay().returning(|| Ok(()));
            console.expect_leave_raw_relay().returning(|| Ok(()));
            console
                .expect_window_size()
                .returning(|| Ok(WindowSize { cols: 80, rows: 24 }));
            console.expect_read_input().returning(|| {
                thread::park();
                Err(PalError::new(PalErrorKind::Disconnected))
            });
            console.expect_write_output().returning(|_| Ok(()));
            let console = LocalConsoleFacade::from_mock(console);
            let outcome = execute(
                &store,
                &processes,
                &transport,
                &console,
                None,
                Trace::default(),
            )
            .unwrap();
            assert!(matches!(outcome, Outcome::AppExit(0)));
        });
    }

    #[test]
    // Talks to the real operating system: the session store is a real directory.
    #[cfg_attr(miri, ignore)]
    fn ambiguous_without_terminal_does_not_prompt() {
        let dir = tempfile::TempDir::new().unwrap();
        let store = FsSessionStore::new(dir.path().to_path_buf());
        publish_ambiguous_sessions(&store);
        let mut processes = MockProcesses::new();
        processes
            .expect_probe()
            .returning(|_| ProcessLiveness::Live);
        let transport = MemoryTransport::new();
        let mut console = MockLocalConsole::new();
        console.expect_stdin_is_terminal().return_const(false);
        let console = LocalConsoleFacade::from_mock(console);
        let error = execute(
            &store,
            &processes,
            &transport,
            &console,
            None,
            Trace::default(),
        )
        .unwrap_err();
        assert!(error.find_source::<PromptFailedError>().is_some());
    }

    #[test]
    // Talks to the real operating system: the session store is a real directory.
    #[cfg_attr(miri, ignore)]
    fn ambiguous_with_terminal_reads_selection() {
        let dir = tempfile::TempDir::new().unwrap();
        let store = FsSessionStore::new(dir.path().to_path_buf());
        publish_ambiguous_sessions(&store);
        let mut processes = MockProcesses::new();
        processes
            .expect_probe()
            .returning(|_| ProcessLiveness::Live);
        let transport = MemoryTransport::new();
        let mut console = MockLocalConsole::new();
        console.expect_stdin_is_terminal().return_const(true);
        console
            .expect_read_prompt_line()
            .returning(|| Ok("not a number".to_string()));
        let console = LocalConsoleFacade::from_mock(console);
        let error = execute(
            &store,
            &processes,
            &transport,
            &console,
            None,
            Trace::default(),
        )
        .unwrap_err();
        assert!(error.find_source::<InvalidSessionIdError>().is_some());
    }
}