Skip to content

perf(desktop): sidebar resilience - timeout, debounce, result cache#4889

Closed
ttmouse wants to merge 13 commits into
esengine:main-v2from
ttmouse:pr/sidebar-resilience
Closed

perf(desktop): sidebar resilience - timeout, debounce, result cache#4889
ttmouse wants to merge 13 commits into
esengine:main-v2from
ttmouse:pr/sidebar-resilience

Conversation

@ttmouse

@ttmouse ttmouse commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

问题

PR #4858 解决了重复扫描问题,但在以下场景仍有性能瓶颈:

  • 云盘路径阻塞:Synology Drive / iCloud 路径的 os.ReadFile 可能无限阻塞,导致整个 UI 卡死
  • 重复构建:前端连续触发 emitProjectTreeChanged,每次都完整重建 ListProjectTree
  • goroutine 卡死:某个会话目录读取异常时 wg.Wait() 永不返回
  • 冷启动延迟loadTopicTitles / loadTopicCreatedAts 对 15+ 个项目顺序读取,最慢可达 ~5s
  • 话题遍历时反复重读文件topicTitleForTab 在遍历 611 个话题时为每个无标题话题重新读整个 JSON 文件

改动

共 7 个 commit,desktop/tabs.go + 少量辅助文件:

加载性能优化

优化 效果
项目话题文件并发加载 15 个项目从串行变成并行,最慢 ~200ms(云盘超时)决定总时间
禁止话题遍历时重新读文件 直接用已加载的 titleMaptopicTitleForTab 不再反复调 loadTopicTitles
磁盘缓存(stale-while-revalidate) 冷启动时先返回缓存树 → 瞬间展示侧边栏 → 后台 goroutine 重建
移除 200ms 防抖 emitProjectTreeChanged 立即通知前端,不再额外等待

防御性保护

保护 解决的问题
readFileWithTimeout (200ms) 防止云盘路径 os.ReadFile 无限阻塞
projectTreeResultCache (2s TTL, 双检锁) 短时间内多次调用直接返回缓存
listProjectTreeMu 串行化并发调用者
wg.Wait 5s 超时 + timedOut flag 防止 goroutine 卡死导致函数永不返回
goroutine panic recovery 隔离单个目录的 panic

其他改进

  • 单实例锁路径哈希com.reasonix.desktop.<binary-path-sha256[:8]>,dev 和 release 自动可多开
  • 配置目录合并desktopConfigDir 指向 ~/.reasonix/,与 sessions/projects 统一目录
  • 详细 slog 日志:每个阶段的耗时、缓存命中、迁移状态均可追踪

效果(15 个项目的用户实测)

场景 优化前 优化后
冷启动首次加载 ~5s ~300ms(首次)+ 后续瞬间(磁盘缓存)
重复请求(2s 内) 重复构建 内存缓存命中
云盘路径阻塞 无限挂起 200ms 超时自动降级

