rmux 0.10.0

A local terminal multiplexer with a tmux-style CLI, daemon runtime, Rust SDK, and ratatui integration.
#![cfg(unix)]

mod common;

use std::error::Error;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, Instant};

use common::{assert_success, stdout, CliHarness};

const BURST_EVENTS: usize = 270;
const CLOSED_SESSIONS: usize = 16;
const BURST_DELIVERY_TIMEOUT: Duration = Duration::from_secs(30);

#[test]
fn slow_lifecycle_hook_does_not_drop_a_large_burst() -> Result<(), Box<dyn Error>> {
    let harness = CliHarness::new("lifecycle-dispatch-backpressure")?;
    let _daemon = harness.start_hidden_daemon()?;
    let state_dir = harness.tmpdir().join("hook-state");
    let hook_path = harness.tmpdir().join("slow-hook.sh");
    let event_path = state_dir.join("events.log");
    let started_path = state_dir.join("started");
    fs::write(
        &hook_path,
        format!(
            "#!/bin/sh\nset -eu\nmkdir -p '{state}'\nif mkdir '{state}/first' 2>/dev/null; then : > '{started}'; sleep 1; fi\nprintf 'event\\n' >> '{events}'\n",
            state = state_dir.display(),
            started = started_path.display(),
            events = event_path.display(),
        ),
    )?;
    let mut permissions = fs::metadata(&hook_path)?.permissions();
    permissions.set_mode(0o700);
    fs::set_permissions(&hook_path, permissions)?;

    let config_path = harness.tmpdir().join("lifecycle-burst.conf");
    let config = (1..=BURST_EVENTS)
        .map(|index| format!("rename-window -t lifecycle:0 burst-{index:03}\n"))
        .collect::<String>();
    fs::write(&config_path, config)?;

    assert_success(&harness.run(&["new-session", "-d", "-s", "lifecycle"])?);
    assert_success(&harness.run(&[
        "set-hook",
        "-g",
        "window-renamed",
        &format!("run-shell '{}'", hook_path.display()),
    ])?);
    assert_success(&harness.run(&["rename-window", "-t", "lifecycle:0", "initial-block"])?);
    wait_until(Duration::from_secs(3), || started_path.is_file())?;

    let sourced = harness.run(&[
        "source-file",
        config_path.to_str().expect("utf-8 config path"),
    ])?;
    assert_success(&sourced);
    wait_until(BURST_DELIVERY_TIMEOUT, || {
        fs::read_to_string(&event_path)
            .map(|events| events.lines().count() == BURST_EVENTS + 1)
            .unwrap_or(false)
    })?;

    let events = fs::read_to_string(&event_path)?;
    assert_eq!(events.lines().count(), BURST_EVENTS + 1);
    assert_eq!(
        stdout(&harness.run(&["display-message", "-p", "-t", "lifecycle:0", "#W",])?),
        format!("burst-{BURST_EVENTS:03}\n")
    );
    Ok(())
}

#[test]
fn kill_server_drains_accepted_session_closed_hooks() -> Result<(), Box<dyn Error>> {
    let harness = CliHarness::new("lifecycle-dispatch-shutdown-drain")?;
    let _daemon = harness.start_hidden_daemon()?;
    let hook_path = harness.tmpdir().join("session-closed-hook.sh");
    let event_path = harness.tmpdir().join("session-closed.log");
    fs::write(
        &hook_path,
        format!(
            "#!/bin/sh\nset -eu\nsleep 0.02\nprintf 'closed\\n' >> '{}'\n",
            event_path.display(),
        ),
    )?;
    let mut permissions = fs::metadata(&hook_path)?.permissions();
    permissions.set_mode(0o700);
    fs::set_permissions(&hook_path, permissions)?;

    for index in 0..CLOSED_SESSIONS {
        assert_success(&harness.run(&[
            "new-session",
            "-d",
            "-s",
            &format!("closed-{index:02}"),
        ])?);
    }
    assert_success(&harness.run(&[
        "set-hook",
        "-g",
        "session-closed",
        &format!("run-shell '{}'", hook_path.display()),
    ])?);

    assert_success(&harness.run(&["kill-server"])?);
    wait_until(Duration::from_secs(5), || {
        fs::read_to_string(&event_path)
            .map(|events| events.lines().count() == CLOSED_SESSIONS)
            .unwrap_or(false)
    })?;
    assert_eq!(
        fs::read_to_string(&event_path)?.lines().count(),
        CLOSED_SESSIONS
    );
    Ok(())
}

#[test]
fn kill_server_cancels_wait_for_inside_accepted_session_closed_hook() -> Result<(), Box<dyn Error>>
{
    let harness = CliHarness::new("lifecycle-dispatch-shutdown-wait-for")?;
    let mut daemon = harness.start_hidden_daemon()?;

    assert_success(&harness.run(&["new-session", "-d", "-s", "blocked-hook"])?);
    assert_success(&harness.run(&[
        "set-hook",
        "-g",
        "session-closed",
        "wait-for lifecycle-shutdown-never-signaled",
    ])?);
    assert_success(&harness.run(&["kill-server"])?);

    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        if let Some(status) = daemon.child_mut().try_wait()? {
            assert!(status.success(), "hidden daemon exited with {status}");
            break;
        }
        if Instant::now() >= deadline {
            return Err("kill-server remained blocked in a lifecycle hook wait-for".into());
        }
        std::thread::sleep(Duration::from_millis(20));
    }

    Ok(())
}

fn wait_until(
    timeout: Duration,
    mut condition: impl FnMut() -> bool,
) -> Result<(), Box<dyn Error>> {
    let deadline = Instant::now() + timeout;
    while !condition() {
        if Instant::now() >= deadline {
            return Err("timed out waiting for lifecycle hook evidence".into());
        }
        std::thread::sleep(Duration::from_millis(20));
    }
    Ok(())
}