From b09413ec8354ddb12a2047cb8687356176fd5945 Mon Sep 17 00:00:00 2001 From: Talut Salako Date: Sat, 22 Aug 2026 00:52:52 +0100 Subject: [PATCH 1/2] fix(gui): stop detail-pane render storm that froze traffic exploration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exploring captures (rapidly clicking rows in the All-traffic view) froze the UI, and large bodies eventually crashed the WebKit web process (JS heap exhaustion; confirmed by a user-session WebKitWeb- Process SIGABRT coredump). Four compounding causes, all fixed: - The 2s poll loop always installed fresh array identities into state, re-rendering the entire tree — including any open detail pane — even when nothing changed. Loaders now bail out via functional setState when a row fingerprint is unchanged. - BodyViewer re-ran prettyBody (JSON.parse + stringify), regex syntax highlighting, and a full innerHTML swap on every render, because dangerouslySetInnerHTML got a new object identity each time. All transforms are now memoized, pretty-print/highlight are skipped above 1 MiB (with an explanatory notice), and the innerHTML prop identity is stable. - Every capture detail embedded an ExportSnippet that auto-fired a snippet conversion on each selection change — one goja JS VM + httpsnippet eval per click. Conversion is now debounced 250ms and the snippet
 no longer re-assigns innerHTML per poll.

- Rapid row clicks each fetched the full body payload and rendered
  every response on arrival. Detail fetches are now deduplicated
  per row while in flight, and superseded responses are dropped.
  GetCapture also stopped shipping bodies twice (UTF-8 + base64);
  base64 alone halves the response the webview has to parse.

Validated live on Linux/webkit2gtk: a 5 MiB body that killed the
app outright now opens fine, and 12 rapid clicks across 1 MiB-body
rows settle cleanly (transient RSS spike, full GC recovery, UI
responsive throughout). Idle polling no longer rebuilds the detail
DOM: RSS is flat between real data changes.
---
 internal/gui/bindings.go        | 10 +++----
 ui/app.js                       | 49 +++++++++++++++++++++++++++------
 ui/components/code-block.js     | 34 +++++++++++++++++++----
 ui/components/export-snippet.js | 30 +++++++++++++-------
 ui/components/traffic-detail.js |  2 --
 5 files changed, 93 insertions(+), 32 deletions(-)

diff --git a/internal/gui/bindings.go b/internal/gui/bindings.go
index ee9b411..9302375 100644
--- a/internal/gui/bindings.go
+++ b/internal/gui/bindings.go
@@ -440,9 +440,11 @@ func captureSummary(rows []store.TrafficCaptureRow) []CaptureView {
 	return out
 }
 
