From 2c5d3c72f90bc9bf4142b5c6d2ff7e74776d9364 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Wed, 18 Feb 2026 09:21:18 +0000 Subject: [PATCH] Pause VT parser for idle sessions to eliminate CPU waste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no WebSocket client is connected, the session's readLoop still processes every byte of terminal output through the go-te VT parser (tracker.Feed), Screen.Draw grapheme segmentation, and string allocations — even though nobody is consuming the screen state. For programs like btop inside tmux that produce continuous full-screen redraws, this causes sustained CPU usage and GC pressure over hours. Fix: after a 10-second idle threshold (no client connected), skip tracker.Feed() and only maintain the replay buffer. When a client reconnects (UpdateConnector) or a screenshot is requested (GetScreenSnapshot), rebuild the tracker by replaying the buffer through a fresh VT parser instance. Changes: - Add idleSince atomic timestamp + MarkIdle() to Session interface - handleOutput() skips tracker.Feed when idle > threshold - UpdateConnector() clears idle flag and rebuilds tracker from replay - GetScreenSnapshot() rebuilds stale tracker on-demand for screenshots - Wire MarkIdle() call into handleWebSocket cleanup (client disconnect) - Add TestIdleTrackerPauseAndRebuild covering the full lifecycle --- webterm/coverage_boost_test.go | 58 ++++++++++++++++++++++++++++++++++ webterm/docker_exec_session.go | 39 +++++++++++++++++++++++ webterm/server.go | 5 +++ webterm/server_test.go | 1 + webterm/session.go | 1 + webterm/terminal_session.go | 46 +++++++++++++++++++++++++++ webterm/test_helpers_test.go | 2 ++ 7 files changed, 152 insertions(+) diff --git a/webterm/coverage_boost_test.go b/webterm/coverage_boost_test.go index 6f0989d..b50a31e 100644 --- a/webterm/coverage_boost_test.go +++ b/webterm/coverage_boost_test.go @@ -770,3 +770,61 @@ func TestDockerWatcherStartStop(t *testing.T) { time.Sleep(20 * time.Millisecond) watcher.Stop() } + +func TestIdleTrackerPauseAndRebuild(t *testing.T) { +s := NewTerminalSession("idle-test", "echo hello") +s.tracker = terminalstate.NewTracker(80, 24) +conn := &recorderConnector{} +s.connector = conn + +// Feed some output while active — tracker and replay both update +s.handleOutput([]byte("hello")) +snap1 := s.GetScreenSnapshot() +if !snap1.HasChanges { +t.Fatal("expected HasChanges after active feed") +} +if got := string(s.GetReplayBuffer()); got != "hello" { +t.Fatalf("replay mismatch: %q", got) +} + +// Mark idle and advance past threshold +s.MarkIdle() +s.idleSince.Store(time.Now().Add(-idleTrackerThreshold - time.Second).UnixNano()) + +// Feed more output while idle — only replay should update +s.handleOutput([]byte(" world")) +if got := string(s.GetReplayBuffer()); got != "hello world" { +t.Fatalf("replay should accumulate while idle: %q", got) +} +conn.mu.Lock() +idleData := len(conn.data) +conn.mu.Unlock() +if idleData != 1 { +t.Fatalf("connector should NOT receive data while idle, got %d calls", idleData) +} + +// GetScreenSnapshot should rebuild tracker on-demand +snap2 := s.GetScreenSnapshot() +if !snap2.HasChanges { +t.Fatal("snapshot after idle rebuild should have changes") +} + +// UpdateConnector should clear idle and rebuild +s.MarkIdle() +s.idleSince.Store(time.Now().Add(-idleTrackerThreshold - time.Second).UnixNano()) +s.handleOutput([]byte("!")) +conn2 := &recorderConnector{} +s.UpdateConnector(conn2) +if s.idleSince.Load() != 0 { +t.Fatal("idleSince should be 0 after UpdateConnector") +} + +// Feed after reconnect should go through full pipeline again +s.handleOutput([]byte("x")) +conn2.mu.Lock() +got := len(conn2.data) +conn2.mu.Unlock() +if got != 1 { +t.Fatalf("expected 1 data call after reconnect, got %d", got) +} +} diff --git a/webterm/docker_exec_session.go b/webterm/docker_exec_session.go index 045dc80..fc18353 100644 --- a/webterm/docker_exec_session.go +++ b/webterm/docker_exec_session.go @@ -12,6 +12,8 @@ import ( "net/url" "strings" "sync" + "sync/atomic" + "time" "github.com/rcarmo/webterm/internal/terminalstate" ) @@ -51,6 +53,7 @@ type DockerExecSession struct { doneOnce sync.Once waitErr error writeMu sync.Mutex + idleSince atomic.Int64 // unix nano; 0 = active } func NewDockerExecSession(sessionID string, spec DockerExecSpec, socketPath string) *DockerExecSession { @@ -148,6 +151,12 @@ func (s *DockerExecSession) handleOutput(data []byte) { connector := s.connector s.mu.Unlock() filtered = FilterUnsupportedModes(filtered) + if ts := s.idleSince.Load(); ts != 0 && time.Since(time.Unix(0, ts)) > idleTrackerThreshold { + if len(filtered) > 0 { + s.replay.Add(filtered) + } + return + } dispatchSessionOutput(filtered, tracker, s.replay, connector) } @@ -307,6 +316,14 @@ func (s *DockerExecSession) GetReplayBuffer() []byte { } func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot { + if s.idleSince.Load() != 0 { + s.mu.Lock() + s.rebuildTracker() + tracker := s.tracker + width, height := s.width, s.height + s.mu.Unlock() + return snapshotFromTracker(tracker, width, height) + } s.mu.RLock() tracker := s.tracker width, height := s.width, s.height @@ -321,4 +338,26 @@ func (s *DockerExecSession) UpdateConnector(connector SessionConnector) { s.mu.Lock() defer s.mu.Unlock() s.connector = connector + if s.idleSince.Swap(0) != 0 { + s.rebuildTracker() + } +} + +// MarkIdle records that no observer is consuming output. +func (s *DockerExecSession) MarkIdle() { + s.idleSince.CompareAndSwap(0, time.Now().UnixNano()) +} + +// rebuildTracker replays the buffer through a fresh tracker so the screen +// state is up-to-date after an idle period. Caller must hold s.mu. +func (s *DockerExecSession) rebuildTracker() { + if s.tracker == nil { + return + } + fresh := terminalstate.NewTracker(s.width, s.height) + if data := s.replay.Bytes(); len(data) > 0 { + _ = fresh.Feed(data) + fresh.ConsumeActivityChanged() + } + s.tracker = fresh } diff --git a/webterm/server.go b/webterm/server.go index c655df9..9eab210 100644 --- a/webterm/server.go +++ b/webterm/server.go @@ -487,6 +487,11 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { s.mu.Unlock() go s.wsSender(client) defer s.stopWSClient(routeKey, client) + defer func() { + if session := s.sessionManager.GetSessionByRouteKey(routeKey); session != nil { + session.MarkIdle() + } + }() // Helper to send JSON through the send channel (avoids concurrent conn writes) sendJSON := func(v any) { diff --git a/webterm/server_test.go b/webterm/server_test.go index f2347eb..c3d7e0c 100644 --- a/webterm/server_test.go +++ b/webterm/server_test.go @@ -88,6 +88,7 @@ func (b *blockingSession) SendMeta(map[string]any) bool { return true } func (b *blockingSession) GetReplayBuffer() []byte { return nil } func (b *blockingSession) ForceRedraw() error { return nil } func (b *blockingSession) UpdateConnector(SessionConnector) {} +func (b *blockingSession) MarkIdle() {} func (b *blockingSession) GetScreenSnapshot() terminalstate.Snapshot { return terminalstate.Snapshot{Width: 80, Height: 24, Buffer: make([][]terminalstate.Cell, 24)} } diff --git a/webterm/session.go b/webterm/session.go index e92a0fc..52c3990 100644 --- a/webterm/session.go +++ b/webterm/session.go @@ -24,6 +24,7 @@ type Session interface { GetScreenSnapshot() terminalstate.Snapshot ForceRedraw() error UpdateConnector(connector SessionConnector) + MarkIdle() } type noopConnector struct{} diff --git a/webterm/terminal_session.go b/webterm/terminal_session.go index 54210c3..4c4b377 100644 --- a/webterm/terminal_session.go +++ b/webterm/terminal_session.go @@ -6,13 +6,20 @@ import ( "os/exec" "strings" "sync" + "sync/atomic" "syscall" + "time" "github.com/creack/pty" "github.com/google/shlex" "github.com/rcarmo/webterm/internal/terminalstate" ) +// idleTrackerThreshold is the duration after which we stop feeding the VT +// parser when no WebSocket client is connected. The replay buffer continues +// to accumulate so the tracker can be rebuilt on reconnect. +const idleTrackerThreshold = 10 * time.Second + type TerminalSession struct { sessionID string command string @@ -32,6 +39,7 @@ type TerminalSession struct { doneOnce sync.Once waitErr error writeMu sync.Mutex + idleSince atomic.Int64 // unix nano; 0 = active } func NewTerminalSession(sessionID string, command string) *TerminalSession { @@ -137,6 +145,13 @@ func (s *TerminalSession) handleOutput(data []byte) { connector := s.connector s.mu.Unlock() filtered = FilterUnsupportedModes(filtered) + if ts := s.idleSince.Load(); ts != 0 && time.Since(time.Unix(0, ts)) > idleTrackerThreshold { + // No client connected — only maintain the replay buffer. + if len(filtered) > 0 { + s.replay.Add(filtered) + } + return + } dispatchSessionOutput(filtered, tracker, s.replay, connector) } @@ -224,6 +239,15 @@ func (s *TerminalSession) GetReplayBuffer() []byte { } func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot { + if s.idleSince.Load() != 0 { + // Tracker is stale — rebuild before snapshotting. + s.mu.Lock() + s.rebuildTracker() + tracker := s.tracker + width, height := s.width, s.height + s.mu.Unlock() + return snapshotFromTracker(tracker, width, height) + } s.mu.RLock() tracker := s.tracker width, height := s.width, s.height @@ -238,4 +262,26 @@ func (s *TerminalSession) UpdateConnector(connector SessionConnector) { s.mu.Lock() defer s.mu.Unlock() s.connector = connector + if s.idleSince.Swap(0) != 0 { + s.rebuildTracker() + } +} + +// MarkIdle records that no observer is consuming output. +func (s *TerminalSession) MarkIdle() { + s.idleSince.CompareAndSwap(0, time.Now().UnixNano()) +} + +// rebuildTracker replays the buffer through a fresh tracker so the screen +// state is up-to-date after an idle period. Caller must hold s.mu. +func (s *TerminalSession) rebuildTracker() { + if s.tracker == nil { + return + } + fresh := terminalstate.NewTracker(s.width, s.height) + if data := s.replay.Bytes(); len(data) > 0 { + _ = fresh.Feed(data) + fresh.ConsumeActivityChanged() + } + s.tracker = fresh } diff --git a/webterm/test_helpers_test.go b/webterm/test_helpers_test.go index ea31b57..30e4b36 100644 --- a/webterm/test_helpers_test.go +++ b/webterm/test_helpers_test.go @@ -100,3 +100,5 @@ func (f *fakeSession) UpdateConnector(connector SessionConnector) { defer f.mu.Unlock() f.connector = connector } + +func (f *fakeSession) MarkIdle() {}