Skip to content

app: every server event walks the whole directory store, so parallel sessions multiply main-thread work #54295

Open

Description

@Te-River

Description

With several sessions running in parallel, the desktop app spends main-thread time on every server event, and the cost grows with the number of open directories.

Two places walk the whole store per event:

  • packages/app/src/runtime/server/sync.tsx calls children.mark(key) for every event that carries a location. markKey (packages/app/src/runtime/server/global-sync/child-store.ts) then runs runEviction, which does Object.keys(children) and builds a Set of directories to evict — so each event is O(directories).
  • packages/client/src/solid/connection.ts batches events with batch(() => events.forEach(options.onEvent)). batch coalesces Solid renders, but every event still runs the full handler switch, so the JS work is linear in the event count.

The event volume is the sum over all sessions (deltas, tool events), so N parallel sessions multiply both costs.

Plugins

none

OpenCode version

2.0.26

Steps to reproduce

  1. Run several sessions in parallel (the reporter saw it with 3).
  2. Watch the main thread while they stream: the spinner appears and the UI stalls.
  3. Profile the renderer: the per-event handlers dominate, and the cost scales with the number of open directories.

Screenshot and/or share link

n/a

Operating System

Windows 11

Terminal

n/a (desktop app)

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed write-up. I couldn't reproduce this here: it needs the desktop app on Windows with several real sessions streaming, and this sandbox can't run that.

    What I could check:

    • children.mark → runEviction does run on every event that has a location. The number of directory stores it walks is capped by MAX_DIR_STORES (30), though, because extra stores are evicted. In a micro-benchmark of pickDirectoriesToEvict, run the way runEviction calls it, each pass took about 0.2 µs with 1 directory and about 2.5 µs with 30. A few hundred events per second therefore add up to well under 1 ms of main-thread time per second. So this pass probably isn't what causes the stall, though avoiding it per event would still be a small cleanup.
    • batch() in connection.ts does run every event through the handler. Which handler work actually dominates would need a real profile.
    • None of these files changed between 2.0.26 and the latest v2.

    If you have one, a renderer performance profile (DevTools Performance tab, exported as JSON or shared as a screenshot of the bottom-up view) recorded during the stall would help a lot. It would show which functions take the time. A maintainer with the desktop app will need to look at this further. Possibly related: #51423 (Desktop V2 becomes unresponsive when opening a session).

  2. Te-River commented on Oct 10, 2026

    @Te-River
    Author

    Here is the renderer profile you asked for, plus a benchmark that reproduces the scenario.

    Benchmark: packages/app/e2e/performance/timeline/parallel-session-events-benchmark.spec.ts (in #54302). 8 sessions × 200 deltas against a production build; PARALLEL_DIRECTORIES and PARALLEL_ACTIVE_DELTAS are knobs.

    Your micro-benchmark holds up. A renderer profile of that scenario (CDP, sourcemap-mapped) puts the pass you measured at 0.6ms over 1401 events. Directory count is not a cost at all: with the same 8 sessions and 1601 events, 8 directories was faster than 1 in 5 of 5 paired rounds (elapsed p50 270.4ms vs 321.8ms).

    Where the time actually goes. Holding the event count fixed and varying only the visible session's deltas:

    visible deltas events elapsed
    0 1401 45.5 / 45.5 / 48.1 ms
    200 1601 ~290 ms

    That is ~1.2ms per visible delta. The profile attributes it to markdown.tsx updateBlock (82ms inclusive) and solid-js/store self time (72.7ms, of which unwrap is 47ms — the markdown-solid.tsx update → setStore → updateArray/reconcile → unwrap chain walking the whole node array), plus dompurify (14.8ms) and insertBefore (10ms).

    So the per-event store walk is real, but it is in the session-ui markdown store, not in sync.tsx / global-sync / connection.ts / data.ts. The whole fan-out chain is ~11µs per event.

    #54302 therefore ships the eviction skip as the small cleanup you described, and the benchmark. The markdown cost is worth its own issue.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions