Skip to content

fix(memory): bound renderer caches and avoid WebKit scrollbar allocations - #1097

Open
kogekiplay wants to merge 9 commits into
spacering-net:mainfrom
kogekiplay:fix/bounded-highlight-memory
Open

kogekiplay wants to merge 9 commits into
spacering-net:mainfrom
kogekiplay:fix/bounded-highlight-memory

Conversation

@kogekiplay

@kogekiplay kogekiplay commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Following the maintainer's review, this PR now tracks the independent splits below. The original combined implementation should not be merged as a whole.

Batch PR Status
WebKit scrollbar allocations #1115 Ready for review; native footprint, geometry, inputs and full frontend validation completed
Tauri callback lifetime #1114 Ready for review; full frontend validation and 11,373 tests passed
Shared highlighting and Markdown overflow #1117 Ready for review; full frontend validation and 11,372 tests passed, including cancellation and 24-block real-Shiki regressions
Native workspace search #1116 Ready for review; query-owned discovery, independent scheduling, budgeted multi-root LRU and reusable disk snapshots implemented and tested

All four new branches are based directly on official main 5c03d7af865f54cf42a994327daf2c45ab4a946f, with independent dependency patch configuration. The search draft does not block the other batches.

The later HTML-preview cache change (f5a0ccc3) remains preserved on this original branch for a separate follow-up; it is not mixed into these four splits. No installed application was replaced or restarted for this split/validation work.
The search split has since been updated onto official main c340c76e34075da83602712e07352b543e6aa380 and completed in c4c095c8; its original draft implementation is superseded.

Original combined implementation and historical validation

Web Content can grow into multiple GiB while the live JavaScript heap remains small. Repeated scrollbar updates trigger retained native WebKit allocations in OverlayScrollbars' container-relative transforms; renderer-wide workspace listings and independent highlighting caches add further retained data.

This change addresses those paths together:

  • WebKit scrollbars: patch OverlayScrollbars 2.15.1 to use its existing scroll-event implementation and percentage-based handle positioning. Disabling ScrollTimeline alone is insufficient because the CSS fallback also uses cqw/cqh. Scope the workaround to WebKit, including iOS browser shells, across all four package entrypoints; Chromium retains the native path. Preserve dragging, track clicks, RTL, resizing and normal animations.

  • Workspace search: discover and rank files in one shared native index with estimated retained/build budgets of 128/192 MiB, a five-minute TTL and a 200-result response cap. Over-budget workspaces retain only the best matches during traversal, including late/deep exact matches. Failed-build markers keep their original expiry so repeated fallback searches do not prevent a later rebuild. Empty queries share worker admission, and the canvas open-files listing makes no empty-query request. Per-consumer generations coalesce obsolete requests and propagate cancellation through desktop and HTTP transports into queued and running searches. Consumer tracking is bounded; searches have a cooperative five-minute deadline and a 330-second transport timeout. Closing a dialog clears local results without invalidating a warm shared index. Mention groups publish independently as each source becomes ready, preserving selection when slower file results arrive.

  • Syntax highlighting: share one lazy Shiki engine and a 16 MiB / 128-entry LRU across Markdown and code views. Do not cache streaming prefixes, coalesce identical requests, bound cold work to 16 requests, and unsubscribe unmounted views. Mounted overflow subscribers remain readable and retry in bounded batches when capacity becomes available; canceled subscribers leave the retry list. Full-content keys avoid equal-length snippet collisions. Oversized code remains readable as plaintext.

  • Tauri event callbacks: retain the callback ID independently of the native event registry. Unsubscribe and rejected subscription creation explicitly release that callback even when the registration eval arrives late. Native unlisten still runs if the registry cleanup is missing or throws. A regression reproduces 100 retained callbacks after 100 disposed subscriptions before the fix and zero after it; coverage also includes rejected creation, independent subscribers and idempotent cleanup.

Native memory measurements on macOS 27.2 (26B123), using isolated system WKWebViews and synthetic content:

Case Observation
24 native scroll timelines with container-relative calc() transforms 293.4 → 2,323.8 MiB in a 40-second sampling span
Same timeline with a fixed-pixel endpoint Approximately 110–111 MiB over 90 seconds
Original OverlayScrollbars with 24 continuously scrolling regions 334.2 → 1,559.1 MiB in approximately 25 seconds; stopped at the footprint cap
Final patched library with 24 continuously scrolling regions Retained summaries through 29.2 minutes: 252.2 → 260.2 MiB; observed peak 276.3 MiB; the 30-minute probe completed normally

Measurement retention limitation: the completed one-shot probe was accidentally submitted as a launchd keepalive job; its automatic restart replaced the raw series after the 30-minute run exited with status 0. The table uses summaries retained before completion, with physical summaries through 29.2 minutes and a successful runtime health check at 29 min 50 s. It does not claim a complete preserved 30-minute trace. Both probe jobs have been stopped.

The patched run continuously checks that handles move, DOM count remains constant and no scrollbar ScrollTimeline animations exist. System WebKit geometry checks pass 16 combinations of axis, LTR/RTL, resize and scroll progress (maximum position error 0.234375 px); real WebKit input checks pass all eight drag/track-click cases. These results isolate the scrollbar allocation path. A follow-up build based on the official v0.42.1 tag, with all PR commits applied, was installed as 0.42.1+memory.3. The app bundle and live service version were verified. A 30-minute application observation retained all 120 physical-footprint samples and kept the same main/Web Content process identities throughout: Web Content started at 263.3 MiB, ended at 589.2 MiB, and peaked at 604.5 MiB in the periodic samples; macOS reported a lifetime peak of 661.0 MiB. The main process ended at 194.6 MiB. A live workspace search returned the exact deep match among two results in 0.249 s, transferring 241 bytes.

The application run did not reproduce the previous multi-GiB runaway, and memory repeatedly fell after temporary peaks. It does not establish zero growth: Web Content medians for minutes 10–15 / 15–20 / 20–25 / 25–30 were 557.0 / 548.8 / 579.0 / 587.9 MiB, and the fit after minute 10 was approximately +3.55 MiB/min under continued interactive use. Native WebKit allocated bytes were approximately 231.7 MiB at minute 15, 232.4 MiB at minute 27, and 239.5 MiB near minute 30. These results support a substantial improvement while leaving a smaller upward drift to distinguish from normal workload/caching. The full application trace is preserved locally; private app data and heap captures are not published.

The earlier 5.3-million-entry workspace probe retained approximately 116 MiB in the compact index. Warm filename searches took 0.17–0.54 seconds and transferred approximately 0.1–13 KB, versus approximately 925 MiB for the previous full renderer listing. Cold discovery still takes a full walk (64–104 seconds in those release probes). The existing ignore rules and authorized linked-root behavior are preserved in desktop and server modes.

Validation:

  • 764 frontend test files / 11,341 tests passed; full frontend lint, production TypeScript checking and static export passed.
  • Rust server library: 6,244 passed / 6 ignored; server binary: 2 passed. Desktop cargo check passed. Desktop Clippy (--all-targets --features test-utils) and server Clippy passed with -D warnings.
  • Frozen dependency installation applies the patch; native scrolling/input/geometry checks above pass.

Copilot review follow-up: 6a0f496a covers failed-build TTL, empty-query admission, obsolete traversal cancellation, progressive mention groups, local reset versus index refresh, and HTTP deadlines. d68c4849 covers retrying mounted highlighting subscribers after saturation. Tests exercise each reported condition, including out-of-order cancellation and keeping consumers independent.

Callback-lifetime follow-up validation on the official 0.42.1 base plus this PR: 768 frontend test files / 11,391 tests passed, including five callback-lifetime regressions; full frontend lint, production TypeScript checking and static export passed.

Two-hour passive application follow-up (November 9, 2026, UTC+08:00): a preserved 10:38:02–12:38:02 trace contains 481 samples, with a maximum gap of 16.5 seconds. The first 19.5 minutes used memory.3; the authorized callback-fix restart at 10:57:44 switched to 0.42.1+memory.4. The same new main/Web Content processes then remained alive for 100.25 minutes (402 samples): Web Content's sampled range was 265.3–516.5 MiB and its final value was 289.5 MiB; the native summary recorded a lifetime peak of 642.5 MiB. Its footprint rose during part of this segment, then fell from 452.0 to 298.6 MiB around 12:10 without restarting, and remained near 289.5 MiB for the last approximately 20 minutes. Native WebKit allocated bytes similarly fell from about 282.8 to 124.7 MiB before settling near 138.1 MiB. This did not reproduce the earlier multi-GiB runaway. The trace is an uncontrolled passive observation with no activity attribution, so it neither proves zero growth for every workload nor isolates the callback fix's causal effect. The complete trace and process-segment records are preserved locally; no private app data was uploaded.

