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 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) + } +} 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)