Pause VT parser for idle sessions to eliminate CPU waste

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
This commit is contained in:
GitHub Copilot
2026-02-18 09:21:18 +00:00
parent 025d91a632
commit 2c5d3c72f9
7 changed files with 152 additions and 0 deletions
+58
View File
@@ -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)
}
}
+39
View File
@@ -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
}
+5
View File
@@ -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) {
+1
View File
@@ -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)}
}
+1
View File
@@ -24,6 +24,7 @@ type Session interface {
GetScreenSnapshot() terminalstate.Snapshot
ForceRedraw() error
UpdateConnector(connector SessionConnector)
MarkIdle()
}
type noopConnector struct{}
+46
View File
@@ -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
}
+2
View File
@@ -100,3 +100,5 @@ func (f *fakeSession) UpdateConnector(connector SessionConnector) {
defer f.mu.Unlock()
f.connector = connector
}
func (f *fakeSession) MarkIdle() {}