Cross-round long-horizon review: the original renderer was followed by PID + process start identity. Earlier runs measured approximately memory.1 248 → 9,370 MiB in 30 minutes, memory.2 536 → 2,924 MiB over 98 minutes, and memory.3 around 263 → 589 MiB over 30 minutes. In the November 9 run, the same memory.4 renderer was 289.5 MiB at 12:38 and later showed repeated rising floors: 30-minute medians/minima progressed through roughly 968/944, 1,019/1,018, 1,106/897, 889/823, 893/882, and 1,291/964 MiB before the later memory.5/memory.6 restarts. These workload periods are uncontrolled and are not an A/B proof, but the same-process floor movement is why the investigation remains open. Short-term retractions and additional WebKit processes are explicitly separated from the original renderer. The overnight controller now emits long_horizon_process_floor and does not mark the issue stable just because the most recent 30-minute slope is negative.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved workspace-search timeouts, redundant traversals, and shared blocking can return empty results and stall searches and mention suggestions for over a minute.

7 open issues
What changed in this PR

Reduces Codeg’s renderer memory use by moving workspace search into shared native storage and consolidating syntax-highlighting caches.

Changes:

  • Adds budgeted native indexing, capped search results and full-traversal fallback.
  • Migrates mention, dialog and canvas searches to the bounded API in desktop and server modes.
  • Shares a lazy Shiki engine and bounded token cache, with regression coverage.
File Description
src/​lib/​code-highlighter.ts Adds shared highlighting, cache budgets and plaintext fallback.
src/​lib/​code-highlighter.test.ts Covers cache bounds, streaming, coalescing and subscriptions.
src/​lib/​api.ts Exposes workspace-search preparation and query APIs.
src/​hooks/​use-file-tree.ts Fetches bounded, debounced matches and rejects stale responses.
src/​hooks/​use-file-tree.test.ts Covers debounce, workspace changes and refresh behavior.
src/​components/​message/​completed-turn-streaming-mode.test.tsx Updates highlighting mocks to the shared service.
src/​components/​conversations/​search-command-dialog.tsx Uses query-driven search only while open.
src/​components/​chat/​composer/​use-reference-search.ts Fetches bounded native file matches for mentions.
src/​components/​chat/​composer/​use-reference-search.test.ts Covers native preparation and selective file fetching.
src/​components/​canvas/​add-node-menu.tsx Requests capped, files-only search results.
src/​components/​ai-elements/​streamdown-plugins.ts Loads the shared highlighting plugin lazily.
src/​components/​ai-elements/​streamdown-plugins.test.ts Updates shared-plugin loading mocks.
src/​components/​ai-elements/​reasoning.test.tsx Updates reasoning-view highlighting mocks.
src/​components/​ai-elements/​message.test.tsx Updates message-view highlighting mocks.
src/​components/​ai-elements/​code-block.tsx Replaces independent caches with shared highlighting subscriptions.
src-tauri/​src/​web/​router.rs Registers workspace-search HTTP routes.
src-tauri/​src/​web/​handlers/​folders.rs Adds search and preparation HTTP handlers.
src-tauri/​src/​lib.rs Registers desktop workspace-search commands.
src-tauri/​src/​commands/​workspace_file_search.rs Implements compact indexing, ranking, budgets and regression tests.
src-tauri/​src/​commands/​mod.rs Exposes the workspace-search module.
src-tauri/​src/​commands/​folders.rs Refactors traversal into reusable visitors with early termination.

Review effort: Max

