Repository navigation
Conversation
Replace the shared mutable snapshot index with a unique GIT_INDEX_FILE for each capture, preview, restore, and checkout. Seed captures from immutable trees and keep the legacy shared index and stale lock untouched. This removes cross-session and cross-process index lock contention from the path that represented 73.8% of post-start profile samples. Add coverage for concurrent captures and existing user and OpenCode index locks.
Return an empty file list when the start and end snapshot tree IDs are equal instead of starting git diff --name-only. An unchanged model step now uses six Git processes instead of seven, with no change to snapshot or revert semantics.
Replace repeated allowed.includes and skipped.includes scans with set membership while preserving candidate order for Git input. The profiled stale index produced about 5,483 candidates per capture; this changes the local filtering path from quadratic to linear work.
Return the immutable base tree when scoped status scans find no candidates instead of starting git write-tree. With isolated indexes, a clean capture now starts three Git processes instead of four. Tree identity and restore behavior stay unchanged.
Replace concurrent diff-files and untracked ls-files processes with one tagged ls-files scan that reports modified, deleted, and untracked paths. A clean isolated capture now starts two Git processes instead of three. Add deletion coverage alongside modified and untracked snapshot paths.
Resolve git.exe once and route absolute native Windows executables directly through node:child_process.spawn. Keep cross-spawn for shell commands, scripts, cmd, and bat files. This removes repeated PATH, PATHEXT, isexe, statSync, and shebang checks that represented 11.2% of post-start profile samples while preserving existing command routing.
Read the worktree, git directory, and common directory with one rev-parse invocation instead of three sequential child processes. Every Location startup and snapshot service initialization now performs one discovery process instead of three, including linked worktrees.
Compile the event schema encoder once, render the exact SSE frame directly, and cache encoded bytes by immutable event object for concurrent subscribers. This removes the Effect SSE channel, repeated text encoding, and per-client JSON work from the path that represented 1.9% of post-start samples and otherwise scaled with every connected client.
Use Effect.fnUntraced for the Session LLM event publisher and provider delta handlers while retaining coarse request, step, tool, and compaction spans. This removes call-site Error allocation, span creation, AsyncLocalStorage context work, and export data for every text, reasoning, and tool-input delta in the 4.4% post-start OpenTelemetry path.
Bind the Session location when the LLM publisher is created and pass it through every text, reasoning, tool, and step event. EventV2 now skips ambient Location service lookup and location object allocation when the caller supplies a trusted ref, removing repeated Effect context work from every streamed delta.
Create the wildcard EventV2 PubSub only when events.all() has a consumer and skip its publish and shutdown work in the normal bounded-listener server path. Production SSE uses allBounded listeners, so every streamed event now avoids one PubSub operation while typed and wildcard subscription behavior remains covered.
Compile the stable ripgrep match decoder and each tool input, output, and structured-output codec once at definition time instead of rebuilding wrappers for every row or call. Validation behavior stays unchanged while repeated closure and parser setup is removed from two high-frequency parts of the 6.2% post-start Schema path.
Append each directory once during ripgrep indexing, prepare fuzzysort targets at insertion time, and reuse the combined file and directory array until the index changes. This removes files-times-directories array rebuilding and repeated fuzzy target preparation from the search path that represented 5.8% of total profile samples, mainly during Windows startup and autocomplete.
Move assistant and shell edits behind the message adapter. The memory adapter preserves immutable produce semantics, while SQLite edits its newly decoded private value in place before encoding it. Each durable message boundary now avoids Immer proxy creation, copy tracking, and recursive freezing in the 2.4% SQLite projection path without changing projected messages.
Select only id, type, and data for projected message edits, decode the known assistant or shell member schema, and filter the newest shell by callID in SQLite. Shell completion no longer loads and decodes every historical shell row, and all edited-message reads avoid unused columns and broad union dispatch in the 2.4% SQLite path.
Build the current-assistant, assistant-by-ID, and shell-by-call-ID Drizzle statements when the projector layer starts and execute them with typed placeholders inside the active Effect transaction. Repeated durable message edits now skip query-builder, SQL conversion, field-ordering, and mapper setup in the 2.4% SQLite path.
Prepare aggregate-sequence and event-ID position reads once in the EventV2 layer and execute them with placeholders inside the active Effect transaction. Every new durable event now skips two Drizzle query-builder, SQL conversion, and result-mapper setup passes in the measured SQLite and Schema paths.
Pass source object alternates through Git environment instead of persisted repository files, and serialize only changed blob and tree publication with the stale-safe Effect file lock. Live Windows verification against the untouched June 2 index.lock improved parallel capture success from 7/10 to 10/10. Clean captures remain lock-free; 10 parallel captures used 609 ms process CPU and 10 sequential captures used 204 ms.
Member
Author
|
Closing this draft because it was based on upstream/dev instead of upstream/v2. The implementation and benchmark results do not apply to the current OpenCode V2 codebase. I am restarting the work from upstream/v2. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Making parallel OpenCode sessions cheap on Windows
TL;DR
Live Event throughput with four SSE subscribers: 77,537 -> 145,942 delivered Events/s (+88.2%), with 48.4% less CPU work.
The local 81.7-second profile spent most active samples starting processes, resolving executables, and repeatedly attempting snapshots. The table gives the evidence for each commit. Percentages are overlapping profile paths and must not be added together.
2f6c8dcGIT_INDEX_FILEindex.lockcontention and permanent failure after a crashb065474git diff --name-onlywhen start and end tree IDs match07e95a60845afegit write-treefor clean captures3199136diff-filesplusls-files --otherswith one taggedls-filesscan218b926git.exeonce and directly spawn absolute native executablesPATH,PATHEXT,which,isexe,statSync, and shebang checks508a8c0rev-parsef913182f25c107Effect.fnUntracedfor per-delta provider and Session handlersError, async context work, and export data for every streamed delta514728c3fe7972events.all()is consumede69235be89d4e86ced7d8b3c0c6dcallIDin SQLite3f0000299e676f026c1721b10d35What the profile actually showed
The profile covered 81.7 seconds and contained 4,820 samples. The first 10.4 seconds included server startup. I used sample counts for ranking because the profile contains long wall-time gaps. Assigning a multi-second gap to the next sampled frame would incorrectly make SSE or tracing look like a multi-second synchronous call.
cross-spawnancestryspawnstatSynclookupThe categories overlap. For example, Schema work can run inside an Event path that also has tracing.
The matching server log supplied the missing command context:
index.lock.This was not a retry timer. Every normal model step asked for another start or end snapshot, so active Sessions repeatedly entered the same expensive failure path.
Why snapshots started so many processes
The runner takes one snapshot before the physical model attempt and another after provider and tool work:
Before this branch, a clean unchanged step used:
Ten Sessions that reached the same boundary could therefore request about 70 Git processes. Windows process creation made this much more visible than Linux or macOS.
The old mutable-index design
OpenCode keeps snapshot objects in an app-owned Git repository under the OpenCode data directory. The user repository and the snapshot repository have different indexes:
This correctly prevents a user
git addfrom fighting with an OpenCodegit add. It does not prevent two OpenCode server processes from sharing the same OpenCode index. Git protects a mutable index by creatingindex.lock, writing the new index, and renaming it into place. A process crash can leave the lock behind.The supplied machine had exactly this state. The best fix was not a larger stale-lock manager. It was to remove the shared mutable index.
Disposable indexes and immutable trees
Git supports an alternate index with
GIT_INDEX_FILE. Git still creates a mandatory lock next to that alternate index, but every capture now has a unique path:The lock gives one capture crash-safe index writes. It cannot block another capture, another Session, or a later server process.
The capture flow is now:
The implementation is in the deep Git boundary:
The design follows Git's plumbing model:
git read-treereads an immutable tree into an index. Its-idocumentation explicitly describes temporary indexes.git write-treecreates an immutable tree object from a fully merged index.GIT_OPTIONAL_LOCKS=0prevents read commands from doing optional index refresh writes. It does not disable mandatory private-index locks.The first capture starts from the source
HEADtree. Later captures start from the last successful Location tree. OpenCode does not read, modify, or delete the user's index or the legacy OpenCode index.Shared object storage is a different concurrency problem
Git indexes are mutable. Git blobs and trees are content-addressed and immutable. The private snapshot repository still shares one object directory so that stored tree IDs remain available after a server restart.
The clean fixture tests passed with concurrent object writes. The live Windows repository found a real platform edge: two Git processes can race to create the same loose object. Git makes completed loose objects read-only on Windows. The losing writer can then receive
Permission denied.The final design keeps the common path simple:
See changed-object publication. Source objects are exposed with
GIT_ALTERNATE_OBJECT_DIRECTORIES; no persisted alternate file is needed. Git documents persistent alternates inobjects/info/alternates; the environment form gives the same read path without another mutable app file.This lock is not a replacement index lock. Clean captures do not acquire it. It protects the one shared resource that remains: changed object publication.
Process count after the snapshot changes
For the first clean model step:
Later clean steps reuse the previous immutable tree and use 4 Git processes. This is down from 7. Changed captures use extra processes only for the actual add, tree write, and changed-file report.
The branch also resolves
git.exeonce. The native Windows fast path is deliberately narrow:.exeand.comfast pathShell commands, shebang scripts,
.cmd, and.batfiles still usecross-spawn. This preserves Windows script behavior while removing repeatedwhich,isexe, and shebang reads for Git.Removing work from every streamed event
After snapshots, the next repeated path was model output. A provider can emit many text, reasoning, and tool-input deltas for one physical attempt.
Encode once per event, not once per client
The old server route created a Schema encoder wrapper, encoded the Event, called
JSON.stringify, allocated an SSE Event, ran the Effect SSE channel, and encoded text for every subscriber.The new route:
data: <json>\n\nframe directly;WeakMapkeyed by the shared Event object;See the Event handler.
Keep useful spans, remove per-delta spans
Named
Effect.fnhandlers create trace spans and call-site stack data. That is useful for a request, step, tool, or compaction operation. It is high-cardinality noise for each token delta.The per-delta handlers now use
Effect.fnUntraced, while coarse operational spans remain. The Session location is also bound once when the publisher is created, so each Event does not search ambient Effect context and allocate another location value.See the bound publisher and untraced event path and per-delta publication.
This intentionally reduces trace detail. It does not change Session events, ordering, or persistence.
Making the remaining CPU proportional to real work
Windows file search
Windows uses the ripgrep and fuzzysort search path. The old index callback rebuilt
Array.from(directories)for every file and repeatedly sliced and joined path prefixes. Each autocomplete query also rebuilt the mixed target list and prepared targets again.The new path appends each directory once, prepares each target when it enters the index, and invalidates the combined list only when a new file arrives. See filesystem search indexing and the cached query target list. Fuzzysort documents prepared targets in its
prepareAPI.Effect Schema codecs
Stable ripgrep and tool schemas now create their decoder and encoder functions once:
Schema validation remains at the same boundaries. Only repeated wrapper construction is removed.
SQLite projectors
The message updater now tells its adapter to edit an assistant or shell. The memory adapter keeps Immer and immutable replacement. The SQLite adapter owns a newly decoded private value, so it applies the edit directly and encodes it once.
The projector also:
id,type, anddata;json_extract(..., '$.callID')andlimit 1for shell lookup;See message edit ownership, prepared projector reads, and prepared durable Event reads. Drizzle describes prepared placeholders in its prepared statement guide.
Prepared queries still execute through the current Effect transaction service. The transaction and rollback tests pass.
Live Windows verification with the broken state left in place
The final Snapshot service was run directly against
C:\Repos\sst\opencodeand the same app data used by the profile.The zero-byte June 2
index.lockremained present before and after the run. Its length and timestamps did not change.The parallel CPU value includes real changed-object publication. The sequential run shows about 20 ms of OpenCode process CPU per capture while most wall time is Windows child-process latency. This is a focused Snapshot benchmark, not a replacement full-server profile.
Replaying a real 18-Session Event workload
The original CPU profile gave stack samples, but it could not recover Event payloads, delta boundaries, or timing. I therefore attached a local recorder to the elected OpenCode service for 10 minutes while 18 real Sessions were active.
The raw trace stayed on the local machine. It contains 4,932 SSE Events, 10.26 MB of frames, 722 tool calls, 515 reasoning deltas, 82 text deltas, and tool-success frames up to 115 KB. Nothing in the benchmark invents prompt text, tool output, delta sizes, Event order, or inter-arrival timing.
The installed service was newer than both commits under test. It had moved the old
data.timestampvalue to top-levelcreated, renamedsession.next.*Events tosession.*, and added several operational Event types. The replay adapter therefore does only source-defined version conversions:createdmillisecond value back todata.timestamp;session.next.*names;callID, provider metadata, structured tool output, and reasoning IDs from captured values according to the target publisher source;This validates 4,305 of 4,931 non-connection Events, or 87.3% by count and 97.0% by captured bytes. The excluded 3.0% of bytes is mainly newer shell lifecycle, inbox, execution, instruction, and usage Events.
Harness
Each measured process uses the exact target commit, a fresh isolated SQLite database, the production
EventV2.publishpath, the production Server Event handler, and real streamed SSE response bodies. The harness is in-process, so it excludes TCP cost and includes both the publisher and the SSE readers in the same Bun process.The harness performs 100 unmeasured live-Event warm-up publications, forces a full GC, then records wall time,
process.cpuUsage(), and RSS every 5 ms. A result of 100% CPU means one full core. Every run verifies that every selected Event reaches every subscriber. A 128-Event delivery window prevents the production 256-Event bounded subscriber queue from overflowing.Maximum-throughput results are medians. The aggregate test uses five alternating base/candidate runs. The live-only test uses seven alternating runs and repeats the same 607-Event live trace 100 times. Later cycles change only the Event ID so every publication has real object identity and cannot reuse an earlier cycle's cached frame.
Live delta and fan-out path
This isolates the non-durable Event and SSE path. One cycle contains the real captured delta sizes and mix; 100 cycles provide 60,700 source Events and enough CPU time for stable measurement.
The first cache implementation used Effect
Cache. Replay showed that its fibers, TTL state, and strong retention made the candidate 27% to 66% slower. The final handler uses aWeakMap<Event, Uint8Array>: one subscriber is effectively neutral, while additional subscribers share the exact encoded frame with no retained Event lifetime.Aggregate publication path
This publishes all 4,305 compatible Events, including durable SQLite commits, and delivers the stream to each subscriber. Windows SQLite wall time varied widely between runs, so CPU work is the more stable aggregate signal.
At four subscribers, the aggregate candidate uses 19.6% less CPU work, delivers 7.8% more frames per second, and peaks 17.9 MiB lower. The higher candidate CPU percentage is not more CPU work: the candidate finishes sooner, so its reduced CPU time is concentrated into less wall time.
Original-timing paced replay
The same 4,305 Events were also replayed once at their captured inter-arrival times for 592.38 seconds with four subscribers. The base and candidate used isolated databases and ran together at this low average load.
Throughput is equal by design because the original timing controls publication. The candidate uses 5.0% less CPU work at that cadence. The single-run total RSS difference was present before measurement began, while measured replay growth was lower for the candidate. Use the repeated unpaced median for the more stable peak-RSS comparison.
Raw aggregate results remain local. The sensitive replay trace was deleted after the benchmark. The PR reports only aggregate counts and medians.
Verification
.cmdrouting: 2 focused tests passed.sdk-nextpackage typechecks passed.Repository-wide lint remains blocked by one pre-existing octal escape error in
packages/session-ui/src/v2/components/prompt-input/index.tsx. It also reports 4,854 existing warnings. No changed file produced a lint error.Compatibility and tradeoffs
HttpApicontracts do not change.indexandindex.lockfiles are ignored, not migrated or deleted.data:frames with the existing heartbeat and response headers.The result keeps the normal flow visible: read a base tree, inspect one scope, return immediately when clean, and publish immutable objects only when files changed.