Reduce terminal and dashboard memory footprint

Frontend terminal:
- Share single TextDecoder across all instances (was one per terminal)
- Write binary WS frames as Uint8Array directly to ghostty-web,
  avoiding intermediate string allocation from TextDecoder.decode()
- Reduce default scrollback to 200 on mobile (was 1000; Safari mobile
  has ~300MB budget across all tabs)
- Pause heartbeat watchdog when tab is hidden to reduce WS traffic
  and message queue growth for background terminals

Dashboard screenshots:
- Revoke old object URL before creating new one (reduces peak memory
  by not having two decoded bitmaps alive simultaneously)
- Re-lookup card from cardsBySlug in fetch callback to avoid orphaned
  blob URLs when renderTiles() runs during in-flight requests
- Remove thumbnailCache writes (was redundant with activeObjectURLBySlug)

Server:
- Halve replay buffer from 256KB to 128KB per session
This commit is contained in:
GitHub Copilot
2026-02-17 21:13:37 +00:00
parent c2b10f1145
commit 3a88fb45d9
4 changed files with 24 additions and 19 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ package webterm
import "sync" import "sync"
const replayBufferSize = 256 * 1024 const replayBufferSize = 128 * 1024
type ReplayBuffer struct { type ReplayBuffer struct {
mu sync.Mutex mu sync.Mutex
+4 -6
View File
@@ -1270,7 +1270,6 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
return; return;
} }
screenshotRequestInFlight = true; screenshotRequestInFlight = true;
const img = card.img;
const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug); const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug);
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), 5000);
@@ -1290,14 +1289,13 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
}) })
.then((blob) => { .then((blob) => {
if (!blob) return; if (!blob) return;
const currentCard = cardsBySlug[slug];
if (!currentCard || !currentCard.img) return;
const previous = activeObjectURLBySlug[slug]; const previous = activeObjectURLBySlug[slug];
if (previous) URL.revokeObjectURL(previous);
const objectURL = URL.createObjectURL(blob); const objectURL = URL.createObjectURL(blob);
activeObjectURLBySlug[slug] = objectURL; activeObjectURLBySlug[slug] = objectURL;
img.src = objectURL; currentCard.img.src = objectURL;
thumbnailCache[slug] = { src: objectURL, updatedAt: Date.now() };
if (previous) {
URL.revokeObjectURL(previous);
}
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
File diff suppressed because one or more lines are too long
+13 -6
View File
@@ -26,6 +26,9 @@ async function getSharedGhostty(): Promise<Ghostty> {
return sharedGhostty; return sharedGhostty;
} }
/** Shared TextDecoder (stateless for UTF-8, safe to share) */
const sharedTextDecoder = new TextDecoder();
/** Default font stack - prefers system monospace, falls back through programming fonts */ /** Default font stack - prefers system monospace, falls back through programming fonts */
const DEFAULT_FONT_FAMILY = const DEFAULT_FONT_FAMILY =
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' + 'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
@@ -660,7 +663,6 @@ class WebTerminal {
private terminal: Terminal; private terminal: Terminal;
private fitAddon: FitAddon; private fitAddon: FitAddon;
private socket: WebSocket | null = null; private socket: WebSocket | null = null;
private readonly textDecoder = new TextDecoder();
private element: HTMLElement; private element: HTMLElement;
private wsUrl: string; private wsUrl: string;
private reconnectAttempts = 0; private reconnectAttempts = 0;
@@ -741,10 +743,11 @@ class WebTerminal {
const fontFamily = config.fontFamily?.trim() || DEFAULT_FONT_FAMILY; const fontFamily = config.fontFamily?.trim() || DEFAULT_FONT_FAMILY;
const fontSize = config.fontSize ?? 16; const fontSize = config.fontSize ?? 16;
const defaultScrollback = isMobileDevice() ? 200 : 1000;
const options: ITerminalOptions = { const options: ITerminalOptions = {
fontFamily, fontFamily,
fontSize, fontSize,
scrollback: config.scrollback ?? 1000, scrollback: config.scrollback ?? defaultScrollback,
cursorBlink: true, cursorBlink: true,
cursorStyle: "block", cursorStyle: "block",
theme: themeToUse, theme: themeToUse,
@@ -905,7 +908,12 @@ class WebTerminal {
// Focus terminal when returning to the tab // Focus terminal when returning to the tab
this.addTrackedListener(document, "visibilitychange", () => { this.addTrackedListener(document, "visibilitychange", () => {
if (!document.hidden) { if (document.hidden) {
this.stopHeartbeatWatchdog();
} else {
if (this.socket?.readyState === WebSocket.OPEN) {
this.startHeartbeatWatchdog();
}
restoreFocus(); restoreFocus();
} }
}); });
@@ -1634,9 +1642,8 @@ class WebTerminal {
private handleMessage(data: string | ArrayBuffer | Blob): void { private handleMessage(data: string | ArrayBuffer | Blob): void {
this.lastMessageAt = Date.now(); this.lastMessageAt = Date.now();
if (data instanceof ArrayBuffer) { if (data instanceof ArrayBuffer) {
// Binary data - write directly to terminal // Binary data - write directly to terminal as Uint8Array (avoids string allocation)
const text = this.textDecoder.decode(data); this.terminal.write(new Uint8Array(data));
this.terminal.write(text);
return; return;
} }
if (data instanceof Blob) { if (data instanceof Blob) {