From 9158a488f7380544275d30574ce7714e678f84ee Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Tue, 2 Jun 2026 14:20:40 -0700 Subject: [PATCH] fix(ui): batch-drain incoming frames so a sandbox stream can't stall chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader funnels both chat and high-volume _sbx:data terminal frames through one channel, and the UI loop redraws after handling a single frame per turn — so on a viewer's side each chat message queued behind hundreds of sandbox frames only surfaced one-per-redraw, making chat appear to buffer/stall whenever a shared shell was scrolling output. Drain a bounded burst (up to 256) of ready frames per turn via a new drain_ready() helper, keeping chat latency bounded no matter how hard the sandbox is streaming. Add regression tests covering FIFO/cap behavior and chat surfacing within a few turns under flood. Also add connect.sh: a join helper with a default port that keeps the room password in RAM only (no-echo prompt or env var, never written to disk). Co-Authored-By: Claude Opus 4.6 --- hh/connect.sh | 71 +++++++++++++++++++++++++++++++ hh/src/app.rs | 115 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 171 insertions(+), 15 deletions(-) create mode 100755 hh/connect.sh diff --git a/hh/connect.sh b/hh/connect.sh new file mode 100755 index 0000000..c84cbd6 --- /dev/null +++ b/hh/connect.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# connect.sh — join a hack-house room without leaving the password on disk. +# +# The password lives only in RAM: it is read into a shell variable (never a +# file), and the interactive prompt keeps it out of your shell history. Supply +# it three ways, most to least private: +# 1) interactive (recommended): ./connect.sh alice 100.117.177.50 +# → prompts "room password:" with no echo +# 2) environment: HH_PASSWORD=secret ./connect.sh alice +# 3) flag: ./connect.sh alice -p secret +# +# Caveat: however it arrives, the client receives the password as a CLI argument, +# so it is briefly visible in the process list (ps) to other *local* users for +# the lifetime of the session. Nothing is ever written to disk. +# +# Usage: ./connect.sh [NAME] [HOST] [-p PASSWORD] [-P PORT] [--tls] [--insecure] +# NAME display handle; omit to be prompted for one on join +# HOST server IP/host (default: 127.0.0.1) +# -P port (default: 4173, or $HH_PORT) +# --tls use wss/https instead of the default plaintext-over-Tailscale +set -euo pipefail +cd "$(dirname "$0")" + +DEFAULT_PORT=4173 +DEFAULT_HOST=127.0.0.1 + +NAME="" +HOST="" +PORT="${HH_PORT:-$DEFAULT_PORT}" +PASSWORD="${HH_PASSWORD:-}" +NO_TLS=1 # rooms run --no-tls over Tailscale/LAN by default +INSECURE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -p|--password) PASSWORD="$2"; shift 2 ;; + -P|--port) PORT="$2"; shift 2 ;; + --tls) NO_TLS=0; shift ;; + --insecure) INSECURE=1; shift ;; + -h|--help) sed -n '2,/^set /{/^set /d;s/^# \{0,1\}//;p}' "$0"; exit 0 ;; + -*) echo "✖ unknown option: $1" >&2; exit 2 ;; + *) + if [[ -z "$NAME" ]]; then NAME="$1" + elif [[ -z "$HOST" ]]; then HOST="$1" + else echo "✖ unexpected argument: $1" >&2; exit 2; fi + shift ;; + esac +done + +HOST="${HOST:-$DEFAULT_HOST}" + +# No password yet? Prompt with no echo — straight into RAM, out of history. +if [[ -z "$PASSWORD" ]]; then + read -rsp "⛧ room password: " PASSWORD < /dev/tty + echo +fi +[[ -n "$PASSWORD" ]] || { echo "✖ a password is required" >&2; exit 1; } + +BIN=./target/release/hack-house +[[ -x "$BIN" ]] || BIN=./target/debug/hack-house +[[ -x "$BIN" ]] || { echo "✖ no hack-house binary — run: cargo build --release" >&2; exit 1; } + +args=(connect "$HOST" "$PORT") +[[ -n "$NAME" ]] && args+=("$NAME") # omit → client prompts for a handle +args+=(--password "$PASSWORD") +[[ "$NO_TLS" -eq 1 ]] && args+=(--no-tls) +[[ "$INSECURE" -eq 1 ]] && args+=(--insecure) + +# exec replaces this shell, so our $PASSWORD copy dies here; only the child holds +# it (via argv), and we never exported it into the child's environment. +exec "$BIN" "${args[@]}" diff --git a/hh/src/app.rs b/hh/src/app.rs index 1bc6596..d48c01e 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -739,25 +739,35 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme } } net = rx.recv() => { - match net { - Some(Net::SbxInput { from, bytes }) => { - if let Some(sb) = &mut broker { - if app.drivers.contains(&from) { - let _ = sb.write_input(&bytes); + // Drain a burst of incoming frames per turn. The reader funnels both + // chat and high-volume `_sbx:data` terminal output through this one + // channel, and the loop redraws once per turn — so handling a single + // frame per redraw lets a busy sandbox stream bury chat arbitrarily far + // back in the queue. Pulling up to a cap of ready frames now keeps chat + // latency bounded no matter how hard the shared shell is scrolling. + let Some(first) = net else { break Ok(()) }; + let mut burst = vec![first]; + drain_ready(&mut rx, &mut burst, 256); + for ev in burst { + match ev { + Net::SbxInput { from, bytes } => { + if let Some(sb) = &mut broker { + if app.drivers.contains(&from) { + let _ = sb.write_input(&bytes); + } } } - } - Some(Net::Ft(f)) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads), - // The broker renders its sandbox locally from the PTY, so it - // ignores its own echoed status/data; everyone else uses them. - Some(Net::SbxData(b)) => { - if broker.is_none() { - if let Some(v) = &mut app.sandbox { v.parser.process(&b); } + Net::Ft(f) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads), + // The broker renders its sandbox locally from the PTY, so it + // ignores its own echoed status/data; everyone else uses them. + Net::SbxData(b) => { + if broker.is_none() { + if let Some(v) = &mut app.sandbox { v.parser.process(&b); } + } } + Net::SbxStatus { .. } if broker.is_some() => {} + other => app.apply(other), } - Some(Net::SbxStatus { .. }) if broker.is_some() => {} - Some(n) => app.apply(n), - None => break Ok(()), } } msg = broker_rx.recv() => { @@ -905,6 +915,19 @@ async fn writer_task( } } +/// Pull up to `cap` *already-ready* items out of `rx` (without awaiting) in FIFO +/// order, appending to `buf`. The UI loop uses this to drain a burst of incoming +/// frames per turn so a high-volume `_sbx:data` stream can't bury chat behind a +/// one-frame-per-redraw cap. +fn drain_ready(rx: &mut UnboundedReceiver, buf: &mut Vec, cap: usize) { + while buf.len() < cap { + match rx.try_recv() { + Ok(m) => buf.push(m), + Err(_) => break, // empty or disconnected — nothing more to take right now + } + } +} + #[allow(clippy::too_many_arguments)] fn handle_command( line: &str, @@ -1399,3 +1422,65 @@ fn spawn_agent( cmd.spawn() .map_err(|e| format!("could not start agent ({}): {e}", program.display())) } + +#[cfg(test)] +mod tests { + use super::drain_ready; + use tokio::sync::mpsc::unbounded_channel; + + /// `drain_ready` pulls a bounded burst in FIFO order and stops at the cap. + #[tokio::test] + async fn drain_ready_is_fifo_and_capped() { + let (tx, mut rx) = unbounded_channel::(); + for i in 0..1000 { + tx.send(i).unwrap(); + } + // Mimic the loop: one awaited frame, then drain the ready burst. + let first = rx.recv().await.unwrap(); + let mut buf = vec![first]; + drain_ready(&mut rx, &mut buf, 256); + assert_eq!(buf.len(), 256, "burst must be capped"); + assert_eq!(buf, (0..256).collect::>(), "burst must stay FIFO"); + } + + /// An empty channel leaves the buffer untouched (no spurious items, no hang). + #[tokio::test] + async fn drain_ready_on_empty_is_a_noop() { + let (tx, mut rx) = unbounded_channel::(); + tx.send(7).unwrap(); + let first = rx.recv().await.unwrap(); + let mut buf = vec![first]; + drain_ready(&mut rx, &mut buf, 256); + assert_eq!(buf, vec![7]); + } + + /// Regression for the "starting a sandbox stalls chat" bug: chat and a flood of + /// `_sbx:data` frames share one channel. Handling one frame per redraw would let + /// chat fall ~800 turns behind; batch draining must surface it within + /// ceil(801 / 256) = 4 turns no matter how hard the shell is scrolling. + #[tokio::test] + async fn chat_surfaces_promptly_under_sbx_flood() { + let (tx, mut rx) = unbounded_channel::<&'static str>(); + for _ in 0..800 { + tx.send("sbx").unwrap(); + } + tx.send("CHAT").unwrap(); + for _ in 0..800 { + tx.send("sbx").unwrap(); + } + + let mut turns = 0usize; + let mut saw_chat = false; + while let Ok(first) = rx.try_recv() { + let mut burst = vec![first]; + drain_ready(&mut rx, &mut burst, 256); + turns += 1; + if burst.contains(&"CHAT") { + saw_chat = true; + break; + } + } + assert!(saw_chat, "chat frame must be observed"); + assert!(turns <= 4, "chat took {turns} turns to surface (expected <= 4)"); + } +}