Fleet terminal: fix tmux nesting-refusal + frontend resize storm
ci / gates (push) Failing after 7s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

Root cause (from the server trace: 69 banner bytes then immediate "to_browser
ended" = pty_exit): tmux spawned and exited instantly, writing its "sessions
should be nested with care" warning to stderr (not the PTY). The operator runs
the daemon inside their own tmux, so $TMUX was inherited and the spawned tmux
refused to nest → browser saw nothing.

- daemon (v0.2.2): spawn tmux on a DEDICATED socket (`tmux -L clawmates
  new-session -A -s main`) and `env_remove("TMUX")`, so it can never collide with
  or be refused by the operator's tmux.
- NodeTerminalApp: debounce the ResizeObserver (150ms). The pull-out animates
  open, firing the observer on every pixel — previously ~80 resize frames per
  open, each fit()+SIGWINCH. Now one resize after layout settles.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-25 06:01:10 -07:00
co-authored by Claude Opus 4.8
parent 27f5d05f96
commit 95bd022d07
4 changed files with 19 additions and 4 deletions
Generated
+1 -1
View File
@@ -737,7 +737,7 @@ dependencies = [
[[package]] [[package]]
name = "clawmates-node" name = "clawmates-node"
version = "0.2.0" version = "0.2.2"
dependencies = [ dependencies = [
"base64", "base64",
"cm-sandbox", "cm-sandbox",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "clawmates-node" name = "clawmates-node"
version = "0.2.1" version = "0.2.2"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
license.workspace = true license.workspace = true
+9 -1
View File
@@ -475,10 +475,16 @@ fn tailscale_up(authkey: &str) {
fn terminal_command() -> CommandBuilder { fn terminal_command() -> CommandBuilder {
let mut cmd = if has_tmux() { let mut cmd = if has_tmux() {
let mut c = CommandBuilder::new("tmux"); let mut c = CommandBuilder::new("tmux");
// A DEDICATED server socket (-L) so we never collide with — or get refused
// by — the operator's own tmux. Without this, running the daemon inside a
// tmux makes the spawned tmux refuse to nest and exit instantly (writing
// its warning to stderr, not the PTY → the browser sees nothing).
c.arg("-L");
c.arg("clawmates");
c.arg("new-session"); c.arg("new-session");
c.arg("-A"); c.arg("-A");
c.arg("-s"); c.arg("-s");
c.arg("clawmates"); c.arg("main");
c c
} else { } else {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_owned()); let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_owned());
@@ -487,6 +493,8 @@ fn terminal_command() -> CommandBuilder {
c c
}; };
cmd.env("TERM", "xterm-256color"); cmd.env("TERM", "xterm-256color");
// Clear any inherited $TMUX so the new tmux doesn't think it's nested.
cmd.env_remove("TMUX");
if let Ok(home) = std::env::var("HOME") { if let Ok(home) = std::env::var("HOME") {
cmd.cwd(home); cmd.cwd(home);
} }
@@ -23,6 +23,7 @@ function NodeShell({ nodeId }: { nodeId: string }) {
let term: import("@xterm/xterm").Terminal | null = null; let term: import("@xterm/xterm").Terminal | null = null;
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let ro: ResizeObserver | null = null; let ro: ResizeObserver | null = null;
let resizeT: ReturnType<typeof setTimeout> | undefined;
(async () => { (async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([ const [{ Terminal }, { FitAddon }] = await Promise.all([
@@ -83,12 +84,18 @@ function NodeShell({ nodeId }: { nodeId: string }) {
term.onData((d) => { term.onData((d) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(d)); if (ws?.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(d));
}); });
ro = new ResizeObserver(() => sendResize()); // Debounce: the pull-out animates open, firing the observer on every pixel.
// Without this we'd spam dozens of resize frames (and SIGWINCH the PTY).
ro = new ResizeObserver(() => {
clearTimeout(resizeT);
resizeT = setTimeout(sendResize, 150);
});
ro.observe(hostRef.current); ro.observe(hostRef.current);
})(); })();
return () => { return () => {
disposed = true; disposed = true;
clearTimeout(resizeT);
ro?.disconnect(); ro?.disconnect();
ws?.close(); ws?.close();
term?.dispose(); term?.dispose();