From b7f21ba436ef312f5a82a4f778128f198dbbd80a Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 10 Jul 2026 18:15:27 -0300 Subject: [PATCH 1/3] perf(term): local binary WebSocket transport for terminal I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every keystroke was an HTTP fetch through the WebKit network process and every output chunk an evaluate_javascript call with a base64 payload — ~60 engine crossings/s while typing, measured as ~40ms main-thread stall trains with every JS callback innocent (rAF instrumentation added to term-perf). One loopback WebSocket (random port + token) now multiplexes all sessions' terminal I/O as binary frames [idLen][sessionId][payload] in both directions: input frames land in the same PTY write as the Wails binding, and the coalescer sends output frames directly, skipping base64. When the socket is down the backend and frontend fall back to the existing fetch/event paths automatically. coder/websocket was already in the module graph via Wails; promoted to a direct dependency. --- frontend/src/components/TerminalView.tsx | 23 ++- frontend/src/lib/term-frame.test.ts | 36 +++++ frontend/src/lib/term-frame.ts | 35 ++++ frontend/src/lib/term-perf.ts | 27 ++++ frontend/src/lib/term-transport.ts | 85 ++++++++++ go.mod | 2 +- internal/terminal/terminal.go | 22 ++- internal/terminal/transport.go | 195 +++++++++++++++++++++++ internal/terminal/transport_test.go | 158 ++++++++++++++++++ 9 files changed, 577 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/term-frame.test.ts create mode 100644 frontend/src/lib/term-frame.ts create mode 100644 frontend/src/lib/term-transport.ts create mode 100644 internal/terminal/transport.go create mode 100644 internal/terminal/transport_test.go diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index 6a2889b6f..b30b3e8cb 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -9,6 +9,7 @@ import { patchFontMetrics } from "@/lib/font-metrics" import { patchGlyphAtlas } from "@/lib/glyph-atlas" import { patchPooledGetLine } from "@/lib/getline-pool" import { patchScrollGate, patchScrollbackCache } from "@/lib/scrollback-perf" +import { ensureTransport, onSessionData, sendInput } from "@/lib/term-transport" import { pauseRenderLoop, resumeRenderLoop } from "@/lib/render-pause" import { patchRowPaint } from "@/lib/row-paint" import { isTextPasteChord, missingKeySequence } from "@/lib/term-keys" @@ -175,6 +176,15 @@ export function TerminalView({ sessionId, projectId, cwd, kind, visible }: Termi patchScrollbackCache(term) fitTerminal(term, container) termRef.current = term + ensureTransport() + + // Input goes over the local WebSocket when it is up; otherwise the + // Wails binding. Both land in the same PTY write on the backend. + const writeInput = (data: string) => { + if (!sendInput(sessionId, data)) { + void Service.Write(sessionId, data) + } + } // Sequences ghostty-web 0.4.0 gets wrong (Shift+Tab, Alt chords) are // written to the PTY directly; returning true stops the terminal from @@ -195,11 +205,11 @@ export function TerminalView({ sessionId, projectId, cwd, kind, visible }: Termi if (seq === null) { return false } - void Service.Write(sessionId, seq) + writeInput(seq) return true }) - const dataInput = term.onData((data) => Service.Write(sessionId, data)) + const dataInput = term.onData(writeInput) const resizeInput = term.onResize(({ cols, rows }) => { if (visibleRef.current) { void Service.Resize(sessionId, cols, rows) @@ -228,10 +238,17 @@ export function TerminalView({ sessionId, projectId, cwd, kind, visible }: Termi term.write(bytes) recordChunk(t1 - t0, performance.now() - t1, bytes.length) }) + // Output arrives here while the WebSocket is up, on the Wails event + // above while it is down; the backend routes each chunk to exactly one. + const offWsData = onSessionData(sessionId, (payload) => { + const t0 = performance.now() + term.write(payload) + recordChunk(0, performance.now() - t0, payload.length) + }) const offExit = Events.On(EXIT_EVENT_PREFIX + sessionId, () => { term.write("\r\n[process exited]\r\n") }) - cleanups.push(offData, offExit) + cleanups.push(offData, offWsData, offExit) const resizeObserver = new ResizeObserver(scheduleRefit) resizeObserver.observe(container) diff --git a/frontend/src/lib/term-frame.test.ts b/frontend/src/lib/term-frame.test.ts new file mode 100644 index 000000000..57774b48e --- /dev/null +++ b/frontend/src/lib/term-frame.test.ts @@ -0,0 +1,36 @@ +import {describe, expect, it} from "vitest" +import {decodeFrame, encodeFrame} from "./term-frame" + +describe("term-frame", () => { + it("round-trips id and payload", () => { + const frame = encodeFrame("sess-1", new Uint8Array([1, 2, 3])) + expect(frame).not.toBeNull() + const decoded = decodeFrame(frame as Uint8Array) + expect(decoded?.id).toBe("sess-1") + expect(Array.from(decoded?.payload ?? [])).toEqual([1, 2, 3]) + }) + + it("round-trips an empty payload", () => { + const frame = encodeFrame("s", new Uint8Array(0)) + const decoded = decodeFrame(frame as Uint8Array) + expect(decoded?.id).toBe("s") + expect(decoded?.payload).toHaveLength(0) + }) + + it("handles multi-byte ids", () => { + const frame = encodeFrame("sessão-1", new Uint8Array([9])) + const decoded = decodeFrame(frame as Uint8Array) + expect(decoded?.id).toBe("sessão-1") + }) + + it("rejects invalid ids", () => { + expect(encodeFrame("", new Uint8Array(0))).toBeNull() + expect(encodeFrame("x".repeat(256), new Uint8Array(0))).toBeNull() + }) + + it("rejects malformed frames", () => { + expect(decodeFrame(new Uint8Array(0))).toBeNull() + expect(decodeFrame(new Uint8Array([0, 65]))).toBeNull() + expect(decodeFrame(new Uint8Array([10, 65]))).toBeNull() + }) +}) diff --git a/frontend/src/lib/term-frame.ts b/frontend/src/lib/term-frame.ts new file mode 100644 index 000000000..5a6399595 --- /dev/null +++ b/frontend/src/lib/term-frame.ts @@ -0,0 +1,35 @@ +// Binary frame codec for the local terminal I/O WebSocket, mirroring +// internal/terminal/transport.go: [1 byte id length][session id][payload]. + +export interface TermFrame { + id: string + payload: Uint8Array +} + +const MAX_ID_BYTES = 255 + +export function encodeFrame(id: string, payload: Uint8Array): Uint8Array | null { + const idBytes = new TextEncoder().encode(id) + if (idBytes.length === 0 || idBytes.length > MAX_ID_BYTES) { + return null + } + const frame = new Uint8Array(1 + idBytes.length + payload.length) + frame[0] = idBytes.length + frame.set(idBytes, 1) + frame.set(payload, 1 + idBytes.length) + return frame +} + +export function decodeFrame(frame: Uint8Array): TermFrame | null { + if (frame.length < 1) { + return null + } + const idLength = frame[0] + if (idLength === 0 || frame.length < 1 + idLength) { + return null + } + return { + id: new TextDecoder().decode(frame.subarray(1, 1 + idLength)), + payload: frame.subarray(1 + idLength), + } +} diff --git a/frontend/src/lib/term-perf.ts b/frontend/src/lib/term-perf.ts index b1c9127ab..20f772bad 100644 --- a/frontend/src/lib/term-perf.ts +++ b/frontend/src/lib/term-perf.ts @@ -15,9 +15,30 @@ const stats = { renderMs: 0, worstRenderMs: 0, sprites: 0, + rafMs: 0, + rafN: 0, + rafWorstMs: 0, } let started = false +// wrapRaf measures every requestAnimationFrame callback so rAF work (ghostty +// render loops, scrollbar fades) can be separated from engine work (paint, +// GC, IPC dispatch) inside a stall: raf≈stall means the callback is guilty, +// raf≪stall means the engine is. Loops that started before the wrap pick it +// up on their next self-rescheduled frame. +function wrapRaf(): void { + const original = window.requestAnimationFrame.bind(window) + window.requestAnimationFrame = (callback) => + original((time) => { + const t0 = performance.now() + callback(time) + const dt = performance.now() - t0 + stats.rafMs += dt + stats.rafN++ + stats.rafWorstMs = Math.max(stats.rafWorstMs, dt) + }) +} + // instrumentRender wraps a ghostty renderer's render() so paint cost shows up // in the per-second report — it runs inside ghostty's own rAF, invisible to // the decode/write timings. @@ -68,6 +89,7 @@ function start(): void { return } started = true + wrapRaf() // rAF gap watcher: frames >33ms are main-thread stalls not accounted for by // decode/write — eval of the event payload, ghostty paint, GC, React. @@ -100,6 +122,8 @@ function start(): void { `render=${stats.renderMs.toFixed(1)}ms n=${stats.renders} ` + `maxRender=${stats.worstRenderMs.toFixed(0)}ms ` + `sprites/s=${stats.sprites} ` + + `raf=${stats.rafMs.toFixed(0)}ms rafN=${stats.rafN} ` + + `rafWorst=${stats.rafWorstMs.toFixed(0)}ms ` + `stalls=${stats.stalls} worst=${stats.worstMs.toFixed(0)}ms` + (stallAt.length > 0 ? ` at=${stallAt.join(",")}` : ""), ) @@ -114,5 +138,8 @@ function start(): void { stats.renderMs = 0 stats.worstRenderMs = 0 stats.sprites = 0 + stats.rafMs = 0 + stats.rafN = 0 + stats.rafWorstMs = 0 }, 1000) } diff --git a/frontend/src/lib/term-transport.ts b/frontend/src/lib/term-transport.ts new file mode 100644 index 000000000..d60d0a44e --- /dev/null +++ b/frontend/src/lib/term-transport.ts @@ -0,0 +1,85 @@ +// Local WebSocket transport for terminal I/O, replacing the Wails bridge for +// the hot path: every Service.Write is an HTTP fetch through the WebKit +// network process and every data event an evaluate_javascript call — ~60 +// engine crossings/s while typing, measured as ~40ms main-thread stall trains +// (2026-07-10). One binary-frame socket carries input and output for every +// session; when it is down, callers fall back to the Wails paths, which the +// backend keeps serving. + +import {Service} from "../../bindings/github.com/omartelo/lich/internal/terminal" +import {decodeFrame, encodeFrame} from "./term-frame" + +const RECONNECT_MS = 1_000 + +let socket: WebSocket | null = null +let ready = false +let starting = false +const handlers = new Map void>() + +// ensureTransport starts the singleton connection; safe to call from every +// terminal mount. A backend without a transport (port 0) leaves the Wails +// bridge as the permanent path. +export function ensureTransport(): void { + if (starting) { + return + } + starting = true + void connect() +} + +async function connect(): Promise { + try { + const info = await Service.Transport() + if (!info.port) { + return + } + const ws = new WebSocket(`ws://127.0.0.1:${info.port}/ws?token=${info.token}`) + ws.binaryType = "arraybuffer" + ws.onopen = () => { + ready = true + } + ws.onclose = () => { + ready = false + socket = null + setTimeout(() => void connect(), RECONNECT_MS) + } + ws.onmessage = (event) => { + const frame = decodeFrame(new Uint8Array(event.data as ArrayBuffer)) + if (frame) { + handlers.get(frame.id)?.(frame.payload) + } + } + socket = ws + } catch { + setTimeout(() => void connect(), RECONNECT_MS) + } +} + +// sendInput delivers keyboard data for a session. Returns false when the +// socket is down so the caller falls back to Service.Write. +export function sendInput(sessionId: string, data: string): boolean { + if (!ready || !socket) { + return false + } + const frame = encodeFrame(sessionId, new TextEncoder().encode(data)) + if (!frame) { + return false + } + socket.send(frame) + return true +} + +// onSessionData registers the output sink for one session. The backend routes +// output through the socket only while it is connected, so subscribing here +// alongside the Wails event never double-delivers. +export function onSessionData( + sessionId: string, + callback: (payload: Uint8Array) => void, +): () => void { + handlers.set(sessionId, callback) + return () => { + if (handlers.get(sessionId) === callback) { + handlers.delete(sessionId) + } + } +} diff --git a/go.mod b/go.mod index 87e001d93..aec58622b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/omartelo/lich go 1.25.0 require ( + github.com/coder/websocket v1.8.14 github.com/creack/pty v1.1.24 github.com/wailsapp/wails/v3 v3.0.0-alpha2.116 modernc.org/sqlite v1.53.0 @@ -10,7 +11,6 @@ require ( require ( github.com/adrg/xdg v0.5.3 // indirect - github.com/coder/websocket v1.8.14 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 230c9dbd2..4bae8f6b3 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -46,12 +46,20 @@ type Service struct { mu sync.Mutex sessions map[string]*session bins BinResolver + // ws is the local WebSocket transport for terminal I/O (see transport.go); + // nil when it failed to start, leaving the Wails event bridge as the path. + ws *transport } // New returns a ready-to-use terminal service that resolves the binary to spawn // through bins. func New(bins BinResolver) *Service { - return &Service{sessions: make(map[string]*session), bins: bins} + s := &Service{sessions: make(map[string]*session), bins: bins} + ws, err := newTransport(func(id string, data []byte) { _ = s.writeBytes(id, data) }) + if err == nil { + s.ws = ws + } + return s } // defaultBin is the Claude Code binary spawned when the user has not configured @@ -119,6 +127,9 @@ func (s *Service) Start(id, projectID, cwd, kind string, cols, rows int) error { } out := newCoalescer(func(data []byte) { + if s.ws != nil && s.ws.send(id, data) { + return + } encoded := base64.StdEncoding.EncodeToString(data) application.Get().Event.Emit(dataEventPrefix+id, encoded) }, visibleFlushInterval, hiddenFlushInterval) @@ -158,11 +169,18 @@ func (s *Service) stream(id string, ptmx *os.File, cmd *exec.Cmd, out *coalescer // Write forwards keyboard input from the frontend to a session's PTY. func (s *Service) Write(id, data string) error { + return s.writeBytes(id, []byte(data)) +} + +// writeBytes delivers input bytes to a session's PTY; unknown sessions are a +// no-op. It is the shared sink for the Wails binding and the WebSocket +// transport's input frames. +func (s *Service) writeBytes(id string, data []byte) error { ptmx := s.ptmxOf(id) if ptmx == nil { return nil } - _, err := ptmx.Write([]byte(data)) + _, err := ptmx.Write(data) return err } diff --git a/internal/terminal/transport.go b/internal/terminal/transport.go new file mode 100644 index 000000000..e81bc2882 --- /dev/null +++ b/internal/terminal/transport.go @@ -0,0 +1,195 @@ +package terminal + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/coder/websocket" +) + +// The Wails event bridge costs one engine round-trip per crossing: every +// Service.Write is an HTTP fetch through the WebKit network process, and every +// data event is an evaluate_javascript call with a base64 payload. During +// interactive typing that adds up to ~60 crossings/s of native dispatch +// overhead that saturates the webview main thread (measured 2026-07-10: +// ~40ms stall trains while typing, all JS callbacks innocent). This transport +// replaces both directions for terminal I/O with one local WebSocket carrying +// binary frames; everything else stays on the Wails bridge. When no client is +// connected (or a write fails) senders fall back to the event bridge, so the +// app works unchanged without it. +// +// Frame format, both directions: [1 byte id length][session id][payload]. + +const ( + // wsWriteTimeout bounds a send to the local client; a stalled write drops + // the connection so output falls back to the event bridge. + wsWriteTimeout = 5 * time.Second + // wsReadLimit bounds one input frame; keystrokes and pastes are far + // smaller, and the frontend chunks nothing above this. + wsReadLimit = 1 << 20 + // tokenBytes is the size of the random connect token. + tokenBytes = 16 +) + +// encodeFrame prefixes payload with the session id. The id must fit one byte +// of length. +func encodeFrame(id string, payload []byte) ([]byte, error) { + if len(id) == 0 || len(id) > 255 { + return nil, fmt.Errorf("session id length %d out of range", len(id)) + } + buf := make([]byte, 1+len(id)+len(payload)) + buf[0] = byte(len(id)) + copy(buf[1:], id) + copy(buf[1+len(id):], payload) + return buf, nil +} + +// decodeFrame splits a frame into session id and payload. The payload aliases +// buf; callers must not retain it past the next read. +func decodeFrame(buf []byte) (string, []byte, error) { + if len(buf) < 1 { + return "", nil, errors.New("empty frame") + } + n := int(buf[0]) + if n == 0 || len(buf) < 1+n { + return "", nil, fmt.Errorf("frame id length %d exceeds frame size %d", n, len(buf)) + } + return string(buf[1 : 1+n]), buf[1+n:], nil +} + +// transport is the local WebSocket endpoint. One client (the webview) is +// expected; a new connection replaces the previous one. +type transport struct { + mu sync.Mutex + conn *websocket.Conn + port int + token string + input func(id string, data []byte) +} + +// newTransport starts the listener on a random loopback port. input receives +// decoded input frames (keyboard data for a session's PTY). +func newTransport(input func(id string, data []byte)) (*transport, error) { + raw := make([]byte, tokenBytes) + if _, err := rand.Read(raw); err != nil { + return nil, fmt.Errorf("failed to generate transport token: %w", err) + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to listen for transport: %w", err) + } + t := &transport{ + port: listener.Addr().(*net.TCPAddr).Port, + token: hex.EncodeToString(raw), + input: input, + } + mux := http.NewServeMux() + mux.HandleFunc("/ws", t.handle) + // ponytail: server and listener live for the process lifetime, like the + // PTY sessions they serve; add Shutdown if the app ever needs teardown. + go func() { _ = http.Serve(listener, mux) }() + return t, nil +} + +// handle upgrades the single expected client. The webview's origin is the +// wails scheme (or the Vite dev server), never this listener's host, so the +// origin check is skipped — the random token is the auth. +func (t *transport) handle(w http.ResponseWriter, r *http.Request) { + provided := r.URL.Query().Get("token") + if subtle.ConstantTimeCompare([]byte(provided), []byte(t.token)) != 1 { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + conn.SetReadLimit(wsReadLimit) + + t.mu.Lock() + previous := t.conn + t.conn = conn + t.mu.Unlock() + if previous != nil { + _ = previous.Close(websocket.StatusPolicyViolation, "replaced by new client") + } + go t.readLoop(conn) +} + +// readLoop forwards input frames to the service until the connection dies. +func (t *transport) readLoop(conn *websocket.Conn) { + ctx := context.Background() + for { + kind, data, err := conn.Read(ctx) + if err != nil { + t.drop(conn) + return + } + if kind != websocket.MessageBinary { + continue + } + id, payload, err := decodeFrame(data) + if err != nil { + continue + } + t.input(id, payload) + } +} + +// drop forgets conn if it is still the active client. +func (t *transport) drop(conn *websocket.Conn) { + t.mu.Lock() + if t.conn == conn { + t.conn = nil + } + t.mu.Unlock() + _ = conn.Close(websocket.StatusNormalClosure, "") +} + +// send delivers one session's output frame to the connected client. It +// returns false — and drops the client on write failure — when the caller +// should fall back to the event bridge. +// ponytail: one mutex serializes writes for every session; per-session queues +// only if a local loopback write ever measurably stalls. +func (t *transport) send(id string, data []byte) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.conn == nil { + return false + } + frame, err := encodeFrame(id, data) + if err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), wsWriteTimeout) + defer cancel() + if err := t.conn.Write(ctx, websocket.MessageBinary, frame); err != nil { + t.conn = nil + return false + } + return true +} + +// TransportInfo tells the frontend where the terminal I/O WebSocket lives. A +// zero Port means the transport failed to start and the event bridge is the +// only path. +type TransportInfo struct { + Port int `json:"port"` + Token string `json:"token"` +} + +// Transport returns the WebSocket endpoint for terminal I/O. +func (s *Service) Transport() TransportInfo { + if s.ws == nil { + return TransportInfo{} + } + return TransportInfo{Port: s.ws.port, Token: s.ws.token} +} diff --git a/internal/terminal/transport_test.go b/internal/terminal/transport_test.go new file mode 100644 index 000000000..8b658519e --- /dev/null +++ b/internal/terminal/transport_test.go @@ -0,0 +1,158 @@ +package terminal + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/coder/websocket" +) + +func TestFrameRoundTrip(t *testing.T) { + frame, err := encodeFrame("sess-1", []byte("hello")) + if err != nil { + t.Fatalf("encodeFrame: %v", err) + } + id, payload, err := decodeFrame(frame) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if id != "sess-1" || !bytes.Equal(payload, []byte("hello")) { + t.Fatalf("got id=%q payload=%q", id, payload) + } +} + +func TestFrameEmptyPayload(t *testing.T) { + frame, err := encodeFrame("s", nil) + if err != nil { + t.Fatalf("encodeFrame: %v", err) + } + id, payload, err := decodeFrame(frame) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if id != "s" || len(payload) != 0 { + t.Fatalf("got id=%q payload=%q", id, payload) + } +} + +func TestFrameErrors(t *testing.T) { + if _, err := encodeFrame("", []byte("x")); err == nil { + t.Fatal("expected error for empty id") + } + long := make([]byte, 256) + if _, err := encodeFrame(string(long), nil); err == nil { + t.Fatal("expected error for oversized id") + } + if _, _, err := decodeFrame(nil); err == nil { + t.Fatal("expected error for empty frame") + } + if _, _, err := decodeFrame([]byte{10, 'a'}); err == nil { + t.Fatal("expected error for truncated frame") + } + if _, _, err := decodeFrame([]byte{0, 'a'}); err == nil { + t.Fatal("expected error for zero id length") + } +} + +// dial connects a test client to the transport with the given token. +func dial(t *testing.T, tr *transport, token string) (*websocket.Conn, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, resp, err := websocket.Dial(ctx, fmt.Sprintf("ws://127.0.0.1:%d/ws?token=%s", tr.port, token), nil) + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + return conn, err +} + +func TestTransportRejectsBadToken(t *testing.T) { + tr, err := newTransport(func(string, []byte) {}) + if err != nil { + t.Fatalf("newTransport: %v", err) + } + if _, err := dial(t, tr, "wrong"); err == nil { + t.Fatal("expected dial to fail with a bad token") + } +} + +func TestTransportInputReachesService(t *testing.T) { + got := make(chan string, 1) + tr, err := newTransport(func(id string, data []byte) { + got <- id + ":" + string(data) + }) + if err != nil { + t.Fatalf("newTransport: %v", err) + } + conn, err := dial(t, tr, tr.token) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + + frame, err := encodeFrame("sess", []byte("ls\r")) + if err != nil { + t.Fatalf("encodeFrame: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Write(ctx, websocket.MessageBinary, frame); err != nil { + t.Fatalf("write: %v", err) + } + select { + case v := <-got: + if v != "sess:ls\r" { + t.Fatalf("got %q", v) + } + case <-time.After(5 * time.Second): + t.Fatal("input frame never reached the service") + } +} + +func TestTransportSendAndFallback(t *testing.T) { + tr, err := newTransport(func(string, []byte) {}) + if err != nil { + t.Fatalf("newTransport: %v", err) + } + if tr.send("sess", []byte("x")) { + t.Fatal("send must report false with no client connected") + } + + conn, err := dial(t, tr, tr.token) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }() + // The server registers the client during the handshake, so send is ready + // as soon as Dial returns. + if !tr.send("sess", []byte("output")) { + t.Fatal("send must succeed with a connected client") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + kind, data, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read: %v", err) + } + if kind != websocket.MessageBinary { + t.Fatalf("got message type %v", kind) + } + id, payload, err := decodeFrame(data) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if id != "sess" || string(payload) != "output" { + t.Fatalf("got id=%q payload=%q", id, payload) + } +} + +func TestTransportInfoZeroWithoutTransport(t *testing.T) { + s := &Service{} + if info := s.Transport(); info.Port != 0 || info.Token != "" { + t.Fatalf("expected zero TransportInfo, got %+v", info) + } +} From 3e6fa2562c5a8c77e8bcfc240c1082269d2e1a7d Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 10 Jul 2026 18:54:26 -0300 Subject: [PATCH 2/3] fix(linux): force X11 backend to dodge Wayland fractional-scaling repaints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKitGTK under Wayland fractional scaling renders every damage frame at 2x and downsamples on the CPU: typing in a full-size window cost ~40ms of engine time per frame, saturating the main thread (~25 stalls/s) and queueing input for seconds. GPU policy, DMABUF renderer, Skia painting threads and canvas alpha were all A/B tested with identical results — the cost is scale-conversion, not raster. Under X11/Xwayland the app sees an integer scale: same spam workload runs stall-free at 80+fps full-window. GDK_BACKEND is only set when absent so it stays overridable. --- main.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/main.go b/main.go index 16be6cd59..442a07ecc 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,8 @@ package main import ( "embed" "log" + "os" + "runtime" "github.com/omartelo/lich/internal/fonts" "github.com/omartelo/lich/internal/project" @@ -23,6 +25,19 @@ var assets embed.FS // main is the application's entry point. It creates the application, opens the // main window and blocks until the app exits. func main() { + // WebKitGTK under Wayland fractional scaling renders every damage frame + // at 2x and downsamples it on the CPU — typing in a full-size window cost + // ~40ms/frame of engine time regardless of raster backend (measured + // 2026-07-10; GPU policy, DMABUF, Skia threads and canvas alpha all made + // no difference). Under X11/Xwayland the app sees an integer scale and the + // same workload runs stall-free at full frame rate. Respect an explicit + // GDK_BACKEND so this stays overridable. + if runtime.GOOS == "linux" && os.Getenv("GDK_BACKEND") == "" { + if err := os.Setenv("GDK_BACKEND", "x11"); err != nil { + log.Printf("failed to set GDK_BACKEND: %v", err) + } + } + db, err := store.New() if err != nil { log.Fatal(err) From be7bec79b715e71e671a4021f1aad81d3c640595 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 12 Jul 2026 15:12:56 -0300 Subject: [PATCH 3/3] chore(release): prepare v0.1.0 --- CHANGELOG.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba7db0bf..a837c61e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] - 2026-07-12 + ### Changed - Terminal rendering is ~4× faster under heavy TUI load (nvim scroll worst-case @@ -89,5 +91,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Coalesced PTY output of hidden sessions in the backend to one event per 250 ms, flushed immediately when the session is shown. - Skipped the resize-driven refit for hidden terminals; they refit once on show. +- Shared one git-status poller per repository path with equality bailout: with + 20+ session cards the idle burst of ~44 IPC calls and ~88 git subprocesses + every 3 s collapses to one fetch per path, and unchanged status no longer + re-renders anything. +- Removed the per-cell defensive copy in ghostty-web's `getLine` (pool-backed + row references), gated rendering while scrolled with nothing dirty, and + memoized scrollback lines — reading scrollback now costs ~0 paint, and heavy + TUI throughput renders at ~40fps instead of ~25. +- Terminal I/O now flows over a local binary WebSocket (random loopback port, + token-authenticated) instead of one Wails HTTP call per keystroke and one + `evaluate_javascript` per output chunk; falls back to the Wails paths + automatically if the socket drops. +- Forced `GDK_BACKEND=x11` on Linux (only when unset): WebKitGTK under Wayland + fractional scaling rendered every damage frame at 2x and downsampled on the + CPU, costing ~40ms per frame in a full-size window. Under Xwayland typing is + stall-free at full frame rate. -[Unreleased]: https://github.com/omartelo/lich/commits/main +[Unreleased]: https://github.com/omartelo/lich/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/omartelo/lich/releases/tag/v0.1.0