-// captureDetail converts one capture row into the full DTO. Text fields remain
-// for compatibility and convenient inspection; Base64 fields preserve exact
-// bytes for binary viewers and downloads.
+// captureDetail converts one capture row into the full DTO. Bodies travel as
+// Base64 only: shipping the same bytes as UTF-8 as well doubled every
+// response (a 5 MB body became 12+ MB of JSON), and the webview's JSON.parse
+// of that payload was a large part of the traffic-tab freezes. The body
+// viewer already decodes Base64 byte-exactly.
 func captureDetail(c *store.TrafficCaptureRow) CaptureView {
 	return CaptureView{
 		ID:             c.ID,
@@ -452,11 +454,9 @@ func captureDetail(c *store.TrafficCaptureRow) CaptureView {
 		URL:            c.URL,
 		Status:         c.Status,
 		ReqHeaders:     parseHeaders(c.ReqHeadersJSON),
-		ReqBody:        string(c.ReqBody),
 		ReqBodyBase64:  base64.StdEncoding.EncodeToString(c.ReqBody),
 		ReqBodyLen:     len(c.ReqBody),
 		RespHeaders:    parseHeaders(c.RespHeadersJSON),
-		RespBody:       string(c.RespBody),
 		RespBodyBase64: base64.StdEncoding.EncodeToString(c.RespBody),
 		RespBodyLen:    len(c.RespBody),
 	}
diff --git a/ui/app.js b/ui/app.js
index 2c9631f..e77b8ec 100644
--- a/ui/app.js
+++ b/ui/app.js
@@ -53,6 +53,16 @@ function CommandDeck({ activeTab, onChange, counts, onSearch, project, filtered,
   `;
 }
 
+// Row fingerprints. Every poll calls setState with a fresh array identity,
+// which re-renders the whole tree — including any open detail pane, whose
+// body viewer is expensive on large payloads. Returning the previous state
+// identity when the rows did not change lets Preact skip that re-render.
+// Webhook (project, seq) pairs and capture ids are immutable once stored,
+// so these fingerprints cannot miss a real change.
+const webhooksFingerprint = (rows) => rows.map((w) => `${w.project}/${w.seq}`).join("|");
+const capturesFingerprint = (rows) => rows.map((c) => `${c.id}:${c.status}`).join("|");
+const sessionsFingerprint = (rows) => rows.map((s) => `${s.id}:${s.captures}:${s.ended_at || ""}`).join("|");
+
 function App() {
   const [activeTab, setActiveTab] = useState("webhooks");
   const [status, setStatus] = useState(null);
@@ -92,21 +102,24 @@ function App() {
   };
   const loadWebhooks = async () => {
     try {
-      setWebhooks((await api.listWebhooks(project)) || []);
+      const rows = (await api.listWebhooks(project)) || [];
+      setWebhooks((prev) => (webhooksFingerprint(prev) === webhooksFingerprint(rows) ? prev : rows));
     } catch (e) {
       showToast("list webhooks: " + e);
     }
   };
   const loadCaptures = async () => {
     try {
-      setCaptures((await api.listCaptures(sessionFilter)) || []);
+      const rows = (await api.listCaptures(sessionFilter)) || [];
+      setCaptures((prev) => (capturesFingerprint(prev) === capturesFingerprint(rows) ? prev : rows));
     } catch (e) {
       showToast("list captures: " + e);
     }
   };
   const loadSessions = async () => {
     try {
-      setSessions((await api.listSessions()) || []);
+      const rows = (await api.listSessions()) || [];
+      setSessions((prev) => (sessionsFingerprint(prev) === sessionsFingerprint(rows) ? prev : rows));
     } catch (e) {
       showToast("list sessions: " + e);
     }
@@ -173,20 +186,38 @@ function App() {
   }, [webhooks, captures, activeTab, follow]);
 
   // --- selection handlers ------------------------------------------------
+  // Rapid row clicking must not pile work onto the webview: each detail
+  // fetch ships the full body payload and each render decodes it, so every
+  // queued click used to add another multi-MB render on arrival. In-flight
+  // requests are deduplicated per row, and responses that were superseded
+  // by a newer click are dropped instead of rendered.
+  const latestDetailReq = useRef(0);
+  const inflightDetails = useRef(new Map());
+  const fetchDetail = (key, load) => {
+    const pending = inflightDetails.current;
+    let p = pending.get(key);
+    if (!p) {
+      p = load().finally(() => pending.delete(key));
+      pending.set(key, p);
+    }
+    return p;
+  };
   const openWebhook = async (proj, seq) => {
+    const req = ++latestDetailReq.current;
     try {
-      const w = await api.getWebhook(proj, seq);
-      setSelection({ kind: "webhook", data: w });
+      const w = await fetchDetail(`${proj}/${seq}`, () => api.getWebhook(proj, seq));
+      if (req === latestDetailReq.current) setSelection({ kind: "webhook", data: w });
     } catch (e) {
-      showToast("get webhook: " + e);
+      if (req === latestDetailReq.current) showToast("get webhook: " + e);
     }
   };
   const openCapture = async (id) => {
+    const req = ++latestDetailReq.current;
     try {
-      const c = await api.getCapture(id);
-      setSelection({ kind: "traffic", data: c });
+      const c = await fetchDetail(`c/${id}`, () => api.getCapture(id));
+      if (req === latestDetailReq.current) setSelection({ kind: "traffic", data: c });
     } catch (e) {
-      showToast("get capture: " + e);
+      if (req === latestDetailReq.current) showToast("get capture: " + e);
     }
   };
   const openScript = async (s) => {
diff --git a/ui/components/code-block.js b/ui/components/code-block.js
index d9cf5a7..2ad8674 100644
--- a/ui/components/code-block.js
+++ b/ui/components/code-block.js
@@ -7,6 +7,11 @@ import { copyText } from "../lib/clipboard.js";
 import { Dropdown } from "./dropdown.js";
 
 const LARGE_BODY_LIMIT = 15 * 1024 * 1024;
+// Pretty-print and JSON highlighting are only attempted below this size.
+// Both are synchronous O(body) passes that build several multiples of the
+// body size in strings and DOM; re-running them on every poll re-render
+// froze the webview on multi-MB bodies (and could exhaust the JS heap).
+const PRETTY_LIMIT = 1024 * 1024;
 
 function mediaType(contentType) {
   return String(contentType || "").split(";", 1)[0].trim().toLowerCase();
@@ -58,11 +63,23 @@ export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeigh
 
   if (!bytes.length) return html`

(empty)

`; - const pretty = prettyBody(text, contentType); - const renderedText = effectiveMode === "pretty" && pretty.isJSON ? pretty.text : text; - const inner = effectiveMode === "pretty" && pretty.isJSON - ? highlightJSON(renderedText) - : escapeHTML(effectiveMode === "hex" ? hexDump(bytes) : renderedText); + const oversizePretty = (bodyLength || bytes.length) > PRETTY_LIMIT; + // All heavy transforms below are memoized on their inputs: the poll loop + // re-renders this component every cycle, and re-running JSON.parse, + // regex highlighting, and a full innerHTML swap for a large body turned + // each 2s poll into seconds of blocked main thread. + const pretty = useMemo(() => { + if (effectiveMode !== "pretty" || oversizePretty) return { text, isJSON: false }; + return prettyBody(text, contentType); + }, [text, contentType, effectiveMode, oversizePretty]); + const renderedText = pretty.isJSON ? pretty.text : text; + const inner = useMemo(() => { + if (pretty.isJSON) return highlightJSON(renderedText); + return escapeHTML(effectiveMode === "hex" ? hexDump(bytes) : renderedText); + }, [pretty, renderedText, effectiveMode, bytes]); + // Stable object identity so Preact does not re-assign innerHTML when the + // content is unchanged. + const innerHTML = useMemo(() => ({ __html: inner }), [inner]); const copy = async () => { try { await copyText(renderedText); setCopyState("copied"); } catch { setCopyState("failed"); } @@ -101,7 +118,12 @@ export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeigh ? html`
This body is ${Math.round((bodyLength || bytes.length) / 1024 / 1024)} MB. Previewing it may use significant memory. Save the original to inspect it externally.
` : effectiveMode === "image" && imageURL ? html`
Captured ${type} body
` - : html`
`}
+        : html`
+ ${oversizePretty && effectiveMode === "pretty" + ? html`
Body is over 1 MB — pretty-print and highlighting are skipped for responsiveness. Use Text, Hex, or Save.
` + : null} +

+            
`} `; } diff --git a/ui/components/export-snippet.js b/ui/components/export-snippet.js index 96136e5..fc2f0e1 100644 --- a/ui/components/export-snippet.js +++ b/ui/components/export-snippet.js @@ -50,21 +50,27 @@ export function ExportSnippet({ exportKey, convert }) { [targets, target], ); - // Re-convert whenever the row or the language/client selection changes. + // Re-convert whenever the row or the language/client selection changes — + // debounced, because conversion spins a JS runtime on the Go side per + // call. Clicking rapidly through capture rows otherwise fires one + // conversion per click, and the pile-up froze the UI. useEffect(() => { if (!active) return undefined; let alive = true; setError(""); - convert(target, client).then( - (out) => alive && setSnippet(out), - (e) => { - if (!alive) return; - setSnippet(""); - setError(String(e)); - }, - ); + const timer = setTimeout(() => { + convert(target, client).then( + (out) => alive && setSnippet(out), + (e) => { + if (!alive) return; + setSnippet(""); + setError(String(e)); + }, + ); + }, 250); return () => { alive = false; + clearTimeout(timer); }; }, [exportKey, target, client, active]); @@ -97,6 +103,10 @@ export function ExportSnippet({ exportKey, convert }) { ] : [{ value: "", label: "default" }]; + // Stable object identity so the snippet
 is not re-assigned
+  // innerHTML on unrelated re-renders (the poll loop).
+  const snippetHTML = useMemo(() => ({ __html: escapeHTML(snippet) }), [snippet]);
+
   return html`
Export as code
@@ -129,7 +139,7 @@ export function ExportSnippet({ exportKey, convert }) {

         `}
   
`; diff --git a/ui/components/traffic-detail.js b/ui/components/traffic-detail.js index c111a81..ccfdaa8 100644 --- a/ui/components/traffic-detail.js +++ b/ui/components/traffic-detail.js @@ -46,7 +46,6 @@ export function TrafficDetail({ capture, onExport, onClose }) { len=${fmtBytes(capture.req_body_len)} > <${CodeBlock} - body=${capture.req_body} bodyBase64=${capture.req_body_base64} bodyLength=${capture.req_body_len} contentType=${reqCT} @@ -63,7 +62,6 @@ export function TrafficDetail({ capture, onExport, onClose }) { len=${fmtBytes(capture.resp_body_len)} > <${CodeBlock} - body=${capture.resp_body} bodyBase64=${capture.resp_body_base64} bodyLength=${capture.resp_body_len} contentType=${respCT} From 7068df84be30ba74160ee2b6aa23550602ad5563 Mon Sep 17 00:00:00 2001 From: Talut Salako Date: Sat, 22 Aug 2026 01:02:04 +0100 Subject: [PATCH 2/2] perf(gui): window body previews to 256 KiB regardless of payload size Memoization removed the repeated cost but the FIRST render of a multi-megabyte body still blocked the webview for 0.5-2s: the whole body was base64-decoded per byte, escaped with three regex passes, and materialized as DOM text. Measured on a 4.8 MB JSON body, one open plus a few row switches cost the shipped v0.2.0 ~7.1s of webview CPU and ~1 GB of RSS growth; the same interaction now costs ~0.5s with flat RSS. The viewer now decodes and renders at most DISPLAY_LIMIT (256 KiB) up front, with a notice and a 'Show entire body' escalation. Copy, Save, and Image still operate on the full payload, decoded on demand. escapeHTML is a single pass, and the base64 prefix is cut on a 4-character boundary so the slice always decodes cleanly. --- ui/components/code-block.js | 110 +++++++++++++++++++++++++++--------- ui/lib/format.js | 6 +- 2 files changed, 86 insertions(+), 30 deletions(-) diff --git a/ui/components/code-block.js b/ui/components/code-block.js index 2ad8674..8c783be 100644 --- a/ui/components/code-block.js +++ b/ui/components/code-block.js @@ -1,17 +1,28 @@ // BodyViewer inspects captured request/response bodies without assuming they are // UTF-8. Auto follows Content-Type; explicit modes handle incorrect headers. +// +// Bodies are rendered through a window: only the first DISPLAY_LIMIT bytes are +// decoded and put into the DOM, regardless of payload size. Materializing a +// multi-megabyte body as DOM text cost 0.5–2s of blocked webview main thread +// per open (and piled up fatally under rapid row switching), while the full +// bytes remain available for Copy, Save, Image, and an explicit +// "show entire body" escalation. import { html } from "../vendor/preact/index.js"; import { useEffect, useMemo, useState } from "../vendor/preact/index.js"; import { prettyBody, highlightJSON, escapeHTML } from "../lib/format.js"; import { copyText } from "../lib/clipboard.js"; import { Dropdown } from "./dropdown.js"; -const LARGE_BODY_LIMIT = 15 * 1024 * 1024; +// How much of a body is decoded and rendered up front. Above this the viewer +// shows a slice plus an escalation button. 256 KiB keeps decode + escape + +// DOM work in the tens of milliseconds even when the payload is megabytes. +const DISPLAY_LIMIT = 256 * 1024; // Pretty-print and JSON highlighting are only attempted below this size. // Both are synchronous O(body) passes that build several multiples of the -// body size in strings and DOM; re-running them on every poll re-render -// froze the webview on multi-MB bodies (and could exhaust the JS heap). +// body size in strings and DOM. const PRETTY_LIMIT = 1024 * 1024; +// Preview is refused outright above this size; Save still works. +const LARGE_BODY_LIMIT = 15 * 1024 * 1024; function mediaType(contentType) { return String(contentType || "").split(";", 1)[0].trim().toLowerCase(); @@ -32,6 +43,13 @@ function decodeBase64(encoded) { return bytes; } +// sliceBase64 cuts a byte-prefix of a base64 string. Only whole 4-char +// blocks decode to bytes, so the cut is aligned down to one. +function sliceBase64(encoded, maxBytes) { + const chars = Math.floor(maxBytes / 3) * 4; + return encoded.length <= chars ? encoded : encoded.slice(0, chars); +} + function bytesToText(bytes) { return new TextDecoder("utf-8", { fatal: false }).decode(bytes); } @@ -47,31 +65,60 @@ function hexDump(bytes) { return lines.join("\n"); } +function fmtMB(n) { + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeightClass = "max-h-72" }) { const [mode, setMode] = useState("auto"); const [copyState, setCopyState] = useState("idle"); + const [showAll, setShowAll] = useState(false); const type = mediaType(contentType); - const bytes = useMemo(() => bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || ""), [body, bodyBase64]); const effectiveMode = mode === "auto" ? autoMode(type) : mode; + + const totalLength = bodyLength || (bodyBase64 ? Math.floor(bodyBase64.length * 3 / 4) : (body || "").length); + const truncated = !showAll && totalLength > DISPLAY_LIMIT; + + // A new body resets the escalation so a big row never opens expanded. + useEffect(() => { + setShowAll(false); + }, [bodyBase64, body]); + + // The display window: at most DISPLAY_LIMIT bytes are ever decoded for + // rendering. Copy, Save, and Image decode the full payload on demand. + const displayB64 = useMemo( + () => (bodyBase64 && truncated ? sliceBase64(bodyBase64, DISPLAY_LIMIT) : bodyBase64 || ""), + [bodyBase64, truncated], + ); + const displayText = useMemo( + () => (!bodyBase64 && body ? (truncated ? body.slice(0, DISPLAY_LIMIT) : body) : ""), + [body, bodyBase64, truncated], + ); + const bytes = useMemo( + () => (displayB64 ? decodeBase64(displayB64) : new TextEncoder().encode(displayText || "")), + [displayB64, displayText], + ); const text = useMemo(() => bytesToText(bytes), [bytes]); - const isLarge = (bodyLength || bytes.length) > LARGE_BODY_LIMIT; + const isLarge = totalLength > LARGE_BODY_LIMIT; const imageURL = useMemo(() => { - if (effectiveMode !== "image" || !bytes.length || !/^image\//.test(type)) return ""; - return URL.createObjectURL(new Blob([bytes], { type: type || "application/octet-stream" })); - }, [bytes, effectiveMode, type]); + if (effectiveMode !== "image" || !totalLength || !/^image\//.test(type)) return ""; + // Images need the complete bytes, however large; they are the one mode + // that cannot render from a prefix. + const full = bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || ""); + if (!full.length) return ""; + return URL.createObjectURL(new Blob([full], { type: type || "application/octet-stream" })); + }, [body, bodyBase64, effectiveMode, type, totalLength]); useEffect(() => () => { if (imageURL) URL.revokeObjectURL(imageURL); }, [imageURL]); - if (!bytes.length) return html`

(empty)

`; + if (!totalLength && !bytes.length) return html`

(empty)

`; - const oversizePretty = (bodyLength || bytes.length) > PRETTY_LIMIT; - // All heavy transforms below are memoized on their inputs: the poll loop - // re-renders this component every cycle, and re-running JSON.parse, - // regex highlighting, and a full innerHTML swap for a large body turned - // each 2s poll into seconds of blocked main thread. + // Heavy transforms are memoized and only attempted within the display + // window; the poll loop re-renders this component every cycle for free. + const oversizePretty = totalLength > PRETTY_LIMIT; const pretty = useMemo(() => { - if (effectiveMode !== "pretty" || oversizePretty) return { text, isJSON: false }; + if (effectiveMode !== "pretty" || oversizePretty || truncated) return { text, isJSON: false }; return prettyBody(text, contentType); - }, [text, contentType, effectiveMode, oversizePretty]); + }, [text, contentType, effectiveMode, oversizePretty, truncated]); const renderedText = pretty.isJSON ? pretty.text : text; const inner = useMemo(() => { if (pretty.isJSON) return highlightJSON(renderedText); @@ -80,13 +127,20 @@ export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeigh // Stable object identity so Preact does not re-assign innerHTML when the // content is unchanged. const innerHTML = useMemo(() => ({ __html: inner }), [inner]); + + const fullText = () => (bodyBase64 ? bytesToText(decodeBase64(bodyBase64)) : (body || "")); + const fullBytes = () => (bodyBase64 ? decodeBase64(bodyBase64) : new TextEncoder().encode(body || "")); const copy = async () => { - try { await copyText(renderedText); setCopyState("copied"); } - catch { setCopyState("failed"); } + try { + await copyText(fullText()); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } setTimeout(() => setCopyState("idle"), 1600); }; const download = () => { - const url = URL.createObjectURL(new Blob([bytes], { type: type || "application/octet-stream" })); + const url = URL.createObjectURL(new Blob([fullBytes()], { type: type || "application/octet-stream" })); const a = document.createElement("a"); a.href = url; a.download = `wiretap-body.${type.split("/")[1]?.split("+")[0] || "bin"}`; @@ -114,16 +168,20 @@ export function BodyViewer({ body, bodyBase64, bodyLength, contentType, maxHeigh ${effectiveMode !== "image" ? html`` : null} + ${truncated + ? html`
+ Showing the first ${Math.round(DISPLAY_LIMIT / 1024)} KB of ${fmtMB(totalLength)}. + + (slower for large bodies) — Copy and Save always use the full payload. +
` + : oversizePretty && effectiveMode === "pretty" + ? html`
Body is over 1 MB — pretty-print and highlighting are skipped for responsiveness. Use Text, Hex, or Save.
` + : null} ${isLarge - ? html`
This body is ${Math.round((bodyLength || bytes.length) / 1024 / 1024)} MB. Previewing it may use significant memory. Save the original to inspect it externally.
` + ? html`
This body is ${fmtMB(totalLength)}. Previewing it may use significant memory. Save the original to inspect it externally.
` : effectiveMode === "image" && imageURL ? html`
Captured ${type} body
` - : html`
- ${oversizePretty && effectiveMode === "pretty" - ? html`
Body is over 1 MB — pretty-print and highlighting are skipped for responsiveness. Use Text, Hex, or Save.
` - : null} -

-            
`} + : html`
`}
   `;
 }
 
diff --git a/ui/lib/format.js b/ui/lib/format.js
index 47f38a9..f8bcb30 100644
--- a/ui/lib/format.js
+++ b/ui/lib/format.js
@@ -81,14 +81,12 @@ export function prettyBody(body, contentType) {
 
 /**
  * Escape HTML-special characters so a string is safe to inject as innerHTML.
+ * Single pass: bodies run through this on every display window change.
  * @param {string} s
  * @returns {string}
  */
 export function escapeHTML(s) {
-  return String(s)
-    .replace(/&/g, "&")
-    .replace(//g, ">");
+  return String(s).replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">"));
 }
 
 /**