@github-actions github-actions Bot added v2 Go rewrite (1.x) — main-v2 branch, active development desktop Wails desktop app (desktop/**) labels Jun 20, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6b6f3554d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/tabs.go Outdated
Comment on lines +3920 to +3921
case <-time.After(5 * time.Second):
slog.Warn("ListProjectTree: wg.Wait timed out after 5s, proceeding with partial results", "elapsed", time.Since(buildStart).String())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop timed-out workers before reading shared maps

When a session dir takes more than 5s, this timeout path lets ListProjectTree continue while the goroutines above still hold references to sessionInfos, sessionTitles, and topicSummaries and can later call mergeSessionInfos. The rest of the function reads those maps without mergeMu, so the slow cloud/unmounted-dir case this is meant to handle can trigger a fatal concurrent map read/write or cache a partial tree that is mutated after return. Wait for isolated worker results or discard late results instead of sharing these maps after the timeout.

Useful? React with 👍 / 👎.

Comment thread desktop/tabs.go Outdated
Comment on lines +3541 to +3543
projectTreeDebouncer.fire(func() {
projectSessionCache.invalidate()
sessionDiskCacher.invalidate()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the result cache when emitting tree changes

In change paths such as RenameProject, topic creation, and runtime status updates, emitProjectTreeChanged now invalidates only projectSessionCache/sessionDiskCacher, while ListProjectTree returns projectTreeResultCache for up to 2s. If the tree was loaded just before the mutation, the debounced event consumer can call ListProjectTree and still receive stale nodes (old title/pin/status or a missing topic) until the TTL expires, despite an explicit change notification. Clear projectTreeResultCache/projectTreeCacheExpiry when emitting the change.

Useful? React with 👍 / 👎.

Comment thread desktop/tabs.go Outdated
Comment on lines 2674 to 2676
b, err := readFileWithTimeout(topicTitlesPath(workspaceRoot), topicFileReadTimeout)
if err != nil {
return m

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid overwriting metadata after read timeouts

When a topic metadata read takes longer than 200ms, loadTopicTitles now returns an empty map, but write paths such as setTopicTitleWithSource immediately save that map back after changing one topic. In the cloud/slow-volume case this timeout is meant to handle, renaming or creating a topic can therefore rewrite desktop-topic-titles.json with only the current entry and drop every other title; the same pattern applies to title sources and created-at metadata. Propagate timeout errors or avoid using lossy timed reads in read-modify-write paths.

Useful? React with 👍 / 👎.

Comment thread desktop/tabs.go Outdated
Comment on lines +2637 to +2639
go func() {
data, err := os.ReadFile(path)
ch <- result{data, err}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid leaking timed-out file-read goroutines

In the cloud-storage/unmounted-volume case this helper is meant to tolerate, returning on the timeout leaves the goroutine running os.ReadFile behind because that read is not cancelled. Since the topic metadata loaders are called for every project/global section and can be retried on later sidebar refreshes, a persistently hung metadata file can accumulate stuck goroutines and file I/O resources over time. Bound or suppress repeated timed-out reads instead of spawning an unbounded goroutine per attempt.

Useful? React with 👍 / 👎.

ttmouse added 7 commits June 20, 2026 17:54
sidebar resilience (ListProjectTree):
- readFileWithTimeout (200ms) for all file reads (topic titles, projects, sessions)
- debounceEmitter (200ms) for emitProjectTreeChanged
- projectTreeResultCache (2s TTL, double-check locking) for ListProjectTree
- wg.Wait 5s timeout + timedOut flag to prevent hung goroutines
- goroutine panic recovery per directory
- detailed slog logging at each stage (dirs, cache hit/miss, migration, topics)

single-instance:
- singleInstanceID now derives from binary path hash instead of fixed const
- different binary paths (dev vs release) get different locks automatically
- removed REASONIX_DEV env var bypass (no longer needed)
The 200ms debounce in emitProjectTreeChanged added a perceptible delay
on every sidebar-relevant action (creating a session, switching tabs,
renaming a topic, etc.). The 2s projectTreeResultCache + listProjectTreeMu
serialization already prevent redundant rebuilds more effectively than
debouncing the event itself — multiple back-to-back emits result in
a single cache hit after the first rebuild populates the cache.

Also removed the now-unused debounceEmitter type and projectTreeDebouncer
variable.
Previously ListProjectTree called loadTopicTitles and loadTopicCreatedAts
sequentially for each project. With 15+ projects including cloud-storage
paths (SynologyDrive, iCloud) where readFileWithTimeout hits its 200ms
timeout, the sequential loop multiplied worst-case latency by the number
of slow paths — the first cold build took ~5s.

Now all project topic files are loaded in parallel goroutines. The total
time is bounded by the single slowest project (200ms for a timeout), not
the sum of all slow projects.
desktopConfigDir now returns ~/.reasonix/ instead of
~/Library/Application Support/reasonix/, so all desktop config files
(desktop-projects.json, desktop-tabs.json, global/*) live in the same
directory as the rest of Reasonix state (sessions/, projects/, config.toml).

No migration needed — the app auto-creates missing files on next save.
Old files in ~/Library/Application Support/reasonix/ are left in place.
topicTitleForTab calls loadTopicTitles which re-reads the entire
JSON file for each topic without an explicit title. With 15 projects
and several on SynologyDrive cloud storage where readFileWithTimeout
hits its 200ms bound, repeated fallback calls add up to seconds.

The project processing loop already has all titles in titleMap from
the concurrent load phase. Use it directly and fall back to the
default title string instead of re-reading the file.
ListProjectTree now saves the built tree to a cache file on disk
(~/.reasonix/desktop-project-tree-cache.json). On the next cold
startup, it reads that cache and returns it immediately — the
sidebar renders instantly, no loading delay.

Rebuilds happen in a background goroutine triggered by either:
- A disk-cache hit on cold startup (stale-while-revalidate)
- emitProjectTreeChanged (user action → rebuild flag set)

Disk cache is invalidated through rebuildRequested atomic flag:
during normal use the flag forces an immediate full rebuild, while
on cold startup the flag is false so the cache is returned first.
@ttmouse ttmouse force-pushed the pr/sidebar-resilience branch from ee0fed6 to 206c7df Compare June 20, 2026 14:51
@github-actions github-actions Bot added the agent Core agent loop (internal/agent, internal/control) label Jun 20, 2026
@ttmouse ttmouse force-pushed the pr/sidebar-resilience branch from 0f92fe6 to 4363277 Compare June 20, 2026 14:53

@SivanCola SivanCola left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the performance work here. I think the direction is valuable, but I do not think this is safe to merge yet.

Blocking issues:

  • ListProjectTree now caches the fully rendered []ProjectNode globally for 2 seconds. That payload includes runtime fields (Open, Running, Status) and is not keyed by the active config/session roots. Calls inside the TTL can return stale project trees, stale runtime status, or even another isolated app/test environment's tree. This is already visible in CI: the desktop job fails in TestOpenProjectTabResolvesProjectSessionFromLegacyDir, and locally go test ./... under desktop/ also fails with the same cache-hit pattern. TestProjectTreeShowsBackgroundJobStatus can also fail because the cached tree does not reflect updated runtime state. Please either cache only the expensive disk-derived inputs and recompute runtime fields each call, or key/invalidate the cache on every state root/runtime-status change.

  • desktopConfigDir() now moves tabs/projects from the platform config dir to ~/.reasonix, but loadTabsFile and loadProjectsFile only read the new location. Existing users with desktop-tabs.json / desktop-projects.json under the old config dir will start with an empty tab/sidebar state and may overwrite the new location before the old data is migrated. This needs a compatibility read/migration path before changing the write location.

  • The new readFileWithTimeout returns after 200ms, but the underlying os.ReadFile goroutine is still left blocked forever when the filesystem call hangs. Repeated sidebar rebuilds against the same cloud/unmounted path can accumulate stuck goroutines. The caller no longer hangs, which is good, but the implementation still needs bounded worker lifetime or a different cancellable/stat-based strategy.

Also, the PR removes the REASONIX_DEV single-instance bypass while the new comment still says it exists. If that bypass is intentionally removed, please update the docs/tests and make sure contributor workflows that rely on same-binary multiple dev instances still have an equivalent path.

Verified:

  • GitHub desktop check is failing on this PR.
  • go test ./... from desktop/ fails locally on the PR head.
  • go test -run TestOpenProjectTabResolvesProjectSessionFromLegacyDir -count=1 -v passes in isolation, which supports the process-global cache/stale-state diagnosis rather than a pure test setup failure.
  • git diff --check origin/main-v2...origin/pr/4889 passes.

Security/cache note: I did not see new dependencies or network/file-exfiltration behavior. This does not appear to affect provider-visible prompt/cache prefixes, and the repository cache-impact check passed. The blocker is desktop state correctness and stale local data handling.

ttmouse added 2 commits June 20, 2026 23:15
…SONIX_DEV

- Remove projectTreeResultCache (2s TTL) — it cached runtime fields
  (Open/Running/Status) and returned stale state. Disk cache alone is
  sufficient for cold-start speed; rebuild are always current.
- Add readDesktopConfigFile helper for backward-compatible reads from
  the old config dir (~/Library/Application Support/reasonix/) so
  existing users don't lose their projects/tabs on upgrade.
- Replace readFileWithTimeout with plain os.ReadFile — the goroutine
  leaked on timeout and the 200ms timeout is redundant when wg.Wait
  (5s) already protects the ListProjectTree call from hangs.
- Restore REASONIX_DEV env var bypass for contributors who need to run
  multiple dev instances from the same binary.
ttmouse added 2 commits June 20, 2026 23:24
- wg.Wait 5s timeout + timedOut + wgDone → plain wg.Wait()
- loadProjectTreeDiskCache/saveProjectTreeDiskCache → inlined
- rebuildRequested kept (needed for stale-while-revalidate)
- Per-stage slog timing logs removed, only start/done remain
@SivanCola

Copy link
Copy Markdown
Collaborator

Integrated into #4942, which combines the related desktop sidebar/session runtime fixes on top of the latest main-v2.

I kept the relevant contribution from this PR credited in the #4942 body and commit co-author trailers. Please use #4942 as the integration path for this set of changes.

The stale-while-revalidate pattern returned stale runtime status
(Open/Running/Status) after the initial rebuild completed because
the disk cache was checked on every call. Added
projectTreeDiskCacheLoaded atomic flag so the disk cache path is
only taken once per process — subsequent calls always rebuild,
ensuring live runtime state.
@SivanCola

Copy link
Copy Markdown
Collaborator

Closing this in favor of the merged integration PR #4942.

What was kept/adapted from this PR:

  • bounded sidebar sidecar reads for read-only listing paths;
  • serialized ListProjectTree rebuilds;
  • concurrent project topic sidecar loading;
  • fewer repeated topic-title sidecar reads;
  • path-scoped single-instance IDs.

What was not adopted:

  • the full ProjectNode result cache, because runtime fields must not be served from stale tree state;
  • the config-dir move, because it needs an explicit migration plan.

@ttmouse is credited in #4942's contribution table and commit co-author trailers. Thank you for the investigation and performance work.

@SivanCola SivanCola closed this Jun 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Core agent loop (internal/agent, internal/control) desktop Wails desktop app (desktop/**) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants