From 52b3ab911f4904d36b306e5fa3e616c9fc9726a9 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Fri, 14 Aug 2026 13:17:30 +0700 Subject: [PATCH] fix(app): stop dev-run console errors and log flattening Five issues seen in a `bun dev:desktop` run: - createRefCountMap ran create() in the first caller's scope, so a shared directory context's internal cleanups (the dir SDK event unsubscribe) fired when the first view closed while other holders still used it, and ownerless callers (event handlers, async continuations) registered cleanups Solid warns can never run, pinning items forever. Items now get their own root under the map's creating scope, disposed when the last pinning caller releases them; ownerless callers borrow without pinning. - The SSE event stream logged an error on the first failed attempt, which races a server being stopped or restarted (sandbox reset, sidecar relaunch). It now logs only after 3 consecutive failures, with the error normalized to a readable string. - The DEV duplicate-command-id warning fired on router-transition overlap (draft->session registers command.palette/input.focus twice for a moment). It now re-checks after 1s and only reports persistent duplicates. - spyRendererConsole receives Chromium's flattened console-message string, so renderer console.* with object args reached the terminal and exported debug bundles as "[object Object]". A renderer console wrapper now serializes structured arguments (Error-aware, circular-safe, truncated). - Two benign Chromium interventions (ResizeObserver loop, autofocus blocked) are dropped from the terminal transport; file logs keep them. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 +++++ DIVERGENCES.md | 5 ++ packages/app/src/context/command.tsx | 25 ++++++++-- packages/app/src/context/server-sdk.tsx | 35 ++++++++------ packages/app/src/utils/refcount.test.ts | 47 ++++++++++++++++++- packages/app/src/utils/refcount.ts | 43 ++++++++++------- packages/desktop/src/main/logging.ts | 19 ++++++++ .../src/renderer/console-format.test.ts | 36 ++++++++++++++ .../desktop/src/renderer/console-format.ts | 41 ++++++++++++++++ packages/desktop/src/renderer/index.tsx | 3 ++ 10 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 packages/desktop/src/renderer/console-format.test.ts create mode 100644 packages/desktop/src/renderer/console-format.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a7caad43..6191097eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ file is the human-readable summary of what changed and why. new name with the next update. On macOS an existing install keeps its `CoDev Code.app` bundle name on disk (updates replace the app in place); fresh installs get `CoDev.app`. +- **A project's live updates no longer die with the first view that opened + it** — the event subscription for a directory was owned by whichever tab or + view touched that directory first, so closing that one could silently stop + live updates (new messages, session changes) for every other view of the + same directory until they were all closed and reopened. The subscription now + lives as long as anything is using it. +- **Debug logs now capture what the app actually reported** — renderer log + lines with structured data reached the dev terminal and the exported debug + bundle as `[object Object]`; they now carry the data. A connection blip + while a server restarts (a sandbox reset, the app relaunching its server) no + longer logs an error — only a connection that keeps failing does — and two + harmless browser notices no longer show up as errors in the dev terminal. - **New sessions show up in Recents right away** — starting a session left the sidebar's Recents list unchanged; the session only appeared once something made that list reload, which is why switching between Code and Work and back diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 121ff9c782..29e04177ee 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -188,6 +188,11 @@ from the squash message first, or use a plain merge. | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev" (Dev/Beta variants too) | Desktop app display name ("Code" dropped once the app carried a Work section beside Code). | Keep our names. | | `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev…"; artifactName → `codev-code-desktop-…` (deliberately kept when the product name dropped "Code" — the update feed's `latest*.yml` and the hand-upload flow reference these artifact names, and nothing user-visible shows them); protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId is `ai.codev.desktop{,.beta,.dev}` and `rpm.packageName` is `codev`/`codev-beta`/`codev-dev` — see the desktop identity row for the full rename and what it costs. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). | +| `packages/app/src/utils/refcount.ts` (+ `.test.ts`) | `createRefCountMap` creates each item in its own root owned by the scope that created the map (disposed when the last pinning caller releases it), and callers without a reactive owner borrow the item without pinning it. | Upstream ran `create` in the *first caller's* scope, so a shared item's internal cleanups (e.g. the dir SDK context's event unsubscribe in `server-sdk.tsx`) fired when that first view closed while other holders still used the item — live updates for the directory silently died. Ownerless callers (event handlers, async continuations: `notification.tsx` session lookup, `layout.tsx` openSession) also registered cleanups Solid warns can never run and pinned items forever. | Keep ours; take upstream's surrounding changes. The two added tests ("ownerless caller borrows without pinning", "item's internal cleanups survive its first caller") pin the semantics. | +| `packages/app/src/context/server-sdk.tsx` | Event-stream failures log only after 3 consecutive failed attempts (`logStreamIssue`), with the error normalized to `name: message`; any received event resets the count. | The first failed attempt usually races a server being stopped or restarted (sandbox reset, sidecar relaunch): the stream dies before the renderer learns the server is going away and the context is disposed before a real retry, so logging attempt one produced a spurious `[global-sdk] event stream error` for normal lifecycle. A genuinely unreachable server still logs within about a second (250 ms reconnect delay). | Keep the threshold and shared `logStreamIssue`; take upstream's surrounding stream changes. | +| `packages/app/src/context/command.tsx` | The DEV duplicate-command-id warning fires only for duplicates still present 1 s later (`warnIfPersistentDuplicate`); runtime keep-first behavior unchanged. | Router transitions keep the outgoing page mounted while the incoming one registers, so ids both pages provide (`command.palette`, `input.focus`) are briefly duplicated on every draft→session navigation; only a duplicate that survives the transition is a registration bug. | Keep ours; take upstream's surrounding command changes. | +| `packages/desktop/src/main/logging.ts` | Added `initChromiumNoiseFilter`: an electron-log hook dropping two benign Chromium intervention messages (ResizeObserver loop, autofocus blocked) from the console transport for renderer-spied messages; file logs keep them. | The `spyRendererConsole` pipe surfaces Chromium interventions the app can neither cause nor fix as red terminal lines during `bun dev:desktop`. | Keep the hook; take upstream's surrounding logging changes. | +| `packages/desktop/src/renderer/index.tsx` | Calls `installConsoleFormatting()` (fork-only `./console-format`) before anything else logs. | `spyRendererConsole` receives Chromium's flattened console-message string, so any renderer `console.*` with an object argument reached the terminal and the exported debug logs as `[object Object]`; the wrapper serializes structured arguments (Error-aware, circular-safe, truncated) at the source. | Keep the import and call; take upstream's surrounding entry changes. | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index f88033bd7e..ae5a59b792 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -263,6 +263,26 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex suspendCount: 0, }) const warnedDuplicates = new Set() + const pendingDuplicates = new Set() + // Route transitions keep the outgoing page mounted while the incoming one + // registers, so ids both pages provide (command.palette, input.focus) are + // briefly registered twice. Only a duplicate that survives the transition + // is a registration bug worth reporting. + const warnIfPersistentDuplicate = (id: string) => { + if (warnedDuplicates.has(id) || pendingDuplicates.has(id)) return + pendingDuplicates.add(id) + setTimeout(() => { + pendingDuplicates.delete(id) + if (warnedDuplicates.has(id)) return + const count = store.registrations.reduce( + (total, reg) => total + reg.options().filter((opt) => opt.id === id).length, + 0, + ) + if (count < 2) return + warnedDuplicates.add(id) + console.warn(`[command] duplicate command id "${id}" registered; keeping first entry`) + }, 1000) + } type CommandCatalog = Record const [catalog, setCatalog, _, catalogReady] = persisted( @@ -284,10 +304,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex for (const reg of store.registrations) { for (const opt of reg.options()) { if (seen.has(opt.id)) { - if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { - warnedDuplicates.add(opt.id) - console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`) - } + if (import.meta.env.DEV) warnIfPersistentDuplicate(opt.id) continue } seen.add(opt.id) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7..f9e1f4c177 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -136,7 +136,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) } + // A server being stopped or restarted (a sandbox reset, the sidecar + // relaunching) kills the stream before the renderer learns the server is + // going away, so the first failed attempts are usually that race, not a + // fault. Only reconnection that keeps failing is worth an error. + const STREAM_ERROR_LOG_THRESHOLD = 3 + let streamFailures = 0 let streamErrorLogged = false + const logStreamIssue = (message: string, error: unknown) => { + streamFailures++ + if (streamErrorLogged || streamFailures < STREAM_ERROR_LOG_THRESHOLD) return + streamErrorLogged = true + console.error(message, { + url: server.http.url, + fetch: eventFetch ? "platform" : "webview", + attempts: streamFailures, + error: error instanceof Error ? `${error.name}: ${error.message}` : error, + }) + } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) let attempt: AbortController | undefined let run: Promise | undefined @@ -178,19 +195,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS signal: attempt.signal, onSseError: (error) => { if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) + logStreamIssue("[global-sdk] event stream error", error) }, }) let yielded = Date.now() resetHeartbeat() for await (const event of events.stream) { resetHeartbeat() + streamFailures = 0 streamErrorLogged = false if (event.payload.type !== "sync") { const directory = event.directory ?? "global" @@ -203,13 +215,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS await wait(0) } } catch (error) { - if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { - streamErrorLogged = true - console.error("[global-sdk] event stream failed", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) + if (!isStreamClosed(error, attempt?.signal)) { + logStreamIssue("[global-sdk] event stream failed", error) } } finally { abort.signal.removeEventListener("abort", onAbort) diff --git a/packages/app/src/utils/refcount.test.ts b/packages/app/src/utils/refcount.test.ts index f48d80db35..cc39f6958b 100644 --- a/packages/app/src/utils/refcount.test.ts +++ b/packages/app/src/utils/refcount.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { createRoot } from "solid-js" +import { createRoot, onCleanup } from "solid-js" import { createRefCountMap } from "./refcount" import { pathKey } from "./path-key" @@ -46,4 +46,49 @@ describe("createRefCountMap", () => { second() expect(removed).toEqual(["C:/repo"]) }) + + test("an ownerless caller borrows without pinning", () => { + const removed: string[] = [] + const map = createRefCountMap( + (key) => key, + (key) => removed.push(key), + ) + + // No reactive owner: an event handler or async continuation. It must get + // the item but must not hold a pin nobody can ever release. + expect(map("/project")).toBe("/project") + + const dispose = createRoot((dispose) => { + map("/project") + return dispose + }) + dispose() + expect(removed).toEqual(["/project"]) + }) + + test("an item's internal cleanups survive its first caller", () => { + const cleaned: string[] = [] + const map = createRoot(() => + createRefCountMap((key) => { + onCleanup(() => cleaned.push(key)) + return key + }), + ) + + const first = createRoot((dispose) => { + map("/project") + return dispose + }) + const second = createRoot((dispose) => { + map("/project") + return dispose + }) + + // The first caller created the item; its disposal must not tear the shared + // item down while the second caller still holds it. + first() + expect(cleaned).toEqual([]) + second() + expect(cleaned).toEqual(["/project"]) + }) }) diff --git a/packages/app/src/utils/refcount.ts b/packages/app/src/utils/refcount.ts index c284491cce..15bfd5fb8f 100644 --- a/packages/app/src/utils/refcount.ts +++ b/packages/app/src/utils/refcount.ts @@ -1,32 +1,43 @@ -import { onCleanup } from "solid-js" +import { createRoot, getOwner, onCleanup } from "solid-js" export function createRefCountMap( create: (key: string) => T, remove?: (key: string) => void, identity: (key: string) => string = (key) => key, ) { - const items = new Map() + const owner = getOwner() + const items = new Map void }>() const refCounts = new Map() return (key: string) => { const id = identity(key) - onCleanup(() => { - refCounts.set(id, (refCounts.get(id) ?? 0) - 1) - if (refCounts.get(id) === 0) { - remove?.(id) - items.delete(id) + // Callers without a reactive owner (event handlers, async continuations) + // borrow the item without pinning it: they have no scope whose disposal + // could release the pin, so counting them would leak the item forever and + // make Solid warn that the cleanup can never run. + if (getOwner()) { + refCounts.set(id, (refCounts.get(id) ?? 0) + 1) + onCleanup(() => { + const next = (refCounts.get(id) ?? 0) - 1 + refCounts.set(id, next) + if (next > 0) return refCounts.delete(id) - } - }) + const item = items.get(id) + items.delete(id) + remove?.(id) + item?.dispose() + }) + } const cached = items.get(id) - if (cached) { - refCounts.set(id, (refCounts.get(id) ?? 0) + 1) - return cached - } - const item = create(key) + if (cached) return cached.value + // The item is shared by every caller, so its internal cleanups must not + // land in the first caller's scope (they would run on that caller's + // disposal while other holders still use the item). Each item gets its own + // root under the scope that created the map, disposed when the last + // pinning caller releases it. + const item = createRoot((dispose) => ({ value: create(key), dispose }), owner ?? undefined) items.set(id, item) - refCounts.set(id, 1) - return item + return item.value } } diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 7e89d2b694..d73dbbf526 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -29,6 +29,7 @@ export function initLogging() { ) log.initialize({ preload: false, spyRendererConsole: true }) initConsoleTransport() + initChromiumNoiseFilter() cleanup() return (logger = log) } @@ -203,3 +204,21 @@ function initConsoleTransport() { function isBrokenPipe(err: unknown) { return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE" } + +// Chromium interventions the renderer can neither cause nor fix; they read as +// failures in the terminal, so keep them out of it while the log files stay +// complete for debugging. +const CHROMIUM_NOISE = [ + "ResizeObserver loop completed with undelivered notifications", + "Autofocus processing was blocked", +] + +function initChromiumNoiseFilter() { + log.hooks.push((message, _transport, transportName) => { + if (transportName !== "console") return message + if (message.variables?.processType !== "renderer") return message + const text = message.data[0] + if (typeof text === "string" && CHROMIUM_NOISE.some((noise) => text.includes(noise))) return false + return message + }) +} diff --git a/packages/desktop/src/renderer/console-format.test.ts b/packages/desktop/src/renderer/console-format.test.ts new file mode 100644 index 0000000000..234c5f5b64 --- /dev/null +++ b/packages/desktop/src/renderer/console-format.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test" +import { formatConsoleArg } from "./console-format" + +test("passes primitives through untouched", () => { + expect(formatConsoleArg("message")).toBe("message") + expect(formatConsoleArg(42)).toBe(42) + expect(formatConsoleArg(false)).toBe(false) + expect(formatConsoleArg(null)).toBe(null) + expect(formatConsoleArg(undefined)).toBe(undefined) +}) + +test("serializes plain objects instead of [object Object]", () => { + expect(formatConsoleArg({ pending: true, url: "http://127.0.0.1:1" })).toBe( + '{"pending":true,"url":"http://127.0.0.1:1"}', + ) +}) + +test("keeps an Error's name, message and stack", () => { + const formatted = formatConsoleArg({ error: new TypeError("fetch failed") }) as string + const parsed = JSON.parse(formatted) + expect(parsed.error.name).toBe("TypeError") + expect(parsed.error.message).toBe("fetch failed") + expect(typeof parsed.error.stack).toBe("string") +}) + +test("survives circular references", () => { + const value: Record = { name: "loop" } + value.self = value + expect(formatConsoleArg(value)).toBe('{"name":"loop","self":"[circular]"}') +}) + +test("truncates oversized payloads", () => { + const formatted = formatConsoleArg({ blob: "x".repeat(20_000) }) as string + expect(formatted.length).toBeLessThan(9_000) + expect(formatted).toContain("chars)") +}) diff --git a/packages/desktop/src/renderer/console-format.ts b/packages/desktop/src/renderer/console-format.ts new file mode 100644 index 0000000000..781f36719d --- /dev/null +++ b/packages/desktop/src/renderer/console-format.ts @@ -0,0 +1,41 @@ +// The terminal and the exported log files receive renderer console output +// through Chromium's console-message event (electron-log's spyRendererConsole +// in src/main/logging.ts), which flattens every non-string argument to +// "[object Object]" before the main process ever sees it. Serialize structured +// arguments up front so the forwarded line carries the data; devtools shows +// the same JSON text. +const MAX_ARG_LENGTH = 8_192 + +export function installConsoleFormatting() { + for (const level of ["debug", "log", "info", "warn", "error"] as const) { + const original = console[level].bind(console) + console[level] = (...args: unknown[]) => original(...args.map(formatConsoleArg)) + } +} + +export function formatConsoleArg(arg: unknown) { + if (arg === null || arg === undefined) return arg + if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean") return arg + // A throwing getter or unserializable exotic object must not break logging. + try { + const json = JSON.stringify(arg, makeReplacer()) + if (json === undefined) return String(arg) + if (json.length > MAX_ARG_LENGTH) return `${json.slice(0, MAX_ARG_LENGTH)}… (${json.length} chars)` + return json + } catch { + return String(arg) + } +} + +function makeReplacer() { + const seen = new WeakSet() + return (_key: string, value: unknown) => { + if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack } + if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") return String(value) + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[circular]" + seen.add(value) + } + return value + } +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 8d65b82acf..fde029197f 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -22,6 +22,7 @@ import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidj import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" +import { installConsoleFormatting } from "./console-format" import { initI18n, t } from "./i18n" import { initializationData, initializationReady } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" @@ -32,6 +33,8 @@ import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" +installConsoleFormatting() + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound"))