From 9c94bcb01fbf6050702affa6c0b24fcf4dc4dc02 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:46:04 +0000 Subject: [PATCH 1/6] perf(host-service): share one login-shell env spawn across callers getStrictShellEnvironment had no shared in-flight promise, so callers that missed the cache at the same time each spawned their own `zsh -i -l -c`. It runs for every `gh` call and for PR git diffs. A real host-service log had 7,462 spawns (about 150 per hour), more than the 60 s TTL alone allows, and the rc-file stderr logged on each spawn was about 40% of the log. - Concurrent callers now await one spawn; a failed spawn is not cached. - TTL is 15 minutes instead of 60 s. - rc-file stderr is logged once per process. --- .../src/terminal/clean-shell-env.test.ts | 47 ++++++++++++++ .../src/terminal/clean-shell-env.ts | 64 ++++++++++++++----- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/packages/host-service/src/terminal/clean-shell-env.test.ts b/packages/host-service/src/terminal/clean-shell-env.test.ts index d47a617d71a..09158117122 100644 --- a/packages/host-service/src/terminal/clean-shell-env.test.ts +++ b/packages/host-service/src/terminal/clean-shell-env.test.ts @@ -13,6 +13,7 @@ import { augmentPathForMacOS, buildMinimalEnv, clearStrictShellEnvCache, + createShellEnvCache, getStrictShellEnvironment, parseEnvOutput, } from "./clean-shell-env.ts"; @@ -92,6 +93,52 @@ describe("augmentPathForMacOS", () => { }); }); +describe("createShellEnvCache", () => { + test("concurrent callers share one spawn and each get a copy", async () => { + let spawns = 0; + const cache = createShellEnvCache(async () => { + spawns++; + return { HOME: "/x" }; + }); + const [a, b] = await Promise.all([cache.get(), cache.get()]); + expect(spawns).toBe(1); + expect(a).toEqual({ HOME: "/x" }); + expect(a).not.toBe(b); + }); + + test("does not cache a failed spawn", async () => { + let spawns = 0; + const cache = createShellEnvCache(async () => { + spawns++; + if (spawns === 1) throw new Error("boom"); + return { HOME: "/x" }; + }); + await expect(cache.get()).rejects.toThrow("boom"); + expect(await cache.get()).toEqual({ HOME: "/x" }); + expect(spawns).toBe(2); + }); + + test("spawns again after the TTL", async () => { + let spawns = 0; + let time = 0; + const cache = createShellEnvCache( + async () => { + spawns++; + return { HOME: "/x" }; + }, + () => time, + 1_000, + ); + await cache.get(); + time = 999; + await cache.get(); + expect(spawns).toBe(1); + time = 1_000; + await cache.get(); + expect(spawns).toBe(2); + }); +}); + const DELIMITER = "__SUPERSET_SHELL_ENV__"; function withDelimiters(body: string): string { diff --git a/packages/host-service/src/terminal/clean-shell-env.ts b/packages/host-service/src/terminal/clean-shell-env.ts index a677707e357..0c1b702a52e 100644 --- a/packages/host-service/src/terminal/clean-shell-env.ts +++ b/packages/host-service/src/terminal/clean-shell-env.ts @@ -5,7 +5,7 @@ import { getManagedEnv } from "../runtime/sandbox-managed-env/sandbox-managed-en import { resolveConfiguredShell } from "./user-shell.ts"; const SHELL_ENV_TIMEOUT_MS = 8_000; -const CACHE_TTL_MS = 60_000; +const CACHE_TTL_MS = 15 * 60_000; const DELIMITER = "__SUPERSET_SHELL_ENV__"; const DIAGNOSTIC_OUTPUT_LIMIT = 200; @@ -130,6 +130,8 @@ function truncateForDiagnostics(value: string): string { return `${trimmed.slice(0, DIAGNOSTIC_OUTPUT_LIMIT)}…`; } +let loggedRcStderr = false; + function spawnCleanShellEnv(): Promise> { return new Promise((resolve, reject) => { const shell = resolveShellForEnv(); @@ -243,7 +245,8 @@ function spawnCleanShellEnv(): Promise> { settle(() => { const stdout = Buffer.concat(stdoutBuffers).toString("utf8"); const stderr = Buffer.concat(stderrBuffers).toString("utf8").trim(); - if (stderr) { + if (stderr && !loggedRcStderr) { + loggedRcStderr = true; console.debug("[terminal-clean-shell-env] stderr:", stderr); } @@ -277,20 +280,52 @@ function spawnCleanShellEnv(): Promise> { }); } -let cache: Record | null = null; -let cacheTime = 0; +export function createShellEnvCache( + resolveEnv: () => Promise>, + now: () => number = Date.now, + ttlMs = CACHE_TTL_MS, +) { + let cache: Record | null = null; + let cacheTime = 0; + let inFlight: Promise> | null = null; + + async function get(): Promise> { + if (cache && now() - cacheTime < ttlMs) { + return { ...cache }; + } + if (!inFlight) { + const promise = resolveEnv().then( + (env) => { + if (inFlight === promise) { + cache = env; + cacheTime = now(); + inFlight = null; + } + return env; + }, + (error) => { + if (inFlight === promise) inFlight = null; + throw error; + }, + ); + inFlight = promise; + } + return { ...(await inFlight) }; + } -export async function getStrictShellEnvironment(): Promise< - Record -> { - if (cache && Date.now() - cacheTime < CACHE_TTL_MS) { - return { ...cache }; + function clear(): void { + cache = null; + cacheTime = 0; + inFlight = null; } - const env = await spawnCleanShellEnv(); - cache = env; - cacheTime = Date.now(); - return { ...cache }; + return { get, clear }; +} + +const shellEnvCache = createShellEnvCache(spawnCleanShellEnv); + +export function getStrictShellEnvironment(): Promise> { + return shellEnvCache.get(); } /** @@ -311,6 +346,5 @@ export async function getToolEnvironment(): Promise> { } export function clearStrictShellEnvCache(): void { - cache = null; - cacheTime = 0; + shellEnvCache.clear(); } From 611c90d433e097dda85f4f4cd9a31ee53157f448 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:46:14 +0000 Subject: [PATCH 2/6] perf(desktop): throttle the React Query persister to 20 s The IndexedDB persister used the default throttleTime of 1000 ms. The persist client saves on every query-cache event, also for queries that are not persisted, and dozens of 2.5-30 s polls fire two events each. So the whole persisted cache went through superjson.stringify and into IndexedDB about once a second. This was a large part of renderer idle CPU (median 20-98% per build, main thread 42% busy in JS). The data is only a cache, so losing up to 20 s of it on a crash is fine. --- .../providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx b/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx index a7882ad6adc..8558f88561d 100644 --- a/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx +++ b/apps/desktop/src/renderer/providers/ElectronTRPCProvider/ElectronTRPCProvider.tsx @@ -83,6 +83,7 @@ const persister = createAsyncStoragePersister({ }, }, key: "superset-rq-cache", + throttleTime: 20_000, // Query data carries Dates (tRPC's superjson transformer); plain JSON would // restore them as strings. serialize: superjson.stringify, From b92172472390cde562bad13d70254704154d56dc Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:46:23 +0000 Subject: [PATCH 3/6] perf(desktop): cap xterm image addon memory per terminal ImageAddon was created with no options, so each terminal could keep up to 128 MB of image storage (storageLimit default) and decode images up to 4096x4096 pixels. Up to 12 parked terminals stay alive at once. Set storageLimit to 16 MB and pixelLimit to 2048x2048. --- apps/desktop/src/renderer/lib/terminal/terminal-addons.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index 0d33f8ad28f..6b1eed7f851 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -42,7 +42,9 @@ export function loadAddons( terminal.loadAddon(unicode11); terminal.unicode.activeVersion = "11"; - terminal.loadAddon(new ImageAddon()); + terminal.loadAddon( + new ImageAddon({ storageLimit: 16, pixelLimit: 2048 * 2048 }), + ); const searchAddon = new SearchAddon(); terminal.loadAddon(searchAddon); From 945f8352a71b0a2a88c0d0c6ec0378bf022908fd Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:46:30 +0000 Subject: [PATCH 4/6] perf: use synchronous = NORMAL with WAL on app SQLite databases WAL was on but synchronous stayed at FULL, so every commit did an fsync. The chat journal appends one transaction per streamed event, about 10 per second per streaming tool. NORMAL is the documented safe pairing for WAL: the database stays consistent, and only the last commits can be lost on power loss. Applies to the chat-runtime chat DB, the host-service DB, and the desktop local DB. --- apps/desktop/src/main/lib/local-db/index.ts | 1 + packages/chat-runtime/src/db/createChatDb/createChatDb.ts | 1 + packages/host-service/src/db/db.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/desktop/src/main/lib/local-db/index.ts b/apps/desktop/src/main/lib/local-db/index.ts index 296c0daa938..6a9041b7422 100644 --- a/apps/desktop/src/main/lib/local-db/index.ts +++ b/apps/desktop/src/main/lib/local-db/index.ts @@ -82,6 +82,7 @@ try { // Best-effort; directory permissions should still protect the DB. } sqlite.pragma("journal_mode = WAL"); +sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("foreign_keys = OFF"); sqlite.function("uuid_v4", () => randomUUID()); sqlite.function("uuid_is_valid_v4", (value: unknown) => { diff --git a/packages/chat-runtime/src/db/createChatDb/createChatDb.ts b/packages/chat-runtime/src/db/createChatDb/createChatDb.ts index 6f621bf6e08..d5edbbc93b4 100644 --- a/packages/chat-runtime/src/db/createChatDb/createChatDb.ts +++ b/packages/chat-runtime/src/db/createChatDb/createChatDb.ts @@ -26,6 +26,7 @@ export function createChatDb(options: ChatDbOptions) { const sqlite = new Database(join(options.dataDir, CHAT_DB_FILENAME)); sqlite.pragma("journal_mode = WAL"); + sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("busy_timeout = 5000"); sqlite.pragma("foreign_keys = ON"); diff --git a/packages/host-service/src/db/db.ts b/packages/host-service/src/db/db.ts index 6fa21976f73..5378f479532 100644 --- a/packages/host-service/src/db/db.ts +++ b/packages/host-service/src/db/db.ts @@ -34,6 +34,7 @@ export function createDb(dbPath: string, migrationsFolder: string) { const sqlite = new Database(dbPath); sqlite.pragma("journal_mode = WAL"); + sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("busy_timeout = 5000"); const db = drizzle(sqlite, { schema }); From 85ead7bcd6cdf918479ecfa4b3ec4c78cb01f80a Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:47:20 +0000 Subject: [PATCH 5/6] perf(desktop): keep source maps and type declarations out of app.asar The shipped app.asar is 1.13 GB. Of that, 418 MB is *.map files (148 MB from the app's own dist/, the rest from node_modules) and 27 MB is *.d.ts/*.d.mts. Every canary update is a full 390-430 MB download. Sentry still gets the maps: the vite plugin uploads them during `compile:app`, before electron-builder packages the app. Nothing reads .map files at runtime (no source-map-support, no --enable-source-maps). --- apps/desktop/electron-builder.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index a050b62aeec..77cbb168300 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -101,6 +101,10 @@ const config: Configuration = { "!**/.DS_Store", "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", "!**/node_modules/@openai/codex*/**", + "!**/*.map", + "!**/*.d.ts", + "!**/*.d.mts", + "!**/*.d.cts", ], // Rebuild native modules for Electron's Node.js version From 65e68b7f63bbd89e21252fdb995a897cb92c4cc2 Mon Sep 17 00:00:00 2001 From: Harshith Mullapudi <17528887+harshithmullapudi@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:47:45 +0000 Subject: [PATCH 6/6] perf(desktop): log auto-updater progress only at 25/50/75/100% electron-updater emits download-progress per chunk, and each event was logged. Progress lines were 4,367 of the 6,571 lines (66%) in a real main.log. Now each download logs once as it crosses 25%, 50%, 75% and 100%. The state resets on update-available, when a new download starts. --- apps/desktop/src/main/lib/auto-updater.ts | 12 +++++++--- .../lib/download-progress-milestones.test.ts | 20 +++++++++++++++++ .../main/lib/download-progress-milestones.ts | 22 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/lib/download-progress-milestones.test.ts create mode 100644 apps/desktop/src/main/lib/download-progress-milestones.ts diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index c895085c1b1..3cb32085af4 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -9,6 +9,7 @@ import { autoUpdater, type UpdateCheckResult } from "electron-updater"; import { env } from "main/env.main"; import { setSkipQuitConfirmation } from "main/index"; import { appState } from "main/lib/app-state"; +import { createDownloadProgressMilestones } from "main/lib/download-progress-milestones"; import { isEnvironmentUpdateError, isUpstreamServerError, @@ -508,7 +509,10 @@ export function setupAutoUpdater(): void { emitStatus(AUTO_UPDATE_STATUS.CHECKING); }); + const progressLogMilestones = createDownloadProgressMilestones(); + autoUpdater.on("update-available", (info) => { + progressLogMilestones.reset(); log.info( `[auto-updater] Update available: ${app.getVersion()} → ${info.version} (files: ${info.files?.map((f: { url: string }) => f.url).join(", ")})`, ); @@ -526,9 +530,11 @@ export function setupAutoUpdater(): void { const PROGRESS_EMIT_INTERVAL_MS = 500; let lastProgressEmitAt = 0; autoUpdater.on("download-progress", (progress) => { - log.info( - `[auto-updater] Download progress: ${progress.percent.toFixed(1)}% (${(progress.transferred / 1024 / 1024).toFixed(1)}MB / ${(progress.total / 1024 / 1024).toFixed(1)}MB)`, - ); + if (progressLogMilestones.shouldLog(progress.percent)) { + log.info( + `[auto-updater] Download progress: ${progress.percent.toFixed(1)}% (${(progress.transferred / 1024 / 1024).toFixed(1)}MB / ${(progress.total / 1024 / 1024).toFixed(1)}MB)`, + ); + } const now = Date.now(); if (now - lastProgressEmitAt < PROGRESS_EMIT_INTERVAL_MS) return; lastProgressEmitAt = now; diff --git a/apps/desktop/src/main/lib/download-progress-milestones.test.ts b/apps/desktop/src/main/lib/download-progress-milestones.test.ts new file mode 100644 index 00000000000..7ea908b226c --- /dev/null +++ b/apps/desktop/src/main/lib/download-progress-milestones.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { createDownloadProgressMilestones } from "./download-progress-milestones"; + +describe("createDownloadProgressMilestones", () => { + test("logs once per crossed milestone", () => { + const milestones = createDownloadProgressMilestones(); + const logged = [1, 24.9, 25, 30, 49, 80, 99, 100, 100].filter((percent) => + milestones.shouldLog(percent), + ); + expect(logged).toEqual([25, 80, 100]); + }); + + test("starts again after reset", () => { + const milestones = createDownloadProgressMilestones(); + expect(milestones.shouldLog(100)).toBe(true); + expect(milestones.shouldLog(100)).toBe(false); + milestones.reset(); + expect(milestones.shouldLog(26)).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/lib/download-progress-milestones.ts b/apps/desktop/src/main/lib/download-progress-milestones.ts new file mode 100644 index 00000000000..f37a6b668ae --- /dev/null +++ b/apps/desktop/src/main/lib/download-progress-milestones.ts @@ -0,0 +1,22 @@ +const MILESTONES = [25, 50, 75, 100]; + +export function createDownloadProgressMilestones() { + let nextIndex = 0; + + return { + shouldLog(percent: number): boolean { + let crossed = false; + while ( + nextIndex < MILESTONES.length && + percent >= MILESTONES[nextIndex] + ) { + nextIndex++; + crossed = true; + } + return crossed; + }, + reset(): void { + nextIndex = 0; + }, + }; +}