perf: reduce input tarpitting and speed up large-display rendering

Input tarpitting fixes:
- terminal.ts: batch stdin writes with 10ms coalescing window (flushes
  immediately for large payloads like paste), replacing per-keystroke
  WebSocket messages with fewer, larger frames
- server.go: replace per-message time.After() with a reusable timer to
  eliminate GC pressure from repeated key input
- server.go: coalesce queued stdin writes (up to 4KB) into a single PTY
  write to reduce syscall overhead

Screenshot/rendering pipeline optimizations:
- tracker.go: stop forcing empty cells to space; let exporters decide
  what to render, drastically reducing work for mostly-blank terminals
- tracker.go: use clear() for dirty map instead of delete-in-loop
- svg_exporter.go: skip visually empty cells (blank glyph, default BG,
  no reverse/underline); still render background rects for colored or
  reverse-video cells
- png_exporter.go: add color parsing cache to avoid redundant hex
  parsing per cell; add empty cell fast-path; short-circuit blend math
  for coverage 0 and 255

Dashboard thumbnail concurrency:
- server.go: replace single-flight screenshot fetching with limited
  parallelism (2-4 concurrent requests based on hardwareConcurrency)
  so large dashboards with many tiles update faster

Also fixes typo in dashboard JS (tetagBySlug -> etagBySlug) that
silently broke ETag caching for screenshot refreshes.

