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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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+`"`)
|
||||
|
||||
Reference in New Issue
Block a user