fix(hh): instant sandbox render + working Ctrl-C for the owner

The owner's keystrokes and terminal output used to round-trip through the
server, so output lagged (appeared only on the next keypress) and Ctrl-C got
queued behind a flood of outgoing output frames (e.g. 'tree') — so it never
interrupted and the command seemed to hang.

- Owner writes drive keystrokes straight to the local PTY (instant; Ctrl-C is
  never starved). Remote drivers still relay via the server.
- Owner renders its sandbox locally from the PTY and ignores its own echoed
  data/status frames (broker.is_none gate); others still render from echoes.
- Coalesce PTY output bursts into one frame (no flood).
- select! is biased on keyboard input; tick 120ms -> 50ms for snappier redraws.

Verified live: echo renders with no extra key; sleep+Ctrl-C interrupts cleanly.
9 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt 2026-05-30 22:10:18 -07:00
parent 1445b1151a
commit d6595935d3

View File

@ -349,7 +349,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
let mut app = App::new(session.username.clone()); let mut app = App::new(session.username.clone());
let mut events = EventStream::new(); let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(120)); let mut tick = tokio::time::interval(Duration::from_millis(50));
let result = loop { let result = loop {
if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) { if let Err(e) = term.draw(|f| ui::draw(f, &app, &theme)) {
@ -370,6 +370,7 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
} }
tokio::select! { tokio::select! {
biased; // keyboard first, so Ctrl-C / Esc are never starved by output floods
maybe = events.next() => { maybe = events.next() => {
match maybe { match maybe {
Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => { Some(Ok(Event::Key(k))) if k.kind == KeyEventKind::Press => {
@ -387,7 +388,13 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
if k.code == KeyCode::Esc { if k.code == KeyCode::Esc {
app.driving = false; app.driving = false;
} else if let Some(bytes) = key_to_pty(k.code, k.modifiers) { } else if let Some(bytes) = key_to_pty(k.code, k.modifiers) {
send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)})); if let Some(sb) = &mut broker {
// I own the sandbox: write straight to the PTY — instant,
// and Ctrl-C can't be queued behind outgoing output.
let _ = sb.write_input(&bytes);
} else {
send_frame(&out_tx, &session.room, json!({"_sbx":"input","b64": STANDARD.encode(&bytes)}));
}
} }
} else { } else {
match k.code { match k.code {
@ -418,6 +425,14 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
} }
} }
Some(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
// 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); }
}
}
Some(Net::SbxStatus { .. }) if broker.is_some() => {}
Some(n) => app.apply(n), Some(n) => app.apply(n),
None => break Ok(()), None => break Ok(()),
} }
@ -429,6 +444,12 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
broker_meta = Some((backend, name)); broker_meta = Some((backend, name));
announced_dims = Some((rows, cols)); announced_dims = Some((rows, cols));
launching = false; launching = false;
// Local sandbox view — broker renders straight from the PTY.
app.sandbox = Some(SbxView {
parser: vt100::Parser::new(rows.max(1), cols.max(1), 0),
backend: backend.label().to_string(),
});
app.sys(format!("⛧ sandbox summoned ({}) — /drive to take the shell", backend.label()));
app.owner = Some(app.me.clone()); app.owner = Some(app.me.clone());
app.drivers.clear(); app.drivers.clear();
app.drivers.insert(app.me.clone()); app.drivers.insert(app.me.clone());
@ -444,7 +465,14 @@ pub async fn run(session: Session, theme: Theme) -> Result<()> {
} }
} }
pty = pty_rx.recv() => { pty = pty_rx.recv() => {
if let Some(bytes) = pty { if let Some(mut bytes) = pty {
// Coalesce a burst (e.g. `tree`) into one frame: fewer round-trips,
// no flood. Render locally now so the owner sees output instantly.
while let Ok(more) = pty_rx.try_recv() {
bytes.extend_from_slice(&more);
if bytes.len() > 256 * 1024 { break; }
}
if let Some(v) = &mut app.sandbox { v.parser.process(&bytes); }
send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)})); send_frame(&out_tx, &session.room, json!({"_sbx":"data","b64": STANDARD.encode(&bytes)}));
} }
} }