Bumps version to 1.3.32.
This commit is contained in:
GitHub Copilot
2026-02-26 19:56:58 +00:00
parent 2d9cb7f062
commit 82ccfeb6a9
7 changed files with 252 additions and 84 deletions
+1 -1
View File
@@ -1 +1 @@
1.3.31
1.3.32
+1 -6
View File
@@ -74,9 +74,7 @@ func (t *Tracker) Feed(data []byte) error {
if len(t.screen.Dirty) > 0 {
t.changeCounter++
// Clear dirty set so subsequent feeds detect new changes
for k := range t.screen.Dirty {
delete(t.screen.Dirty, k)
}
clear(t.screen.Dirty)
}
return nil
}
@@ -108,9 +106,6 @@ func (t *Tracker) Snapshot() Snapshot {
for col := 0; col < t.screen.Columns; col++ {
raw := t.screen.Buffer[row][col]
data := raw.Data
if data == "" {
data = " "
}
line[col] = Cell{
Data: data,
FG: colorToString(raw.Attr.Fg),
+55 -11
View File
@@ -40,30 +40,74 @@ func RenderTerminalPNG(
return nil, nil
}
bgColor := mustParseHexColor(background)
bgFillColor := mustParseHexColor(background)
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
draw.Draw(img, img.Bounds(), &image.Uniform{bgFillColor}, image.Point{}, draw.Src)
// Cache parsed colors to avoid per-cell hex parsing overhead on large terminals.
colorCache := map[string]color.RGBA{}
parseColor := func(hex string) color.RGBA {
if c, ok := colorCache[hex]; ok {
return c
}
c := mustParseHexColor(hex)
colorCache[hex] = c
return c
}
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
row := buffer[rowIdx]
rectY := pngPadding + rowIdx*cellHeight
for col := 0; col < len(row); col++ {
cell := row[col]
if cell.Data == "" && cell.BG == "" {
charData := cell.Data
// Fast-path skip for visually empty cells (default background, no reverse video).
if (charData == "" || charData == " ") && !cell.Reverse &&
(cell.BG == "" || strings.EqualFold(cell.BG, "default")) {
continue
}
x := pngPadding + col*pngCharWidth
fg := colorToHex(cell.FG, true, palette, foreground, background)
bg := colorToHex(cell.BG, false, palette, foreground, background)
fgHex := colorToHex(cell.FG, true, palette, foreground, background)
bgHex := colorToHex(cell.BG, false, palette, foreground, background)
if cell.Reverse {
fg, bg = bg, fg
fgHex, bgHex = bgHex, fgHex
}
bgColor := mustParseHexColor(bg)
coverage := uint8(0)
if cell.Data != "" {
coverage = coverageForRune(firstRune(cell.Data))
// If there's no glyph, only render background when it differs from the image background.
if charData == "" || charData == " " {
if bgHex == background {
continue
}
cellColor := blendColors(mustParseHexColor(fg), bgColor, coverage)
draw.Draw(
img,
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
&image.Uniform{parseColor(bgHex)},
image.Point{},
draw.Src,
)
continue
}
bgColor := parseColor(bgHex)
fgColor := parseColor(fgHex)
coverage := coverageForRune(firstRune(charData))
var cellColor color.RGBA
switch coverage {
case 0:
cellColor = bgColor
case 255:
cellColor = fgColor
default:
cellColor = blendColors(fgColor, bgColor, coverage)
}
// Skip drawing default background cells (background already filled).
if cellColor == bgFillColor {
continue
}
draw.Draw(
img,
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
+58 -10
View File
@@ -2,6 +2,7 @@ package webterm
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"crypto/sha1"
@@ -555,14 +556,36 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
}
stdinQueue := make(chan stdinWrite, wsSendQueueMax)
defer close(stdinQueue)
// Coalesce small stdin writes (e.g. key repeats) to reduce syscall and locking overhead.
const stdinCoalesceMaxBytes = 4 * 1024
go func() {
var buf bytes.Buffer
for write := range stdinQueue {
if !write.session.SendBytes([]byte(write.data)) {
buf.Reset()
buf.WriteString(write.data)
for buf.Len() < stdinCoalesceMaxBytes {
select {
case next := <-stdinQueue:
buf.WriteString(next.data)
default:
goto flush
}
}
flush:
if !write.session.SendBytes(buf.Bytes()) {
log.Printf("stdin write failed route=%s remote=%s", routeKey, r.RemoteAddr)
}
}
}()
// Allocate the timeout timer once; avoid time.After() per stdin message.
stdinTimer := time.NewTimer(stdinWriteTimeout)
if !stdinTimer.Stop() {
<-stdinTimer.C
}
defer stdinTimer.Stop()
for {
messageType, payload, err := conn.ReadMessage()
if err != nil {
@@ -589,14 +612,34 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
if len(envelope) > 1 {
data, _ = envelope[1].(string)
}
write := stdinWrite{session: session, data: data}
select {
case stdinQueue <- stdinWrite{session: session, data: data}:
case <-time.After(stdinWriteTimeout):
case stdinQueue <- write:
// queued
default:
// Queue is full; wait briefly for it to drain.
if !stdinTimer.Stop() {
select {
case <-stdinTimer.C:
default:
}
}
stdinTimer.Reset(stdinWriteTimeout)
select {
case stdinQueue <- write:
if !stdinTimer.Stop() {
select {
case <-stdinTimer.C:
default:
}
}
case <-stdinTimer.C:
log.Printf("stdin queue saturated route=%s remote=%s: disconnecting client", routeKey, r.RemoteAddr)
sendJSON([]any{"error", "Input backlog detected"})
return
}
}
}
case "resize":
s.markRouteActivity(routeKey)
width, height := 80, 24
@@ -1168,7 +1211,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
const etagBySlug = {};
const refreshQueue = [];
const queuedRefresh = {};
let screenshotRequestInFlight = false;
// Allow limited parallelism when fetching thumbnails so large dashboards update faster.
const MAX_SCREENSHOT_CONCURRENCY = Math.max(2, Math.min(4, Math.floor((navigator.hardwareConcurrency || 4) / 2)));
let screenshotRequestsInFlight = 0;
const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle');
@@ -1452,15 +1497,17 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
window.addEventListener('blur', onDashboardFocusChanged);
function processRefreshQueue() {
if (screenshotRequestInFlight || refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return;
if (refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return;
while (screenshotRequestsInFlight < MAX_SCREENSHOT_CONCURRENCY && refreshQueue.length > 0) {
const slug = refreshQueue.shift();
delete queuedRefresh[slug];
const card = cardsBySlug[slug];
if (!card || !card.img) {
setTimeout(processRefreshQueue, 0);
return;
continue;
}
screenshotRequestInFlight = true;
screenshotRequestsInFlight++;
const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
@@ -1491,10 +1538,11 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
.catch(() => {})
.finally(() => {
clearTimeout(timeout);
screenshotRequestInFlight = false;
screenshotRequestsInFlight--;
setTimeout(processRefreshQueue, 0);
});
}
}
function queueTileRefresh(slug) {
if (!slug || queuedRefresh[slug]) return;
@@ -1570,7 +1618,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
grid.innerHTML = '';
cardsBySlug = {};
refreshQueue.length = 0;
screenshotRequestInFlight = false;
screenshotRequestsInFlight = 0;
for (const key in queuedRefresh) {
delete queuedRefresh[key];
}
File diff suppressed because one or more lines are too long
+69 -10
View File
@@ -14,6 +14,12 @@ const MAX_MESSAGE_QUEUE_SIZE = 1000;
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
/** Maximum bytes to buffer while the tab is hidden (256 KB) */
const MAX_HIDDEN_BUFFER_BYTES = 256 * 1024;
/** Batch stdin writes to reduce per-keystroke overhead and avoid WS/PTY backlogs */
const STDIN_BATCH_DELAY_MS = 10;
/** Flush stdin batch when it gets large (e.g. paste) */
const STDIN_BATCH_MAX_CHARS = 8192;
const BELL_EMOJI = "🔔";
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
@@ -663,6 +669,11 @@ class WebTerminal {
private lastMessageAt = 0;
private lastPongAt = 0;
private messageQueue: [string, unknown][] = [];
// Stdin batching (coalesces key repeats into fewer WS frames)
private pendingStdin = "";
private pendingStdinTimer: number | undefined;
private lastValidSize: { cols: number; rows: number } | null = null;
private mobileInput: HTMLTextAreaElement | null = null;
private mobileKeybar: HTMLElement | null = null;
@@ -828,7 +839,7 @@ class WebTerminal {
// Handle terminal input
this.terminal.onData((data) => {
this.clearBellState();
this.send(["stdin", data]);
this.sendStdin(data);
});
this.terminal.onBell(() => {
@@ -947,7 +958,7 @@ class WebTerminal {
if (!text) {
return;
}
this.send(["stdin", applyMobileModifiers(text)]);
this.sendStdin(applyMobileModifiers(text));
textarea.value = "";
this.deactivateModifiers();
this.pendingCtrl = false;
@@ -1000,7 +1011,7 @@ class WebTerminal {
e.preventDefault();
e.stopPropagation();
const toSend = isAlt ? applyAltModifier(ctrlApplied) : ctrlApplied;
this.send(["stdin", toSend]); // Ctrl+A=0x01, Ctrl+C=0x03, etc.
this.sendStdin(toSend); // Ctrl+A=0x01, Ctrl+C=0x03, etc.
this.deactivateModifiers(); // Clear modifiers after physical Ctrl+key
return;
}
@@ -1010,7 +1021,7 @@ class WebTerminal {
if (fnApplied) {
e.preventDefault();
e.stopPropagation();
this.send(["stdin", fnApplied]);
this.sendStdin(fnApplied);
this.deactivateModifiers();
return;
}
@@ -1049,7 +1060,7 @@ class WebTerminal {
if (seq) {
e.preventDefault();
e.stopPropagation();
this.send(["stdin", isAlt ? applyAltModifier(seq) : seq]);
this.sendStdin(isAlt ? applyAltModifier(seq) : seq);
// Always clear modifiers after any key
this.deactivateModifiers();
}
@@ -1077,7 +1088,7 @@ class WebTerminal {
const toSend = applyModifiers(event.key, useShift, useCtrl, useAlt, useFn);
event.preventDefault();
event.stopPropagation();
this.send(["stdin", toSend]);
this.sendStdin(toSend);
handled = true;
} else {
let seq: string | null = null;
@@ -1120,7 +1131,7 @@ class WebTerminal {
if (seq) {
event.preventDefault();
event.stopPropagation();
this.send(["stdin", useAlt ? applyAltModifier(seq) : seq]);
this.sendStdin(useAlt ? applyAltModifier(seq) : seq);
handled = true;
}
}
@@ -1319,7 +1330,7 @@ class WebTerminal {
key = applyAltModifier(key);
}
this.send(["stdin", key]);
this.sendStdin(key);
this.deactivateModifiers();
});
});
@@ -1535,7 +1546,7 @@ class WebTerminal {
/** Validate terminal dimensions */
private isValidSize(cols: number, rows: number): boolean {
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 200;
return cols >= 2 && cols <= 500 && rows >= 1 && rows <= 500;
}
/** Connect to WebSocket server */
@@ -1557,7 +1568,8 @@ class WebTerminal {
this.element.classList.add("-connected");
this.element.classList.remove("-disconnected");
// Process any queued messages
// Flush any batched stdin and process queued messages
this.flushStdin();
this.processMessageQueue();
// Send initial size
@@ -1723,8 +1735,50 @@ class WebTerminal {
this.connect();
}
/** Queue stdin data for batched sending */
private sendStdin(data: string): void {
if (!data) {
return;
}
this.pendingStdin += data;
// Flush immediately for large payloads (e.g. paste) to avoid excessive buffering.
if (this.pendingStdin.length >= STDIN_BATCH_MAX_CHARS) {
this.flushStdin();
return;
}
if (this.pendingStdinTimer) {
return;
}
this.pendingStdinTimer = window.setTimeout(() => {
this.pendingStdinTimer = undefined;
this.flushStdin();
}, STDIN_BATCH_DELAY_MS);
}
private flushStdin(): void {
if (this.pendingStdinTimer) {
clearTimeout(this.pendingStdinTimer);
this.pendingStdinTimer = undefined;
}
if (!this.pendingStdin) {
return;
}
const chunk = this.pendingStdin;
this.pendingStdin = "";
this.send(["stdin", chunk]);
}
/** Send message to server with queueing support */
private send(message: [string, unknown]): void {
// Preserve ordering: flush any pending stdin before non-stdin messages (resize/ping/etc).
if (message[0] !== "stdin" && this.pendingStdin) {
this.flushStdin();
}
if (this.messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
this.messageQueue = this.messageQueue.slice(-Math.floor(MAX_MESSAGE_QUEUE_SIZE / 2));
console.warn("[webterm] Message queue overflow; trimmed old messages");
@@ -1775,6 +1829,11 @@ class WebTerminal {
dispose(): void {
this.stopResourceCleanup();
this.stopHeartbeatWatchdog();
if (this.pendingStdinTimer) {
clearTimeout(this.pendingStdinTimer);
this.pendingStdinTimer = undefined;
}
this.pendingStdin = "";
if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = undefined;
+23 -1
View File
@@ -71,18 +71,40 @@ func RenderTerminalSVG(
for col := 0; col < len(row); col++ {
cell := row[col]
charData := cell.Data
if charData == "" {
blankGlyph := charData == "" || charData == " "
defaultBG := cell.BG == "" || strings.EqualFold(cell.BG, "default")
// If there's no glyph, no underline, no reverse, and the background is default,
// this cell is visually empty.
if blankGlyph && !cell.Underscore && !cell.Reverse && defaultBG {
continue
}
x := 10 + float64(col)*charWidth
fg := colorToHex(cell.FG, true, palette, foreground, background)
bg := colorToHex(cell.BG, false, palette, foreground, background)
if cell.Reverse {
fg, bg = bg, fg
}
// Render background even for empty cells (important for reverse video / colored spans).
if bg != background {
b.WriteString(fmt.Sprintf(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, x, rectY, charWidth+0.5, cellHeight+0.5, bg))
}
// Only render text for visible glyphs (or underlined blanks).
if charData == "" {
if cell.Underscore {
charData = " "
} else {
continue
}
}
if charData == " " && !cell.Underscore {
// Space glyphs are visually empty; keep background only.
continue
}
attrs := []string{fmt.Sprintf(`x="%.1f"`, x)}
if fg != foreground {
attrs = append(attrs, `fill="`+fg+`"`)