fix(ui): batch-drain incoming frames so a sandbox stream can't stall chat
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 <noreply@anthropic.com>
This commit is contained in:
parent
ea67796551
commit
9158a488f7
71
hh/connect.sh
Executable file
71
hh/connect.sh
Executable file
|
|
@ -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 <host>
|
||||||
|
# 3) flag: ./connect.sh alice <host> -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[@]}"
|
||||||
115
hh/src/app.rs
115
hh/src/app.rs
|
|
@ -739,25 +739,35 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
net = rx.recv() => {
|
net = rx.recv() => {
|
||||||
match net {
|
// Drain a burst of incoming frames per turn. The reader funnels both
|
||||||
Some(Net::SbxInput { from, bytes }) => {
|
// chat and high-volume `_sbx:data` terminal output through this one
|
||||||
if let Some(sb) = &mut broker {
|
// channel, and the loop redraws once per turn — so handling a single
|
||||||
if app.drivers.contains(&from) {
|
// frame per redraw lets a busy sandbox stream bury chat arbitrarily far
|
||||||
let _ = sb.write_input(&bytes);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Net::Ft(f) => handle_ft(f, &mut app, &mut active_send, &out_tx, &session.room, &downloads),
|
||||||
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
|
||||||
// The broker renders its sandbox locally from the PTY, so it
|
// ignores its own echoed status/data; everyone else uses them.
|
||||||
// ignores its own echoed status/data; everyone else uses them.
|
Net::SbxData(b) => {
|
||||||
Some(Net::SbxData(b)) => {
|
if broker.is_none() {
|
||||||
if broker.is_none() {
|
if let Some(v) = &mut app.sandbox { v.parser.process(&b); }
|
||||||
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() => {
|
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<T>(rx: &mut UnboundedReceiver<T>, buf: &mut Vec<T>, 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn handle_command(
|
fn handle_command(
|
||||||
line: &str,
|
line: &str,
|
||||||
|
|
@ -1399,3 +1422,65 @@ fn spawn_agent(
|
||||||
cmd.spawn()
|
cmd.spawn()
|
||||||
.map_err(|e| format!("could not start agent ({}): {e}", program.display()))
|
.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::<u32>();
|
||||||
|
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::<Vec<_>>(), "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::<u32>();
|
||||||
|
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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user