This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-daemon/src/pty.rs
T
Miguel PalhasandClaude Opus 5 6596104789
ci / web (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 1m49s
ci / web (push) Successful in 39s
ci / rust (push) Successful in 2m48s
refactor(maestro): delete the config projections and the dead knobs
maestro-cli/src/config.rs was two pass-throughs, a newtype error, a
two-field projection of MaestroConfigFile, and one four-line or_else with
a single caller. `resolve_daemon_url` moves onto MaestroConfigFile, next
to the schema it reads, and the module goes; its tests move with it.

`[web].dev_proxy_target` is deleted end to end — schema, env var,
normalisation, three tests, README line, and the startup warning that
admitted the server ignores it.

MaestroConfigFile::save had no callers outside its own tests. Deleting it
also removes every skip_serializing_if, the five is_empty impls, and the
Serialize derives that existed only to make its output tidy.

BroadcastTuning was a struct that was never instantiated: all three uses
were `::DEFAULT.field` in const position and there is no [broadcast]
section. Now two plain consts.

$MAESTRO_CONFIG was honoured twice, by resolve_config_path and by clap's
`env =`. Every binary already gets it through clap, so resolve_config_path
and CONFIG_PATH_ENV go and the TUI falls back to default_config_path like
everything else.

Also collapses three layers of 1:1 forwarding: the umbrella's cli_dispatch
shim, and four near-identical 8-line functions in cli/dispatch.rs that
differed only in which run_* they called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:39:01 +01:00

366 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Per-session PTY byte bridge.
//!
//! tmux writes pane output to a named pipe via `pipe-pane 'cat >> <fifo>'`.
//! The daemon reads from that FIFO and broadcasts the bytes to all attached
//! WS clients via a [`tokio::sync::broadcast`] channel.
//!
//! Implementation notes:
//!
//! - The FIFO is opened `O_RDWR | O_NONBLOCK`. `O_RDWR` keeps the file's
//! writer count non-zero from our side, so `read()` never returns EOF when
//! tmux's `cat` exits (e.g., during a pipe-pane re-issue or a brief tmux
//! hiccup). `O_NONBLOCK` is essential: without it, `tokio::fs::File`'s
//! reads route through `spawn_blocking` and block one of those threads in
//! `read()` until data arrives — which never happens for idle sessions.
//! A pile of such threads also prevents the tokio runtime from shutting
//! down cleanly between tests.
//! - We wrap the non-blocking file descriptor in [`AsyncFd`] and use
//! `try_io` to read directly off the FD, leaving the blocking pool alone.
//! - NAP-229: every byte we broadcast is also appended to a per-bridge ring
//! buffer (default 64 KB). New subscribers receive the buffer as a
//! one-shot prefix before joining the live broadcast, so attaching to an
//! idle session immediately shows the last screenful instead of a blank
//! terminal.
use std::collections::VecDeque;
use std::ffi::CString;
use std::fs::File;
use std::io::{self, Read};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio::io::unix::AsyncFd;
use tokio::io::Interest;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
/// How many recent PTY frames each new subscriber inherits before starting to
/// receive live frames. A small buffer is fine — clients render a fresh
/// terminal anyway. Sourced from [`maestro_config::PTY_CAPACITY`]
/// (NAP-273).
pub const PTY_BROADCAST_CAPACITY: usize = maestro_config::PTY_CAPACITY;
/// Default replay-buffer size — roughly one screenful for a typical 24×80
/// terminal plus reasonable scrollback. Override via
/// [`PtyBridge::start_with_replay`] or the `[daemon] pty_replay_bytes` config.
pub const DEFAULT_REPLAY_BYTES: usize = 64 * 1024;
/// Maximum bytes read per syscall; chosen to match typical pipe buffer sizes
/// and avoid copying huge `Vec`s through the channel.
const READ_BUF_BYTES: usize = 4096;
/// Owns a FIFO + the reader task that pumps bytes into a broadcast channel.
/// Dropping the bridge aborts the reader and closes the channel; the FIFO
/// file is left on disk for `SessionManager::kill` to remove.
#[derive(Debug)]
pub struct PtyBridge {
fifo_path: PathBuf,
/// `None` once [`Self::seal`] has run: live subscribers see the channel
/// close, but the replay buffer stays readable.
sender: Option<broadcast::Sender<Vec<u8>>>,
replay: ReplayBuffer,
/// Monotonic timestamp of the last non-empty read off the FIFO, bumped by
/// `run_reader`. The activity watcher reads it to gauge PTY quiescence
/// without touching the byte stream (NAP-340).
last_activity: Arc<Mutex<Instant>>,
task: JoinHandle<()>,
}
impl PtyBridge {
/// Create the FIFO (if it doesn't exist), open it non-blocking, and start
/// the reader task with the default replay buffer size.
pub async fn start(fifo_path: PathBuf) -> io::Result<Self> {
Self::start_with_replay(fifo_path, DEFAULT_REPLAY_BYTES).await
}
/// Variant that lets the caller pick a custom replay-buffer cap (bytes).
/// Pass `0` to disable replay (subscribers see only live bytes).
pub async fn start_with_replay(fifo_path: PathBuf, replay_bytes: usize) -> io::Result<Self> {
if let Some(parent) = fifo_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
mkfifo(&fifo_path)?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(&fifo_path)?;
let async_fd = AsyncFd::with_interest(file, Interest::READABLE)?;
let (sender, _) = broadcast::channel::<Vec<u8>>(PTY_BROADCAST_CAPACITY);
let replay = ReplayBuffer::new(replay_bytes);
let tx = sender.clone();
let replay_for_task = replay.clone();
let last_activity = Arc::new(Mutex::new(Instant::now()));
let last_activity_for_task = last_activity.clone();
let task = tokio::spawn(async move {
run_reader(async_fd, tx, replay_for_task, last_activity_for_task).await;
});
Ok(Self {
fifo_path,
sender: Some(sender),
replay,
last_activity,
task,
})
}
/// How long since the session last produced PTY output. The activity
/// watcher treats a short value as `Active` (NAP-340).
pub fn idle_for(&self) -> std::time::Duration {
self.last_activity
.lock()
.expect("last_activity poisoned")
.elapsed()
}
pub fn fifo_path(&self) -> &Path {
&self.fifo_path
}
/// Subscribe to live PTY frames. Use [`Self::subscribe_with_replay`] for
/// new attaches that want the buffered scrollback first.
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
match &self.sender {
Some(tx) => tx.subscribe(),
None => {
let (tx, rx) = broadcast::channel(1);
drop(tx);
rx
}
}
}
/// Atomically snapshot the replay buffer and return a fresh subscriber
/// (NAP-229). Send the returned `Vec<u8>` to the client first; subsequent
/// live bytes arrive on the receiver. Holding the replay lock across
/// `subscribe()` keeps the reader task from publishing in between, so
/// the seam has no gap and no duplicates.
pub fn subscribe_with_replay(&self) -> (Vec<u8>, broadcast::Receiver<Vec<u8>>) {
let guard = self.replay.lock_for_snapshot();
let snapshot = guard.snapshot();
let rx = match &self.sender {
Some(tx) => tx.subscribe(),
// Sealed: hand back the scrollback plus a receiver that is already
// closed, so the client renders the final output and then sees the
// stream end rather than hanging.
None => {
let (tx, rx) = broadcast::channel(1);
drop(tx);
rx
}
};
drop(guard);
(snapshot, rx)
}
/// Stop reading and close the live stream, keeping the replay buffer.
///
/// Attached clients see the channel close — without that a `maestro attach`
/// sits in raw mode against a shell that has exited (NAP-226) — while the
/// buffered output survives for anyone who attaches later.
pub fn seal(&mut self) {
self.task.abort();
self.sender = None;
}
}
impl Drop for PtyBridge {
fn drop(&mut self) {
self.task.abort();
}
}
// ---------------------------------------------------------------------------
// ReplayBuffer
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct ReplayBuffer {
inner: Arc<Mutex<ReplayInner>>,
}
#[derive(Debug)]
struct ReplayInner {
buf: VecDeque<u8>,
cap: usize,
}
impl ReplayBuffer {
fn new(cap: usize) -> Self {
Self {
inner: Arc::new(Mutex::new(ReplayInner {
buf: VecDeque::with_capacity(cap.min(READ_BUF_BYTES * 2)),
cap,
})),
}
}
/// Append bytes, dropping oldest as needed to stay under `cap`.
/// Only used by tests — production calls inline this under
/// `run_reader`'s lock so the broadcast send stays atomic with the push.
#[cfg(test)]
fn push(&self, bytes: &[u8]) {
let mut s = self.inner.lock().expect("ReplayBuffer poisoned");
if s.cap == 0 {
return;
}
// If the incoming chunk alone exceeds the cap, take only its tail —
// anything in the buffer would be evicted before this finishes anyway.
let take_from = bytes.len().saturating_sub(s.cap);
let bytes = &bytes[take_from..];
s.buf.extend(bytes);
while s.buf.len() > s.cap {
s.buf.pop_front();
}
}
/// Acquire the lock for a snapshot. The caller must call `snapshot()` on
/// the returned guard before releasing the lock — that's the
/// atomicity that prevents a race with concurrent appends.
fn lock_for_snapshot(&self) -> ReplayGuard<'_> {
ReplayGuard {
inner: self.inner.lock().expect("ReplayBuffer poisoned"),
}
}
}
struct ReplayGuard<'a> {
inner: std::sync::MutexGuard<'a, ReplayInner>,
}
impl ReplayGuard<'_> {
fn snapshot(&self) -> Vec<u8> {
let (a, b) = self.inner.buf.as_slices();
let mut out = Vec::with_capacity(a.len() + b.len());
out.extend_from_slice(a);
out.extend_from_slice(b);
out
}
}
async fn run_reader(
async_fd: AsyncFd<File>,
tx: broadcast::Sender<Vec<u8>>,
replay: ReplayBuffer,
last_activity: Arc<Mutex<Instant>>,
) {
let mut buf = [0u8; READ_BUF_BYTES];
loop {
let mut guard = match async_fd.readable().await {
Ok(g) => g,
Err(e) => {
tracing::warn!(error = ?e, "fifo readability wait failed");
return;
}
};
// `&File: Read`, so we can read through the immutable ref the AsyncFd
// hands us inside try_io.
let result = guard.try_io(|inner| {
let mut file: &File = inner.get_ref();
file.read(&mut buf)
});
match result {
Ok(Ok(0)) => {
// 0 bytes despite holding a write fd; just yield and retry.
tokio::task::yield_now().await;
}
Ok(Ok(n)) => {
// Mark the session as having just produced output, for the
// activity watcher's quiescence check (NAP-340).
*last_activity.lock().expect("last_activity poisoned") = Instant::now();
// Hold the replay lock across both the buffer push and the
// broadcast send so `subscribe_with_replay` either sees this
// chunk in its snapshot *or* receives it on the broadcast —
// never both, never neither.
let mut s = replay.inner.lock().expect("ReplayBuffer poisoned");
if s.cap > 0 {
let take_from = n.saturating_sub(s.cap);
s.buf.extend(&buf[take_from..n]);
while s.buf.len() > s.cap {
s.buf.pop_front();
}
}
let _ = tx.send(buf[..n].to_vec());
drop(s);
}
Ok(Err(e)) => {
tracing::warn!(error = ?e, "fifo read error");
return;
}
Err(_would_block) => {
// AsyncFd reported readable but read() said WouldBlock.
// Clear readiness so we wait again. try_io did that for us.
continue;
}
}
}
}
fn mkfifo(path: &Path) -> io::Result<()> {
let c =
CString::new(path.as_os_str().as_bytes()).map_err(|e| io::Error::other(e.to_string()))?;
// SAFETY: `c` is a valid NUL-terminated path string for the duration of
// the call. mkfifo only reads it.
let res = unsafe { libc::mkfifo(c.as_ptr(), 0o600) };
if res == 0 {
return Ok(());
}
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::AlreadyExists {
return Ok(());
}
Err(err)
}
/// The shell command tmux's `pipe-pane` will run to forward pane output into
/// `fifo_path`. `cat` writes raw bytes through unchanged.
pub fn pipe_pane_command(fifo_path: &Path) -> String {
format!("cat >> {}", shell_quote(&fifo_path.display().to_string()))
}
fn shell_quote(s: &str) -> String {
let escaped = s.replace('\'', r#"'\''"#);
format!("'{escaped}'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replay_buffer_keeps_last_cap_bytes() {
let r = ReplayBuffer::new(4);
r.push(b"abcdefgh");
assert_eq!(r.lock_for_snapshot().snapshot(), b"efgh");
}
#[test]
fn replay_buffer_disabled_when_cap_zero() {
let r = ReplayBuffer::new(0);
r.push(b"anything");
assert!(r.lock_for_snapshot().snapshot().is_empty());
}
#[test]
fn replay_buffer_handles_chunk_larger_than_cap() {
let r = ReplayBuffer::new(3);
r.push(b"abcdefghij");
assert_eq!(r.lock_for_snapshot().snapshot(), b"hij");
}
#[test]
fn replay_buffer_accumulates_across_pushes() {
let r = ReplayBuffer::new(6);
r.push(b"abc");
r.push(b"de");
assert_eq!(r.lock_for_snapshot().snapshot(), b"abcde");
r.push(b"fghi");
assert_eq!(r.lock_for_snapshot().snapshot(), b"defghi");
}
}