Comment on lines +388 to +395
if fresh && !refresh {
if limit == 0 {
return Vec::new();
}
if let Some(index) = cache.as_ref().and_then(|c| c.index.as_ref()) {
return index.search(query, limit, files_only);
}
}
Comment on lines +448 to +449
if query.trim().is_empty() {
return tokio::task::spawn_blocking(move || {
.await
.map_err(|e| AppCommandError::task_execution_failed(e.to_string()));
}
let mut cache = CACHE.lock().await;
Comment on lines +404 to +405
const [files, sessions, commits] = await Promise.all([
filesPromise,
Comment thread src/hooks/use-file-tree.ts Outdated
const reset = useCallback(() => {
loadedForPathRef.current = null
generation.current += 1
refresh.current = true
Comment thread src/lib/api.ts Outdated
Comment on lines +5045 to +5049
return getTransport().call("search_workspace_files", {
path,
query,
...options,
})
Comment thread src/lib/code-highlighter.ts Outdated
Comment on lines +159 to +160
if (pending.size >= HIGHLIGHT_LIMITS.inFlight) {
return rawCodeTokens(options.code)
@kogekiplay kogekiplay changed the title fix(memory): bound workspace search and syntax highlighting caches fix(memory): bound renderer caches and avoid WebKit scrollbar allocations Nov 7, 2026
@xintaofei

Copy link
Copy Markdown
Collaborator

方案结论:可以拆分发布,而且建议这样推进。 共享高亮缓存、WebKit 内存修复、Tauri 回调清理都有独立价值。当前工作区搜索的调度与缓存问题,只需阻塞搜索这一批,无需让其他优化一起等待。

以下意见基于当前提交 4847f7c2ddfe138025813d570d619bec741b6c1b。

感谢这轮补充,查询取消、高亮重试和回调清理都在推进问题解决。建议将当前 PR 拆成下面四个独立 PR,各自验证、合并和发布;顺序表示建议优先级,没有必须依次发布的限制。

批次 包含的改动 发布前需要完成
1. WebKit 内存修复 OverlayScrollbars 事件回退、滚动条定位修复、依赖补丁和测试 验证干净安装时补丁生效、WebKit 滚动条位置及 LTR/RTL、Chromium 行为,并检查原生内存
2. Tauri 回调清理 Transport 订阅竞态清理、scoped listener 补丁和测试 修复测试类型错误,验证订阅失败、注册与取消竞态、正常解绑
3. 高亮缓存优化 共享 Shiki、缓存预算、流式前缀处理、请求合并和卸载清理 补齐 Markdown 在并发饱和后的重试,确保不会引入无界积压
4. 工作区搜索优化 原生索引、桌面及服务器 API、文件搜索和引用建议接入 调整调度与缓存策略,修复建议选中项和窗口焦点回归,完成前后端验证

前两批可以优先整理送审;第三批完成局部修复后可以独立推进。 第四批单独确认方案,不作为其他批次的前置条件。先发布前三批能够交付各自的内存改善;大工作区全量文件列表进入渲染进程的问题,则仍需第四批解决。

已确认的问题可以分别带入对应 PR,避免拆分后遗漏:

  1. 高亮批次:Markdown 在容量恢复后没有重试。

    code-highlighter.ts:258 在容量满时返回纯文本,现有重试只覆盖订阅接口,Streamdown 调用的 highlightCode 没有接入。真实 Shiki 复现中,24 个请求只有 16 个收到高亮回调,另外 8 个在队列清空后仍未重试。

    建议补上受控重试,覆盖超过并发上限、流式更新和卸载场景。这是高亮批次内部可以修复的问题,无需等待搜索改造。

  2. 搜索批次:后台预热阻塞其他工作区,发现结果缺乏复用。

    前台搜索和预热逻辑在遍历期间持有同一全局锁,预热未接入发起方的取消生命周期。关闭输入框后,预热仍可能继续,让其他小工作区甚至空查询等待。单工作区缓存还会使 A → B → A 的非空搜索构建三次;超预算工作区每次查询都重新遍历。

    建议保留原生索引方向,调整预热取消、前台查询调度和总预算下的跨工作区复用,并避免超预算场景每次输入都从头扫描。增量或磁盘索引只是可选实现,具体方案由作者选择。

    验收覆盖大工作区预热期间查询小工作区、关闭输入框、A/B 切换和超预算连续查询。这一项需要先确定方案。

  3. 搜索批次:渐进结果可能改变用户已选中的引用。

    suggestion-popup.tsx:211 保留选中行号,却会在新结果到达后自动切换分组。已复现:键盘选中第二条会话建议后,文件结果到达,Enter 插入第二个文件;文件只有一条时,Enter 无效。

    建议在键盘导航后保留当前分组,并维护有效的选中项,补上延迟结果到达时的键盘回归测试。

  4. 搜索批次:切回窗口会取消查询,却不重启。

    use-reference-search.ts:317 的 focus 处理会中止当前查询,相同查询不会自动重新执行,建议因此可能清空,直到用户再次输入。

    建议保留有效的在途查询,或在失效后重新执行当前查询,确保切回窗口后无需额外输入即可恢复结果。

  5. 测试类型错误随所属批次一起修复。

    Tauri 批次的 tauri-transport.test.ts:28 需要明确回调 mock 的函数类型;搜索批次的 use-file-tree.test.ts:13 需要替换不兼容 ES2020 的 .at(-1)。生产 TypeScript 检查已通过,失败的是包含测试文件的类型检查。

拆分时,请把每组实现、测试和依赖补丁配置一起带上;工作区搜索的前后端接口及调用方应保持配套。已有提交基本按模块划分,但共享的 pnpm-workspace.yaml 和锁文件需要按各批实际内容整理。

此前的 139 项聚焦前端测试、相关 ESLint 和生产 TypeScript 检查通过,上述问题由额外复现确认;没有把 GitHub CI 状态作为判断依据。拆分后,各 PR 仍需针对自己的最终内容验证。当前完整 PR 不建议整体合并,独立批次可以在各自问题解决、验证通过后先发布。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants