mod audit;
pub mod cli;
pub mod session;
#[cfg(feature = "cli")]
pub use cli::ServeArgs;
pub use cli::{serve, serve_with, Hosts, ServeConfig};
pub use session::{
ChangeSignal, ClientId, HostProvider, PtyHost, PtyHosts, SessionHost, SharedHost, SharedSession,
};
use std::time::Duration;
use crate::input::{UserInput, WireEvent};
use crate::ssp::{RecvOutcome, SyncState, Transport};
use crate::transport_iroh::{IrohChannel, MonoClock};
use tracing::info;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionExit {
Detached,
ShellExited,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
enum Ss3State {
#[default]
Ground,
Esc,
Ss3,
}
#[derive(Default)]
struct CursorKeyNormalizer {
state: Ss3State,
}
impl CursorKeyNormalizer {
fn normalize(&mut self, input: &[u8], app_cursor: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(input.len() + 1);
for &b in input {
match self.state {
Ss3State::Ground => {
if b == 0x1b {
self.state = Ss3State::Esc;
}
out.push(b); }
Ss3State::Esc => {
if b == b'O' {
self.state = Ss3State::Ss3; } else {
self.state = Ss3State::Ground;
out.push(b);
}
}
Ss3State::Ss3 => {
self.state = Ss3State::Ground;
out.push(if !app_cursor && (b'A'..=b'D').contains(&b) {
b'['
} else {
b'O'
});
out.push(b);
}
}
}
out
}
}
fn coalesce_drained_input(
input_diff: &[WireEvent],
cursor_keys: &mut CursorKeyNormalizer,
app_cursor: bool,
) -> (Vec<u8>, Option<(u16, u16)>) {
let mut keys = Vec::new();
let mut last_resize: Option<(u16, u16)> = None;
for w in input_diff {
match w {
WireEvent::Keys(b) => keys.extend(cursor_keys.normalize(b, app_cursor)),
WireEvent::Resize { rows, cols } => {
last_resize = Some(crate::terminal::clamp_dims(*rows, *cols));
}
}
}
(keys, last_resize)
}
struct DrainedInput {
keys: Vec<u8>,
resize: Option<(u16, u16)>,
frame: u64,
}
pub(crate) const ECHO_TIMEOUT_MS: u64 = 50;
#[derive(Debug)]
pub(crate) struct EchoAck {
acked: u64,
input_history: Vec<(u64, u64)>,
echo_timeout_ms: u64,
}
impl Default for EchoAck {
fn default() -> Self {
Self {
acked: 0,
input_history: Vec::new(),
echo_timeout_ms: ECHO_TIMEOUT_MS,
}
}
}
impl EchoAck {
#[cfg(test)]
pub(crate) fn with_timeout_ms(echo_timeout_ms: u64) -> Self {
Self {
echo_timeout_ms,
..Self::default()
}
}
pub(crate) fn register_input_frame(&mut self, n: u64, now: u64) {
if self.input_history.last().is_none_or(|(f, _)| n > *f) {
self.input_history.push((n, now));
}
}
pub(crate) fn set_echo_ack(&mut self, now: u64) -> bool {
let cutoff = now.saturating_sub(self.echo_timeout_ms);
let mut newest = self.acked;
for &(frame, ts) in &self.input_history {
if ts <= cutoff {
newest = newest.max(frame);
}
}
self.input_history.retain(|&(frame, _)| frame >= newest);
let changed = self.acked != newest;
self.acked = newest;
changed
}
pub(crate) fn wait_time(&self, now: u64) -> u64 {
let Some(&(_, arrived)) = self.input_history.get(1) else {
return crate::ssp::NEVER;
};
let fire_at = arrived + self.echo_timeout_ms;
fire_at.saturating_sub(now)
}
pub(crate) const fn echo_ack(&self) -> u64 {
self.acked
}
}
struct ServerSession<S: SyncState> {
transport: Transport<S, UserInput>,
cursor_keys: CursorKeyNormalizer,
echo: EchoAck,
dirty: bool,
}
impl<S: SyncState> ServerSession<S> {
fn new(now: u64, mtu: usize) -> Self {
let mut transport = Transport::<S, UserInput>::new(now, mtu);
transport.set_connected(true);
Self {
transport,
cursor_keys: CursorKeyNormalizer::default(),
echo: EchoAck::default(),
dirty: true, }
}
fn set_echo_ack(&mut self, now: u64) -> bool {
self.echo.set_echo_ack(now)
}
const fn echo_ack(&self) -> u64 {
self.echo.echo_ack()
}
fn echo_ack_wait_time(&self, now: u64) -> u64 {
self.echo.wait_time(now)
}
fn register_input_frame(&mut self, frame: u64, now: u64) {
self.echo.register_input_frame(frame, now);
}
fn observe_link(&mut self, mtu: usize, rtt_ms: Option<f64>) {
self.transport.set_mtu(mtu);
if let Some(rtt) = rtt_ms {
self.transport.observe_rtt(rtt);
}
}
const fn needs_snapshot(&self, echo_changed: bool) -> bool {
self.dirty || echo_changed
}
fn install_snapshot(&mut self, snapshot: Option<S>) {
if let Some(state) = snapshot {
*self.transport.current_mut() = state;
}
self.dirty = false;
}
fn wait_ms(&mut self, now: u64, echo_wait: u64) -> u64 {
self.transport.wait_time(now).min(echo_wait).min(1000)
}
fn mark_dirty(&mut self) {
self.dirty = true;
}
fn recv(&mut self, now: u64, bytes: &[u8]) -> RecvOutcome {
self.transport.recv(now, bytes)
}
fn drain_input(&mut self, app_cursor: bool) -> Option<DrainedInput> {
let diff = self.transport.get_remote_diff();
if diff.is_empty() {
return None;
}
let frame = self.transport.remote_num();
let (keys, resize) = coalesce_drained_input(&diff, &mut self.cursor_keys, app_cursor);
Some(DrainedInput {
keys,
resize,
frame,
})
}
fn tick(&mut self, now: u64, child_alive: bool) -> Vec<Vec<u8>> {
if !child_alive && !self.transport.shutdown_in_progress() {
self.transport.start_shutdown(now);
}
self.transport.tick(now)
}
fn shutdown_complete(&self, now: u64) -> bool {
self.transport.shutdown_in_progress()
&& (self.transport.shutdown_acknowledged()
|| self.transport.shutdown_ack_timed_out(now))
}
}
pub async fn run_attached<H: SessionHost>(
conn: iroh::endpoint::Connection,
handle: SharedSession<H>,
client: ClientId,
) -> anyhow::Result<SessionExit> {
let channel = IrohChannel::new(conn);
let clock = MonoClock::new();
let mut session = ServerSession::<H::State>::new(clock.now_ms(), channel.max_datagram_size());
let mut changed = handle.changed.subscribe();
loop {
let now = clock.now_ms();
session.observe_link(channel.max_datagram_size(), channel.rtt_ms());
let echo_changed = session.set_echo_ack(now);
let child_alive = {
let take = session.needs_snapshot(echo_changed);
if take {
let _ = changed.borrow_and_update();
}
let mut s = handle.session.lock().await;
let mut snapshot = take.then(|| s.host.snapshot());
let alive = s.host.alive();
drop(s);
if let Some(state) = snapshot.as_mut() {
H::stamp_echo_ack(state, session.echo_ack());
}
session.install_snapshot(snapshot);
alive
};
let sleep_ms = session.wait_ms(now, session.echo_ack_wait_time(now));
tokio::select! {
_ = changed.changed() => session.mark_dirty(),
dg = channel.recv() => {
match dg {
Ok(bytes) => {
let now = clock.now_ms();
if session.recv(now, &bytes) == RecvOutcome::NewState {
let mut s = handle.session.lock().await;
let app_cursor = s.host.application_cursor();
if let Some(input) = session.drain_input(app_cursor) {
if !input.keys.is_empty() {
s.host.input(&input.keys);
}
let resized = input.resize.is_some();
if let Some((rows, cols)) = input.resize {
s.host.resize(client, rows, cols);
}
drop(s);
session.register_input_frame(input.frame, now);
if resized {
session.mark_dirty();
}
}
}
}
Err(e) => {
info!(reason = %e, "connection closed by peer (detaching)");
channel.close(0, b"client detached");
return Ok(SessionExit::Detached);
}
}
}
_ = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {}
}
let now = clock.now_ms();
for datagram in session.tick(now, child_alive) {
channel.send(&datagram);
}
if session.shutdown_complete(now) {
channel.close(0, b"session ended");
return Ok(SessionExit::ShellExited);
}
}
}
pub async fn run_session(
conn: iroh::endpoint::Connection,
command: &[String],
scrollback: usize,
) -> anyhow::Result<()> {
let handle = session::spawn_session(command, scrollback)?;
run_session_with(conn, handle).await
}
pub async fn run_session_with<H: SessionHost>(
conn: iroh::endpoint::Connection,
handle: SharedSession<H>,
) -> anyhow::Result<()> {
let client = ClientId::next();
let _ = run_attached(conn, handle.clone(), client).await?;
let mut s = handle.session.lock().await;
s.host.client_detached(client);
s.host.kill();
Ok(())
}
#[cfg(test)]
mod tests {
use super::{coalesce_drained_input, CursorKeyNormalizer, EchoAck, ServerSession};
use crate::input::{UserInput, WireEvent};
use crate::ssp::testkit::GridState;
use crate::ssp::{RecvOutcome, Transport};
use crate::terminal::TerminalScreen;
fn norm(chunks: &[&[u8]], app_cursor: bool) -> Vec<u8> {
let mut n = CursorKeyNormalizer::default();
let mut out = Vec::new();
for c in chunks {
out.extend(n.normalize(c, app_cursor));
}
out
}
#[test]
fn ss3_arrows_rewrite_to_csi_when_not_in_application_cursor_mode() {
assert_eq!(norm(&[b"\x1bOA"], false), b"\x1b[A");
assert_eq!(norm(&[b"\x1bOD"], false), b"\x1b[D");
}
#[test]
fn ss3_arrows_preserved_in_application_cursor_mode() {
assert_eq!(norm(&[b"\x1bOA"], true), b"\x1bOA");
}
#[test]
fn csi_arrows_and_plain_bytes_pass_through() {
assert_eq!(norm(&[b"\x1b[A"], false), b"\x1b[A");
assert_eq!(norm(&[b"ls\r"], false), b"ls\r");
assert_eq!(norm(&[b"\x1bi"], false), b"\x1bi");
}
#[test]
fn ss3_sequence_split_across_chunks_normalizes() {
assert_eq!(norm(&[b"\x1b", b"O", b"A"], false), b"\x1b[A");
assert_eq!(norm(&[b"\x1b", b"[", b"A"], false), b"\x1b[A");
}
#[test]
fn coalesce_keeps_only_the_last_resize_and_concatenates_keys() {
let mut norm = CursorKeyNormalizer::default();
let diff = vec![
WireEvent::Keys(b"ab".to_vec()),
WireEvent::Resize { rows: 10, cols: 20 },
WireEvent::Keys(b"cd".to_vec()),
WireEvent::Resize { rows: 30, cols: 40 },
WireEvent::Resize {
rows: 65000,
cols: 1,
}, WireEvent::Keys(b"ef".to_vec()),
];
let (keys, last_resize) = coalesce_drained_input(&diff, &mut norm, false);
assert_eq!(keys, b"abcdef", "keystrokes concatenate in order");
assert_eq!(
last_resize,
Some(crate::terminal::clamp_dims(65000, 1)),
"only the final resize survives, clamped to [MIN_DIM, MAX_DIM]"
);
}
#[test]
fn coalesce_with_no_resize_returns_none() {
let mut norm = CursorKeyNormalizer::default();
let diff = vec![WireEvent::Keys(b"x".to_vec())];
let (keys, last_resize) = coalesce_drained_input(&diff, &mut norm, false);
assert_eq!(keys, b"x");
assert!(last_resize.is_none(), "no resize event -> None");
}
#[test]
fn echo_ack_debounces() {
let mut t = EchoAck::default();
t.register_input_frame(5, 1000);
assert!(!t.set_echo_ack(1010));
assert_eq!(t.echo_ack(), 0);
assert!(t.set_echo_ack(1050));
assert_eq!(t.echo_ack(), 5);
}
#[test]
fn echo_ack_honors_injected_timeout() {
let mut t = EchoAck::with_timeout_ms(10);
t.register_input_frame(5, 1000);
assert!(
!t.set_echo_ack(1005),
"still inside the injected 10ms window"
);
assert_eq!(t.echo_ack(), 0);
assert!(t.set_echo_ack(1011), "past the injected 10ms window");
assert_eq!(t.echo_ack(), 5);
let mut d = EchoAck::default();
d.register_input_frame(5, 1000);
assert!(
!d.set_echo_ack(1011),
"the 50ms default has not elapsed yet"
);
}
#[test]
fn echo_ack_is_monotonic_and_takes_newest() {
let mut t = EchoAck::default();
t.register_input_frame(3, 1000);
t.register_input_frame(7, 1005);
t.set_echo_ack(1100); assert_eq!(t.echo_ack(), 7);
}
#[test]
fn echo_ack_wait_time_points_at_the_second_pending_frame() {
let mut t = EchoAck::default();
assert_eq!(t.wait_time(0), crate::ssp::NEVER, "nothing pending");
t.register_input_frame(1, 1000);
assert_eq!(
t.wait_time(1000),
crate::ssp::NEVER,
"one frame: nothing waits behind it"
);
t.register_input_frame(2, 1020);
assert_eq!(t.wait_time(1030), 40, "the second frame fires at 1070");
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(128))]
#[test]
fn echo_ack_trackers_are_independent_per_connection(
ops in proptest::collection::vec((proptest::prelude::any::<bool>(), 1u64..1000, 0u64..10_000), 1..64),
) {
let mut a = EchoAck::with_timeout_ms(10);
let mut b = EchoAck::with_timeout_ms(10);
let (mut max_a, mut max_b) = (0u64, 0u64);
for (to_a, frame, now) in ops {
if to_a {
a.register_input_frame(frame, now);
max_a = max_a.max(frame);
} else {
b.register_input_frame(frame, now);
max_b = max_b.max(frame);
}
a.set_echo_ack(now.saturating_add(100));
b.set_echo_ack(now.saturating_add(100));
proptest::prop_assert!(a.echo_ack() <= max_a, "A acked a frame it never saw");
proptest::prop_assert!(b.echo_ack() <= max_b, "B acked a frame it never saw");
}
}
}
#[test]
fn server_session_snapshot_gating() {
let mut s = ServerSession::<GridState>::new(0, 1200);
assert!(s.needs_snapshot(false), "the first pass always snapshots");
s.install_snapshot(Some(GridState::default()));
assert!(!s.needs_snapshot(false), "clean after a snapshot");
assert!(
s.needs_snapshot(true),
"an echo-ack advance forces a snapshot even when clean (else confirmations stall)"
);
s.mark_dirty();
assert!(
s.needs_snapshot(false),
"a changed-pulse / applied resize re-arms the snapshot"
);
}
#[test]
fn server_session_shutdown_handshake_progresses() {
let mut s = ServerSession::<GridState>::new(0, 1200);
let _ = s.tick(0, true); assert!(!s.shutdown_complete(0));
let _ = s.tick(10, false); assert!(
!s.shutdown_complete(10),
"shutdown just started: neither acked nor timed out yet"
);
assert!(
s.shutdown_complete(10_000_000),
"far in the future the unacked shutdown times out -> reapable"
);
}
#[test]
fn server_session_drains_coalesced_input_from_a_real_datagram() {
let mut client = Transport::<UserInput, TerminalScreen>::new(0, 1200);
client.set_connected(true);
client.current_mut().push_bytes(b"ls\r");
client.current_mut().push_resize(10, 20);
client.current_mut().push_resize(30, 40);
let datagrams = client.tick(1000);
assert!(
!datagrams.is_empty(),
"the client transmits its queued input"
);
let mut server = ServerSession::<TerminalScreen>::new(0, 1200);
let mut drained = None;
for dg in &datagrams {
if server.recv(1000, dg) == RecvOutcome::NewState {
drained = server.drain_input(false);
}
}
let input = drained.expect("the server drained the client's input");
assert_eq!(
input.keys, b"ls\r",
"keystrokes concatenate in order through the normalizer"
);
assert_eq!(
input.resize,
Some((30, 40)),
"KOH-05: only the final resize survives (clamped)"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_session_over_a_scripted_host_delivers_frames_and_clamped_resizes() {
use crate::server::session::test_host::{HostCall, ScriptedHost};
use crate::server::session::SessionHandle;
use crate::transport_iroh::{
bind_endpoint_local, generate_secret_key, loopback_addr, IrohChannel, MonoClock, ALPN,
};
let server_ep = bind_endpoint_local(generate_secret_key(), true)
.await
.expect("bind");
let addr = loopback_addr(&server_ep);
let handle = SessionHandle::new(ScriptedHost::new());
let h2 = handle.clone();
let accept = tokio::spawn(async move {
if let Some(incoming) = server_ep.accept().await {
if let Ok(conn) = incoming.await {
let _ = super::run_session_with(conn, h2).await;
}
}
});
let client_ep = bind_endpoint_local(generate_secret_key(), false)
.await
.expect("bind client");
let chan = IrohChannel::new(client_ep.connect(addr, ALPN).await.expect("connect"));
let clock = MonoClock::new();
let mut t = Transport::<UserInput, GridState>::new(clock.now_ms(), 1200);
t.set_connected(true);
t.observe_rtt(10.0);
t.current_mut().push_resize(65000, 1);
t.current_mut().push_bytes(b"xy");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
for dg in t.tick(clock.now_ms()) {
chan.send(&dg);
}
tokio::select! {
r = chan.recv() => { if let Ok(b) = r { t.recv(clock.now_ms(), &b); } }
() = tokio::time::sleep(std::time::Duration::from_millis(5)) => {}
}
if t.remote_state().contents().contains("xy") && t.remote_state().echo_ack >= 1 {
break;
}
}
assert!(
t.remote_state().contents().contains("xy"),
"input reached the host"
);
chan.close(0, b"done");
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), accept).await;
let s = handle.session.lock().await;
let calls = &s.host.calls;
assert!(
calls.iter().any(|c| matches!(c, HostCall::Resize(_, r, cc) if (*r, *cc) == crate::terminal::clamp_dims(65000, 1))),
"resize arrives clamped: {calls:?}"
);
assert_eq!(
t.remote_state().echo_ack,
1,
"the loop's own echo-ack for frame 1 is stamped onto the snapshot (KS-02)"
);
assert!(
calls.iter().any(|c| matches!(c, HostCall::Detached(_))),
"the connection's detach reaches the host: {calls:?}"
);
assert!(
calls.contains(&HostCall::Kill),
"run_session_with kills the host at the end"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[expect(
clippy::items_after_statements,
reason = "the pump helper reads best next to the viewers it drives"
)]
async fn echo_ack_is_tracked_per_connection_so_a_second_viewer_sees_only_its_own_frames() {
use crate::server::session::test_host::ScriptedHost;
use crate::server::session::{ClientId, SessionHandle};
use crate::transport_iroh::{
bind_endpoint_local, generate_secret_key, loopback_addr, IrohChannel, MonoClock, ALPN,
};
let server_ep = bind_endpoint_local(generate_secret_key(), true)
.await
.expect("bind");
let addr = loopback_addr(&server_ep);
let handle = SessionHandle::new(ScriptedHost::new());
let h2 = handle.clone();
let accept = tokio::spawn(async move {
while let Some(incoming) = server_ep.accept().await {
let h = h2.clone();
tokio::spawn(async move {
if let Ok(conn) = incoming.await {
let _ = super::run_attached(conn, h, ClientId::next()).await;
}
});
}
});
let clock = MonoClock::new();
let mut viewers = Vec::new();
for _ in 0..2 {
let ep = bind_endpoint_local(generate_secret_key(), false)
.await
.expect("bind client");
let chan = IrohChannel::new(ep.connect(addr.clone(), ALPN).await.expect("connect"));
let mut t = Transport::<UserInput, GridState>::new(clock.now_ms(), 1200);
t.set_connected(true);
t.observe_rtt(10.0);
viewers.push((chan, t, ep));
}
async fn pump(
viewers: &mut [(IrohChannel, Transport<UserInput, GridState>, iroh::Endpoint)],
clock: &MonoClock,
ms: u64,
) {
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(ms);
while std::time::Instant::now() < deadline {
for (chan, t, _) in viewers.iter_mut() {
for dg in t.tick(clock.now_ms()) {
chan.send(&dg);
}
if let Ok(Ok(b)) =
tokio::time::timeout(std::time::Duration::from_millis(2), chan.recv()).await
{
t.recv(clock.now_ms(), &b);
}
assert!(
t.remote_state().echo_ack <= t.newest_sent_num(),
"a viewer was acked for a frame it never sent: ack {} > sent {}",
t.remote_state().echo_ack,
t.newest_sent_num()
);
}
}
}
for _ in 0..30 {
viewers[0].1.current_mut().push_bytes(b"a");
pump(&mut viewers, &clock, 40).await;
}
viewers[1].1.current_mut().push_bytes(b"b");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
pump(&mut viewers, &clock, 50).await;
let b_done = viewers[1].1.remote_state().echo_ack >= 1;
let a_done = viewers[0].1.remote_state().echo_ack >= 30;
if a_done && b_done {
break;
}
}
let (a_ack, a_sent) = (
viewers[0].1.remote_state().echo_ack,
viewers[0].1.newest_sent_num(),
);
let (b_ack, b_sent) = (
viewers[1].1.remote_state().echo_ack,
viewers[1].1.newest_sent_num(),
);
assert!(
a_ack >= 30 && a_ack <= a_sent,
"A's ack covers A's 30 frames: ack {a_ack}, sent {a_sent}"
);
assert!(
b_ack >= 1 && b_ack <= b_sent,
"B's ack covers B's one frame: ack {b_ack}, sent {b_sent}"
);
assert!(
b_ack < 30,
"B was handed A's ack ({b_ack}): the echo-ack leaked across connections"
);
for (chan, _, _) in &viewers {
chan.close(0, b"done");
}
accept.abort();
}
}