From c8a282ed243c812793d98bc62703ad0079b41441 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 18 Jul 2026 11:10:01 -0500 Subject: [PATCH 1/4] perf(desktop): fix log ingest freeze and wrap log lines The main process froze under high-volume command output. Root causes, all on the ingest path rather than rendering: - Synchronous appendFileSync per line blocked the event loop. Switch LogStore to buffered async write streams (closeLog flushes on exit). - One IPC message per line flooded the channel. Coalesce lines into batched command-progress events (flush every 64ms or 250 lines) via a shared log sink; add an optional lines[] field to CommandProgress. - The renderer log buffer grew unbounded. Cap it (ring buffer, 5000 lines with hysteresis) in the detail page and create wizard. Display: rewrite LogTable to drop manual virtualization in favor of content-visibility windowing, so long lines soft-wrap while offscreen rows stay cheap. Cap rendered rows with a 'lines hidden' notice so a huge saved log can't create excessive DOM nodes. Verified with a 30k-line burst: UI stays responsive and lines wrap. --- desktop/src/main/__tests__/log-store.test.ts | 6 +- desktop/src/main/ipc.ts | 200 +++++++++--------- desktop/src/main/log-store.ts | 37 +++- .../src/lib/components/log/LogTable.svelte | 133 +++++------- .../workspace/WorkspaceWizard.svelte | 13 +- desktop/src/renderer/src/lib/types/index.ts | 12 +- .../src/pages/WorkspaceDetailPage.svelte | 15 +- 7 files changed, 224 insertions(+), 192 deletions(-) diff --git a/desktop/src/main/__tests__/log-store.test.ts b/desktop/src/main/__tests__/log-store.test.ts index 1a1ad80a7..7c2792526 100644 --- a/desktop/src/main/__tests__/log-store.test.ts +++ b/desktop/src/main/__tests__/log-store.test.ts @@ -25,10 +25,11 @@ describe("LogStore", () => { expect(logPath).toMatch(/\.log$/) }) - it("appends lines to a log file", () => { + it("appends lines to a log file", async () => { const logPath = store.createLogFile(CTX, "ws-1") store.appendLog(logPath, "line 1") store.appendLog(logPath, "line 2") + await store.closeLog(logPath) const content = store.readLogByPath(logPath) expect(content).toContain("line 1") expect(content).toContain("line 2") @@ -54,9 +55,10 @@ describe("LogStore", () => { expect(store.listLogs(CTX, "nonexistent")).toEqual([]) }) - it("reads a log file by workspace and filename", () => { + it("reads a log file by workspace and filename", async () => { const logPath = store.createLogFile(CTX, "ws-1") store.appendLog(logPath, "test content") + await store.closeLog(logPath) const entries = store.listLogs(CTX, "ws-1") const content = store.readLog(CTX, "ws-1", entries[0].filename) expect(content).toContain("test content") diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 178e5194d..9ec753de5 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -83,6 +83,66 @@ function formatLogLine(line: string, level: "INFO" | "ERROR" = "INFO"): string { return `${new Date().toISOString()}\t${level}\t${line}` } +interface ProgressSink { + /** Buffer a streamed line; flushes on a timer or when the batch fills. */ + line(formatted: string): void + /** Emit the final line and mark the command done, flushing immediately. */ + done( + finalLine: string, + extra?: { level?: "info" | "warn" | "error"; cliError?: CLIError }, + ): void +} + +/** + * Coalesces streamed log lines into batched `command-progress` events so a + * high-volume command doesn't emit one IPC message per line (which floods the + * channel and stalls both processes). Lines flush on a short timer or once the + * batch fills, whichever comes first. + */ +function createLogSink( + getWin: () => BrowserWindow | null, + commandId: string, + appendLog?: (line: string) => void, +): ProgressSink { + const FLUSH_MS = 64 + const MAX_BATCH = 250 + let buf: string[] = [] + let timer: ReturnType | null = null + + function post( + done: boolean, + extra?: { message?: string; level?: "info" | "warn" | "error"; cliError?: CLIError }, + ): void { + if (timer) { + clearTimeout(timer) + timer = null + } + if (!done && buf.length === 0) return + const lines = buf + buf = [] + getWin()?.webContents.send("command-progress", { + commandId, + lines, + done, + ...extra, + }) + } + + return { + line(formatted) { + appendLog?.(formatted) + buf.push(formatted) + if (buf.length >= MAX_BATCH) post(false) + else if (!timer) timer = setTimeout(() => post(false), FLUSH_MS) + }, + done(finalLine, extra) { + appendLog?.(finalLine) + buf.push(finalLine) + post(true, { message: finalLine, ...extra }) + }, + } +} + export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: Map scheduleProviderUpdateCheck: () => void @@ -549,7 +609,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { const wsId = args.workspaceId ?? args.source const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(wsId), wsId) - const win = deps.getMainWindow() + const sink = createLogSink(deps.getMainWindow, cmdId, (line) => + logStore.appendLog(logPath, line), + ) let signalledDone = false // Kill any existing tunnel process for this workspace before starting a new one @@ -562,45 +624,29 @@ export function registerIpcHandlers(deps: IpcDependencies): { const child = await cli.runStreaming( cliArgs, (line) => { + if (signalledDone) return const formatted = formatLogLine(line) - if (!signalledDone && line.includes('"outcome":"success"')) { - logStore.appendLog(logPath, formatted) + if (line.includes('"outcome":"success"')) { signalledDone = true // Track this as a tunnel process (it stays alive for the tunnel) tunnelProcesses.set(wsId, child) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: formatted, - done: true, - }) + sink.done(formatted) + void logStore.closeLog(logPath) return } - if (!signalledDone) { - logStore.appendLog(logPath, formatted) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: formatted, - done: false, - }) - } + sink.line(formatted) }, (code) => { if (tunnelProcesses.get(wsId) === child) { tunnelProcesses.delete(wsId) } if (signalledDone) return - const exitMsg = formatLogLine( - `Exit code: ${code}`, - code === 0 ? "INFO" : "ERROR", + sink.done( + formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - logStore.appendLog(logPath, exitMsg) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: exitMsg, - done: true, - }) + void logStore.closeLog(logPath) }, wsId, ) @@ -616,33 +662,21 @@ export function registerIpcHandlers(deps: IpcDependencies): { await quiesceWorkspace(args.workspaceId) const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const win = deps.getMainWindow() + const sink = createLogSink(deps.getMainWindow, cmdId, (line) => + logStore.appendLog(logPath, line), + ) const cliArgs = ["workspace", "stop", args.workspaceId] if (args.debug) cliArgs.push("--debug") cli.runStreaming( cliArgs, - (line) => { - const formatted = formatLogLine(line) - logStore.appendLog(logPath, formatted) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: formatted, - done: false, - }) - }, + (line) => sink.line(formatLogLine(line)), (code) => { - const exitMsg = formatLogLine( - `Exit code: ${code}`, - code === 0 ? "INFO" : "ERROR", + sink.done( + formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - logStore.appendLog(logPath, exitMsg) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: exitMsg, - done: true, - }) + void logStore.closeLog(logPath) }, args.workspaceId, ) @@ -663,7 +697,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { await quiesceWorkspace(args.workspaceId) const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const win = deps.getMainWindow() + const sink = createLogSink(deps.getMainWindow, cmdId, (line) => + logStore.appendLog(logPath, line), + ) const cliArgs = ["workspace", "delete", args.workspaceId] if (args.debug) cliArgs.push("--debug") @@ -671,26 +707,12 @@ export function registerIpcHandlers(deps: IpcDependencies): { cli.runStreaming( cliArgs, - (line) => { - const formatted = formatLogLine(line) - logStore.appendLog(logPath, formatted) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: formatted, - done: false, - }) - }, + (line) => sink.line(formatLogLine(line)), (code) => { - const exitMsg = formatLogLine( - `Exit code: ${code}`, - code === 0 ? "INFO" : "ERROR", + sink.done( + formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - logStore.appendLog(logPath, exitMsg) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: exitMsg, - done: true, - }) + void logStore.closeLog(logPath) }, args.workspaceId, ) @@ -705,33 +727,21 @@ export function registerIpcHandlers(deps: IpcDependencies): { trackEvent("workspace_rebuild") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const win = deps.getMainWindow() + const sink = createLogSink(deps.getMainWindow, cmdId, (line) => + logStore.appendLog(logPath, line), + ) const cliArgs = ["workspace", "up", args.workspaceId, "--recreate"] if (args.debug) cliArgs.push("--debug") cli.runStreaming( cliArgs, - (line) => { - const formatted = formatLogLine(line) - logStore.appendLog(logPath, formatted) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: formatted, - done: false, - }) - }, + (line) => sink.line(formatLogLine(line)), (code) => { - const exitMsg = formatLogLine( - `Exit code: ${code}`, - code === 0 ? "INFO" : "ERROR", + sink.done( + formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - logStore.appendLog(logPath, exitMsg) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: exitMsg, - done: true, - }) + void logStore.closeLog(logPath) }, args.workspaceId, ) @@ -746,33 +756,21 @@ export function registerIpcHandlers(deps: IpcDependencies): { trackEvent("workspace_reset") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const win = deps.getMainWindow() + const sink = createLogSink(deps.getMainWindow, cmdId, (line) => + logStore.appendLog(logPath, line), + ) const cliArgs = ["workspace", "up", args.workspaceId, "--reset"] if (args.debug) cliArgs.push("--debug") cli.runStreaming( cliArgs, - (line) => { - const formatted = formatLogLine(line) - logStore.appendLog(logPath, formatted) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: formatted, - done: false, - }) - }, + (line) => sink.line(formatLogLine(line)), (code) => { - const exitMsg = formatLogLine( - `Exit code: ${code}`, - code === 0 ? "INFO" : "ERROR", + sink.done( + formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - logStore.appendLog(logPath, exitMsg) - win?.webContents.send("command-progress", { - commandId: cmdId, - message: exitMsg, - done: true, - }) + void logStore.closeLog(logPath) }, args.workspaceId, ) diff --git a/desktop/src/main/log-store.ts b/desktop/src/main/log-store.ts index 1e018d30b..ef0cb4148 100644 --- a/desktop/src/main/log-store.ts +++ b/desktop/src/main/log-store.ts @@ -1,11 +1,12 @@ import { - appendFileSync, + createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, + type WriteStream, writeFileSync, } from "node:fs" import { basename, join } from "node:path" @@ -52,6 +53,11 @@ let counter = 0 export class LogStore { constructor(private logsDir: string) {} + // Append-mode write streams keyed by log path. Buffered async writes keep + // per-line disk I/O off the main event loop; synchronous appends here would + // stall the whole process under a high-volume command. + private streams = new Map() + private workspaceLogDir(context: string, workspaceId: string): string { return join(this.logsDir, "workspaces", context, workspaceId) } @@ -68,14 +74,29 @@ export class LogStore { } appendLog(logPath: string, line: string): void { - try { - appendFileSync(logPath, `${line}\n`) - } catch (err) { - // Defensive: the workspace dir under the desktop logs root is owned by - // this process, but a manual `rm -rf` of the logs tree (or any other - // out-of-band removal) shouldn't crash the main process. - if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err + let stream = this.streams.get(logPath) + if (!stream) { + stream = createWriteStream(logPath, { flags: "a" }) + stream.on("error", (err) => { + // A manual `rm -rf` of the logs tree (or any out-of-band removal) can + // make late writes fail; don't crash the main process over it. + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + console.error(`log write failed for ${logPath}:`, err) + } + }) + this.streams.set(logPath, stream) } + stream.write(`${line}\n`) + } + + // Flushes and closes the append stream for a log once its command finishes, + // so file handles don't leak across many commands. Resolves after the buffered + // data has been flushed to disk, so a subsequent read sees the full log. + closeLog(logPath: string): Promise { + const stream = this.streams.get(logPath) + if (!stream) return Promise.resolve() + this.streams.delete(logPath) + return new Promise((resolve) => stream.end(resolve)) } readLogByPath(logPath: string): string { diff --git a/desktop/src/renderer/src/lib/components/log/LogTable.svelte b/desktop/src/renderer/src/lib/components/log/LogTable.svelte index 2166fe3b8..6ab7f4df8 100644 --- a/desktop/src/renderer/src/lib/components/log/LogTable.svelte +++ b/desktop/src/renderer/src/lib/components/log/LogTable.svelte @@ -7,64 +7,43 @@ let { lines, class: className = "", maxHeightClass = "max-h-96", - rowHeight = 28, - overscan = 12, follow = false, + maxLines = 5000, }: { lines: string[] class?: string maxHeightClass?: string - rowHeight?: number - overscan?: number follow?: boolean + /** Hard cap on rendered rows; older lines are dropped from the view. */ + maxLines?: number } = $props() let viewport = $state(null) -let scrollTop = $state(0) -let viewportHeight = $state(0) let pinnedToBottom = $state(true) -let total = $derived(lines.length) -let totalHeight = $derived(total * rowHeight) +// Bound the number of DOM rows regardless of how many lines are passed in, so a +// huge saved log can't create hundreds of thousands of nodes. Offscreen rows +// are skipped by the browser via content-visibility; this cap only guards the +// DOM node count. +let hiddenCount = $derived(Math.max(0, lines.length - maxLines)) +let visible = $derived(hiddenCount > 0 ? lines.slice(-maxLines) : lines) -let startIndex = $derived( - Math.max(0, Math.floor(scrollTop / rowHeight) - overscan), -) -let endIndex = $derived( - Math.min( - total, - Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan, - ), -) - -let visible = $derived( - lines.slice(startIndex, endIndex).map((raw, i) => ({ - index: startIndex + i, - line: parseLogLine(raw), - })), -) +function levelRowClass(level: string | undefined): string { + if (level === "fatal" || level === "error") return "bg-destructive/5" + if (level === "warn") return "bg-amber-500/5" + return "" +} function onScroll() { if (!viewport) return - scrollTop = viewport.scrollTop const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight - pinnedToBottom = distanceFromBottom <= rowHeight + pinnedToBottom = distanceFromBottom <= 8 } -$effect(() => { - if (!viewport) return - const observer = new ResizeObserver(() => { - if (viewport) viewportHeight = viewport.clientHeight - }) - observer.observe(viewport) - viewportHeight = viewport.clientHeight - return () => observer.disconnect() -}) - $effect(() => { // Follow the tail as new lines arrive, but only while the user is at the bottom. - void total + void lines.length if (follow && pinnedToBottom && viewport) { viewport.scrollTop = viewport.scrollHeight } @@ -75,50 +54,52 @@ $effect(() => { bind:this={viewport} onscroll={onScroll} role="table" - aria-rowcount={total} + aria-rowcount={lines.length} class={cn("relative overflow-auto rounded-md border", maxHeightClass, className)} >
-
Time
-
Level
-
Message
+
Time
+
Level
+
Message
-
- {#each visible as row (row.index)} -
-
{row.line.time}
-
- {#if row.line.level} - - {row.line.level} - - {/if} -
-
{row.line.message}
-
- {/each} -
+ {#if hiddenCount > 0} +
+ {hiddenCount.toLocaleString()} earlier {hiddenCount === 1 ? "line" : "lines"} hidden — open the full log to see everything +
+ {/if} + + {#each visible as raw, i (i)} + {@const line = parseLogLine(raw)} +
+ {line.time} + + {#if line.level} + + {line.level} + + {/if} + + {line.message} +
+ {/each} diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index fd9b2f9ab..34da3e380 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -426,16 +426,25 @@ onDestroy(() => { } }) +// Cap the in-memory live buffer so a noisy build can't grow it without bound. +// Trim to TRIM_TARGET only once the cap is exceeded (see WorkspaceDetailPage). +const MAX_LOG_LINES = 5000 +const TRIM_TARGET = 4000 + function flushLines() { flushHandle = null if (pendingLines.length === 0) return outputLines.push(...pendingLines) pendingLines.length = 0 + if (outputLines.length > MAX_LOG_LINES) { + outputLines.splice(0, outputLines.length - TRIM_TARGET) + } } function handleProgress(progress: CommandProgress, wsId: string | undefined) { - if (progress.message) { - pendingLines.push(progress.message) + const incoming = progress.lines ?? (progress.message ? [progress.message] : []) + if (incoming.length > 0) { + pendingLines.push(...incoming) if (flushHandle === null) { flushHandle = requestAnimationFrame(flushLines) } diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 64e9c8b73..7567243aa 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -123,7 +123,17 @@ export interface Context { export interface CommandProgress { commandId: string - message: string + /** + * Batched output lines. High-volume workspace commands coalesce many lines + * into one event to avoid flooding the IPC channel. When present, prefer this + * over `message`. + */ + lines?: string[] + /** + * Single output line. Used by low-volume streams (e.g. provider init) and as + * the summary line on the final `done: true` event (for success detection). + */ + message?: string /** Optional log level; populated when the underlying stderr line was JSON. */ level?: "info" | "warn" | "error" done: boolean diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 95f48bfc0..6c436008c 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -158,11 +158,21 @@ let filteredIdes = $derived( ), ) +// Cap the in-memory live buffer so a noisy long-running command can't grow it +// without bound. The full log is always persisted to disk (Log Files section). +// Trim down to TRIM_TARGET only once the cap is exceeded, so steady-state +// appends don't reshuffle the array (and re-render the table) on every batch. +const MAX_LOG_LINES = 5000 +const TRIM_TARGET = 4000 + function flushLines() { flushHandle = null if (pendingLines.length === 0) return outputLines.push(...pendingLines) pendingLines.length = 0 + if (outputLines.length > MAX_LOG_LINES) { + outputLines.splice(0, outputLines.length - TRIM_TARGET) + } } async function copyToClipboard(text: string) { @@ -178,8 +188,9 @@ onMount(async () => { try { unlisten = await onCommandProgress((progress) => { if (commandId && progress.commandId === commandId) { - if (progress.message) { - pendingLines.push(progress.message) + const incoming = progress.lines ?? (progress.message ? [progress.message] : []) + if (incoming.length > 0) { + pendingLines.push(...incoming) if (flushHandle === null) { flushHandle = requestAnimationFrame(flushLines) } From 4b2b20a6fe58cbccb81cc119363c89362b55c530 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 18 Jul 2026 11:21:44 -0500 Subject: [PATCH 2/4] fix(desktop): address log review feedback - LogTable: use minmax(0,1fr) for the message column plus min-w-0 and wrap-anywhere so long unbroken tokens shrink and wrap instead of forcing horizontal overflow. - LogTable: offset ARIA row positions for the header and hidden rows (aria-rowcount + 1; data rows start at hiddenCount + 2). - ipc/log-store: flush the log file (await closeLog) before emitting the done event, so a post-done read can't observe partial output. --- desktop/src/main/ipc.ts | 66 ++++++++++++------- .../src/lib/components/log/LogTable.svelte | 11 ++-- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 9ec753de5..4e8de5a58 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -86,11 +86,14 @@ function formatLogLine(line: string, level: "INFO" | "ERROR" = "INFO"): string { interface ProgressSink { /** Buffer a streamed line; flushes on a timer or when the batch fills. */ line(formatted: string): void - /** Emit the final line and mark the command done, flushing immediately. */ + /** + * Emit the final line and mark the command done. Awaits the log flush first + * so any post-`done` read of the log file sees the complete output. + */ done( finalLine: string, extra?: { level?: "info" | "warn" | "error"; cliError?: CLIError }, - ): void + ): Promise } /** @@ -103,6 +106,7 @@ function createLogSink( getWin: () => BrowserWindow | null, commandId: string, appendLog?: (line: string) => void, + flush?: () => Promise, ): ProgressSink { const FLUSH_MS = 64 const MAX_BATCH = 250 @@ -135,9 +139,12 @@ function createLogSink( if (buf.length >= MAX_BATCH) post(false) else if (!timer) timer = setTimeout(() => post(false), FLUSH_MS) }, - done(finalLine, extra) { + async done(finalLine, extra) { appendLog?.(finalLine) buf.push(finalLine) + // Flush the log file before signalling completion so a post-done read + // (e.g. the detail page reloading its log list) can't see partial output. + await flush?.() post(true, { message: finalLine, ...extra }) }, } @@ -609,8 +616,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { const wsId = args.workspaceId ?? args.source const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(wsId), wsId) - const sink = createLogSink(deps.getMainWindow, cmdId, (line) => - logStore.appendLog(logPath, line), + const sink = createLogSink( + deps.getMainWindow, + cmdId, + (line) => logStore.appendLog(logPath, line), + () => logStore.closeLog(logPath), ) let signalledDone = false @@ -631,8 +641,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { signalledDone = true // Track this as a tunnel process (it stays alive for the tunnel) tunnelProcesses.set(wsId, child) - sink.done(formatted) - void logStore.closeLog(logPath) + void sink.done(formatted) return } @@ -643,10 +652,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses.delete(wsId) } if (signalledDone) return - sink.done( + void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - void logStore.closeLog(logPath) }, wsId, ) @@ -662,8 +670,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { await quiesceWorkspace(args.workspaceId) const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const sink = createLogSink(deps.getMainWindow, cmdId, (line) => - logStore.appendLog(logPath, line), + const sink = createLogSink( + deps.getMainWindow, + cmdId, + (line) => logStore.appendLog(logPath, line), + () => logStore.closeLog(logPath), ) const cliArgs = ["workspace", "stop", args.workspaceId] @@ -673,10 +684,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cliArgs, (line) => sink.line(formatLogLine(line)), (code) => { - sink.done( + void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - void logStore.closeLog(logPath) }, args.workspaceId, ) @@ -697,8 +707,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { await quiesceWorkspace(args.workspaceId) const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const sink = createLogSink(deps.getMainWindow, cmdId, (line) => - logStore.appendLog(logPath, line), + const sink = createLogSink( + deps.getMainWindow, + cmdId, + (line) => logStore.appendLog(logPath, line), + () => logStore.closeLog(logPath), ) const cliArgs = ["workspace", "delete", args.workspaceId] @@ -709,10 +722,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cliArgs, (line) => sink.line(formatLogLine(line)), (code) => { - sink.done( + void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - void logStore.closeLog(logPath) }, args.workspaceId, ) @@ -727,8 +739,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { trackEvent("workspace_rebuild") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const sink = createLogSink(deps.getMainWindow, cmdId, (line) => - logStore.appendLog(logPath, line), + const sink = createLogSink( + deps.getMainWindow, + cmdId, + (line) => logStore.appendLog(logPath, line), + () => logStore.closeLog(logPath), ) const cliArgs = ["workspace", "up", args.workspaceId, "--recreate"] @@ -738,10 +753,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cliArgs, (line) => sink.line(formatLogLine(line)), (code) => { - sink.done( + void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - void logStore.closeLog(logPath) }, args.workspaceId, ) @@ -756,8 +770,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { trackEvent("workspace_reset") const cmdId = crypto.randomUUID() const logPath = logStore.createLogFile(state.workspaceContext(args.workspaceId), args.workspaceId) - const sink = createLogSink(deps.getMainWindow, cmdId, (line) => - logStore.appendLog(logPath, line), + const sink = createLogSink( + deps.getMainWindow, + cmdId, + (line) => logStore.appendLog(logPath, line), + () => logStore.closeLog(logPath), ) const cliArgs = ["workspace", "up", args.workspaceId, "--reset"] @@ -767,10 +784,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cliArgs, (line) => sink.line(formatLogLine(line)), (code) => { - sink.done( + void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), ) - void logStore.closeLog(logPath) }, args.workspaceId, ) diff --git a/desktop/src/renderer/src/lib/components/log/LogTable.svelte b/desktop/src/renderer/src/lib/components/log/LogTable.svelte index 6ab7f4df8..8a80f3665 100644 --- a/desktop/src/renderer/src/lib/components/log/LogTable.svelte +++ b/desktop/src/renderer/src/lib/components/log/LogTable.svelte @@ -54,12 +54,13 @@ $effect(() => { bind:this={viewport} onscroll={onScroll} role="table" - aria-rowcount={lines.length} + aria-rowcount={lines.length + 1} class={cn("relative overflow-auto rounded-md border", maxHeightClass, className)} >
Time
Level
@@ -76,9 +77,9 @@ $effect(() => { {@const line = parseLogLine(raw)}
@@ -99,7 +100,7 @@ $effect(() => { {/if} - {line.message} + {line.message}
{/each}
From 8b0d64fa0ec9b9ea4ce358bf850b62f7a0f38a81 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 18 Jul 2026 11:27:59 -0500 Subject: [PATCH 3/4] perf(desktop): honor log write-stream backpressure appendLog now returns the write result and LogStore exposes onDrain. runStreaming pauses the child's stdout/stderr when onLine returns a promise (the consumer applying backpressure) and resumes on drain, so a command emitting logs faster than disk can't grow the write buffer without bound. Verified with a 50k-line burst: no deadlock, UI stays responsive. --- desktop/src/main/cli.ts | 24 +++++++++++++++++++++--- desktop/src/main/ipc.ts | 31 ++++++++++++++++++++++--------- desktop/src/main/log-store.ts | 17 +++++++++++++++-- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/desktop/src/main/cli.ts b/desktop/src/main/cli.ts index c57d8e648..5749ce7ba 100644 --- a/desktop/src/main/cli.ts +++ b/desktop/src/main/cli.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from "node:child_process" import { execFile as execFileCb, spawn } from "node:child_process" import { createInterface } from "node:readline" +import type { Readable } from "node:stream" import { promisify } from "node:util" import type { CLIError, CliLogLine } from "../shared/cli-error.js" import { getAnalyticsDistinctId } from "./analytics.js" @@ -8,6 +9,21 @@ import { getAnalyticsDistinctId } from "./analytics.js" const execFile = promisify(execFileCb) const MAX_CONCURRENT = 50 +// When onLine returns a promise (the consumer is applying backpressure, e.g. a +// saturated log write stream), pause the source until it resolves so a fast +// producer can't outrun the consumer. A counter guards overlapping pauses. +function backpressureController(source: Readable): (r: void | Promise) => void { + let pending = 0 + return (result) => { + if (!result || typeof (result as Promise).then !== "function") return + pending++ + if (pending === 1) source.pause() + void (result as Promise).finally(() => { + if (--pending === 0) source.resume() + }) + } +} + export interface StreamLine { /** Raw line text from the CLI (already ANSI-stripped upstream is not assumed). */ raw: string @@ -190,7 +206,7 @@ export class CliRunner { line: string, stream: "stdout" | "stderr", meta?: StreamLine, - ) => void, + ) => void | Promise, onExit: (code: number, cliError?: CLIError) => void, workspaceId?: string, ): Promise { @@ -214,11 +230,13 @@ export class CliRunner { let lastCliError: CLIError | undefined if (child.stdout) { + const applyBackpressure = backpressureController(child.stdout) const rl = createInterface({ input: child.stdout }) - rl.on("line", (line) => onLine(line, "stdout")) + rl.on("line", (line) => applyBackpressure(onLine(line, "stdout"))) } if (child.stderr) { + const applyBackpressure = backpressureController(child.stderr) const rl = createInterface({ input: child.stderr }) rl.on("line", (line) => { const parsed = parseStderrLine(line) @@ -231,7 +249,7 @@ export class CliRunner { cliError: parsed?.cliError, level: normalizeLevel(parsed?.level), } - onLine(line, "stderr", meta) + applyBackpressure(onLine(line, "stderr", meta)) }) } diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 4e8de5a58..0c62165b6 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -84,8 +84,12 @@ function formatLogLine(line: string, level: "INFO" | "ERROR" = "INFO"): string { } interface ProgressSink { - /** Buffer a streamed line; flushes on a timer or when the batch fills. */ - line(formatted: string): void + /** + * Buffer a streamed line; flushes on a timer or when the batch fills. Returns + * false when the underlying log write stream is saturated, so the caller can + * apply backpressure to the source. + */ + line(formatted: string): boolean /** * Emit the final line and mark the command done. Awaits the log flush first * so any post-`done` read of the log file sees the complete output. @@ -105,7 +109,7 @@ interface ProgressSink { function createLogSink( getWin: () => BrowserWindow | null, commandId: string, - appendLog?: (line: string) => void, + appendLog?: (line: string) => boolean, flush?: () => Promise, ): ProgressSink { const FLUSH_MS = 64 @@ -134,10 +138,11 @@ function createLogSink( return { line(formatted) { - appendLog?.(formatted) + const ok = appendLog?.(formatted) ?? true buf.push(formatted) if (buf.length >= MAX_BATCH) post(false) else if (!timer) timer = setTimeout(() => post(false), FLUSH_MS) + return ok }, async done(finalLine, extra) { appendLog?.(finalLine) @@ -645,7 +650,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { return } - sink.line(formatted) + if (!sink.line(formatted)) return logStore.onDrain(logPath) }, (code) => { if (tunnelProcesses.get(wsId) === child) { @@ -682,7 +687,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cli.runStreaming( cliArgs, - (line) => sink.line(formatLogLine(line)), + (line) => { + if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) + }, (code) => { void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), @@ -720,7 +727,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cli.runStreaming( cliArgs, - (line) => sink.line(formatLogLine(line)), + (line) => { + if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) + }, (code) => { void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), @@ -751,7 +760,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cli.runStreaming( cliArgs, - (line) => sink.line(formatLogLine(line)), + (line) => { + if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) + }, (code) => { void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), @@ -782,7 +793,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { cli.runStreaming( cliArgs, - (line) => sink.line(formatLogLine(line)), + (line) => { + if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) + }, (code) => { void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), diff --git a/desktop/src/main/log-store.ts b/desktop/src/main/log-store.ts index ef0cb4148..f7ba3c33b 100644 --- a/desktop/src/main/log-store.ts +++ b/desktop/src/main/log-store.ts @@ -73,7 +73,11 @@ export class LogStore { return filePath } - appendLog(logPath: string, line: string): void { + // Returns false when the write stream's buffer is full (per Node's + // `Writable.write` contract). Callers should stop feeding lines and wait for + // `onDrain` before continuing, so a producer faster than disk can't grow the + // buffer without bound. + appendLog(logPath: string, line: string): boolean { let stream = this.streams.get(logPath) if (!stream) { stream = createWriteStream(logPath, { flags: "a" }) @@ -86,7 +90,16 @@ export class LogStore { }) this.streams.set(logPath, stream) } - stream.write(`${line}\n`) + return stream.write(`${line}\n`) + } + + // Resolves once the write stream has drained after a saturating `appendLog` + // (or immediately if the stream is gone). Lets the caller apply backpressure + // to the upstream reader. + onDrain(logPath: string): Promise { + const stream = this.streams.get(logPath) + if (!stream) return Promise.resolve() + return new Promise((resolve) => stream.once("drain", resolve)) } // Flushes and closes the append stream for a log once its command finishes, From 91ccf6cb438dfad4c09ee76490bc8f0860592d82 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sat, 18 Jul 2026 11:56:13 -0500 Subject: [PATCH 4/4] chore(desktop): drop explanatory comments from log-perf changes --- desktop/src/main/cli.ts | 3 --- desktop/src/main/ipc.ts | 17 ----------------- desktop/src/main/log-store.ts | 15 --------------- .../src/lib/components/log/LogTable.svelte | 5 ----- .../components/workspace/WorkspaceWizard.svelte | 2 -- desktop/src/renderer/src/lib/types/index.ts | 9 --------- .../src/pages/WorkspaceDetailPage.svelte | 4 ---- 7 files changed, 55 deletions(-) diff --git a/desktop/src/main/cli.ts b/desktop/src/main/cli.ts index 5749ce7ba..76fd4fb3c 100644 --- a/desktop/src/main/cli.ts +++ b/desktop/src/main/cli.ts @@ -9,9 +9,6 @@ import { getAnalyticsDistinctId } from "./analytics.js" const execFile = promisify(execFileCb) const MAX_CONCURRENT = 50 -// When onLine returns a promise (the consumer is applying backpressure, e.g. a -// saturated log write stream), pause the source until it resolves so a fast -// producer can't outrun the consumer. A counter guards overlapping pauses. function backpressureController(source: Readable): (r: void | Promise) => void { let pending = 0 return (result) => { diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 0c62165b6..90ad010d1 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -84,28 +84,13 @@ function formatLogLine(line: string, level: "INFO" | "ERROR" = "INFO"): string { } interface ProgressSink { - /** - * Buffer a streamed line; flushes on a timer or when the batch fills. Returns - * false when the underlying log write stream is saturated, so the caller can - * apply backpressure to the source. - */ line(formatted: string): boolean - /** - * Emit the final line and mark the command done. Awaits the log flush first - * so any post-`done` read of the log file sees the complete output. - */ done( finalLine: string, extra?: { level?: "info" | "warn" | "error"; cliError?: CLIError }, ): Promise } -/** - * Coalesces streamed log lines into batched `command-progress` events so a - * high-volume command doesn't emit one IPC message per line (which floods the - * channel and stalls both processes). Lines flush on a short timer or once the - * batch fills, whichever comes first. - */ function createLogSink( getWin: () => BrowserWindow | null, commandId: string, @@ -147,8 +132,6 @@ function createLogSink( async done(finalLine, extra) { appendLog?.(finalLine) buf.push(finalLine) - // Flush the log file before signalling completion so a post-done read - // (e.g. the detail page reloading its log list) can't see partial output. await flush?.() post(true, { message: finalLine, ...extra }) }, diff --git a/desktop/src/main/log-store.ts b/desktop/src/main/log-store.ts index f7ba3c33b..a73d44e24 100644 --- a/desktop/src/main/log-store.ts +++ b/desktop/src/main/log-store.ts @@ -53,9 +53,6 @@ let counter = 0 export class LogStore { constructor(private logsDir: string) {} - // Append-mode write streams keyed by log path. Buffered async writes keep - // per-line disk I/O off the main event loop; synchronous appends here would - // stall the whole process under a high-volume command. private streams = new Map() private workspaceLogDir(context: string, workspaceId: string): string { @@ -73,17 +70,11 @@ export class LogStore { return filePath } - // Returns false when the write stream's buffer is full (per Node's - // `Writable.write` contract). Callers should stop feeding lines and wait for - // `onDrain` before continuing, so a producer faster than disk can't grow the - // buffer without bound. appendLog(logPath: string, line: string): boolean { let stream = this.streams.get(logPath) if (!stream) { stream = createWriteStream(logPath, { flags: "a" }) stream.on("error", (err) => { - // A manual `rm -rf` of the logs tree (or any out-of-band removal) can - // make late writes fail; don't crash the main process over it. if ((err as NodeJS.ErrnoException).code !== "ENOENT") { console.error(`log write failed for ${logPath}:`, err) } @@ -93,18 +84,12 @@ export class LogStore { return stream.write(`${line}\n`) } - // Resolves once the write stream has drained after a saturating `appendLog` - // (or immediately if the stream is gone). Lets the caller apply backpressure - // to the upstream reader. onDrain(logPath: string): Promise { const stream = this.streams.get(logPath) if (!stream) return Promise.resolve() return new Promise((resolve) => stream.once("drain", resolve)) } - // Flushes and closes the append stream for a log once its command finishes, - // so file handles don't leak across many commands. Resolves after the buffered - // data has been flushed to disk, so a subsequent read sees the full log. closeLog(logPath: string): Promise { const stream = this.streams.get(logPath) if (!stream) return Promise.resolve() diff --git a/desktop/src/renderer/src/lib/components/log/LogTable.svelte b/desktop/src/renderer/src/lib/components/log/LogTable.svelte index 8a80f3665..52c90ef7b 100644 --- a/desktop/src/renderer/src/lib/components/log/LogTable.svelte +++ b/desktop/src/renderer/src/lib/components/log/LogTable.svelte @@ -14,17 +14,12 @@ let { class?: string maxHeightClass?: string follow?: boolean - /** Hard cap on rendered rows; older lines are dropped from the view. */ maxLines?: number } = $props() let viewport = $state(null) let pinnedToBottom = $state(true) -// Bound the number of DOM rows regardless of how many lines are passed in, so a -// huge saved log can't create hundreds of thousands of nodes. Offscreen rows -// are skipped by the browser via content-visibility; this cap only guards the -// DOM node count. let hiddenCount = $derived(Math.max(0, lines.length - maxLines)) let visible = $derived(hiddenCount > 0 ? lines.slice(-maxLines) : lines) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 34da3e380..0f056e28b 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -426,8 +426,6 @@ onDestroy(() => { } }) -// Cap the in-memory live buffer so a noisy build can't grow it without bound. -// Trim to TRIM_TARGET only once the cap is exceeded (see WorkspaceDetailPage). const MAX_LOG_LINES = 5000 const TRIM_TARGET = 4000 diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 7567243aa..d700fcaa8 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -123,16 +123,7 @@ export interface Context { export interface CommandProgress { commandId: string - /** - * Batched output lines. High-volume workspace commands coalesce many lines - * into one event to avoid flooding the IPC channel. When present, prefer this - * over `message`. - */ lines?: string[] - /** - * Single output line. Used by low-volume streams (e.g. provider init) and as - * the summary line on the final `done: true` event (for success detection). - */ message?: string /** Optional log level; populated when the underlying stderr line was JSON. */ level?: "info" | "warn" | "error" diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 6c436008c..6d3634931 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -158,10 +158,6 @@ let filteredIdes = $derived( ), ) -// Cap the in-memory live buffer so a noisy long-running command can't grow it -// without bound. The full log is always persisted to disk (Log Files section). -// Trim down to TRIM_TARGET only once the cap is exceeded, so steady-state -// appends don't reshuffle the array (and re-render the table) on every batch. const MAX_LOG_LINES = 5000 const TRIM_TARGET = 4000