Skip to content

fix(markdown): bound Markdown document listing and renderer retention - #10990

Draft
nwparker wants to merge 8 commits into
oom-slice-8-ssh-configfrom
oom-slice-9-markdown
Draft

nwparker wants to merge 8 commits into
oom-slice-8-ssh-configfrom
oom-slice-9-markdown

Conversation

@nwparker

@nwparker nwparker commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Slice 9 of the re-landed OOM work (#10179, reverted in #10255 for size). Based on oom-slice-8-ssh-config (#10887).

ELI5

When you type [[ in a Markdown file, Orca offers to link to any other Markdown file in your workspace. To do that it walks the whole folder tree and builds a list of every .md file it finds.

That walk had no stopping point. In a huge monorepo it would keep going until it had every file, and the editor kept one of those lists per worktree, forever, never throwing any away. Two ways to run out of memory: one workspace that's too big, or a lot of workspaces that each add up.

This adds a ceiling to the walk and a small, self-cleaning cache to the editor.

What changed

A shared listing budget. One module now owns the ceilings, and the three producers that build this list — local filesystem, SSH provider, relay — all enforce the same ones:

Limit Value Guards against
documents 20,000 very large monorepos
aggregate metadata 8 MiB many files with long paths
single path 64 KiB one pathological path
visited entries 100,000 wide trees, many non-Markdown files
recursion depth 256 deeply nested trees

Caller-supplied limits are clamped down, never up (clampLimit returns Math.min). Limits arrive from the renderer and across the relay, so an untrusted caller asking for a bigger budget gets the module ceiling instead. There's a test for exactly this — it passes Number.MAX_SAFE_INTEGER for all five and asserts the budget comes back at the module values.

Streaming directory reads. readdir materialized every entry of a directory as an array before any of it was examined; a single flat directory with a million entries allocated the whole array first. Discovery now uses opendir and consumes entries with for await, so entries are processed as they arrive.

Bounded renderer retention. The editor kept Record<worktreeId, MarkdownDocument[]> and only ever added keys. It's now an LRU of 8 worktree snapshots capped at 32 MiB aggregate, evicting oldest-first on either bound.

Typed capacity failures. Hitting a ceiling raises a typed error (relay code -33008) rather than silently returning a short list, so the renderer can tell "workspace too large" apart from "listing failed".

User-facing changes

Three, stated plainly:

  1. Above 20,000 Markdown files (or 8 MiB of paths), [[ link completion is lost entirely for that workspace — not degraded. The listing throws, the renderer shows one toast ("Workspace is too large for Markdown link completion.") and sets an empty list. This is the one real downside of the slice. Truncating instead would silently offer an arbitrary subset with no signal that anything was missing, which is worse to debug; a hard, announced failure was the deliberate call. Worth revisiting if anyone hits it.
  2. ** in a workspace with more than 256 levels of nesting stops descending at 256. No known real tree comes close.
  3. Switching between more than 8 worktrees re-fetches the document list on return to an evicted one, instead of showing it instantly from cache. A refetch, not a failure.

Everything below those ceilings is byte-for-byte unchanged.

Point 3 was only half true when first written, and the follow-up commit is what makes it true. See below.

The bug the follow-up commit fixes

Review of the first commit found that one of the two evictions this slice adds was not in fact "a refetch, not a failure".

What went wrong. There are two caches here. The worktree LRU in useMarkdownDocuments does re-fetch on a miss — that one was fine. The Monaco completion retention (MARKDOWN_COMPLETION_MAX_SCOPES = 32, MAX_MODELS = 256, 64 MiB) had no such path:

  • provideCompletionItems only reads the scope maps. A miss returned [].
  • The only writer, setMarkdownDocCompletionDocuments, is called from an effect keyed on language, markdownDocuments, runtimeEnvironmentId, and worktreeId.
  • None of those change while you type. Typing [[ cannot trigger a re-supply.

So an editor whose scope got evicted — by 32 other scopes appearing, or by the byte ceiling — offered zero [[ completions for as long as it stayed open. Identical on screen to a workspace containing no Markdown files. Unlike case 1 above, there is no toast: it fails silently and looks like a correct empty result.

Reachable in ordinary use: open a Markdown file, work across enough other worktrees or runtime environments to push 32 scopes, come back to the still-open tab.

Confirmed chain-introduced, not pre-existing. At the merge base (f10b6de2c7) the module holds a plain unbounded documentsByModel Map with no eviction at all, so a mounted editor could never lose its documents. The eviction — and therefore the empty-completion state — arrives with this slice.

The fix. Each model registers a refill callback alongside its documents. A completion request that misses the cache now calls it once and re-reads:

provideCompletionItems → cache hit?  → serve
                       → cache miss? → refill() → re-read → serve

Eviction becomes a recomputation instead of a dead feature. Details worth a reviewer's attention:

  • The refill reaches current props through a ref. It outlives the render that registered it, so capturing that render's closure would re-supply the document list as it was at mount — a file added later would stay missing from completions for the life of the editor. A test pins this.
  • The result is re-read, not trusted. A refill can legitimately decline to store (non-Markdown model, or a document set over the 8 MiB listing ceiling). Returning getCompletionDocuments() recursively instead of re-reading gives infinite recursion — the mutation run proves it (RangeError: Maximum call stack size exceeded).
  • Refills are bounded too, at 512. Each is a closure and a key, not a document snapshot, and the editor already retains the array either way — so holding one retains nothing new.
  • Released on unmount, and cleared when the provider is torn down.

Mutation-tested, 7 mutations, all fail:

Mutation Tests failing
Completion miss no longer refills (restores the bug) 2
Unmount stops releasing the refill 1
Refill map unbounded 1
Provider teardown stops clearing refills 1
Refill result trusted recursively instead of re-read 1 (stack overflow)
MonacoEditor stops registering the refill 1
Refill captures a stale closure instead of the current ref 1

The last two are the ones that matter for call-site vacuity: they mutate the component, not the completion module. A new test renders the real MonacoEditor, drives the real registered Monaco provider through provideCompletionItems, and asserts suggestions survive eviction — so deleting the wiring fails a test rather than passing silently.

Verification at the current tip (after merging current origin/main): src/renderer/src/components/editor + src/renderer/src/store + src/shared — 6,379 passed, 0 failed (7 skipped). Node and web typecheck clean. Lint 0 warnings / 0 errors.

Proof the bounds actually hold

Every constant was rewritten to Number.MAX_SAFE_INTEGER and the suite re-run, serially, in a scope covering all seven producer/consumer areas. A bound whose removal leaves the suite green isn't proven — it's decoration. Baseline was sampled twice and its intersection taken, so a known load-sensitive flake can't be mistaken for a kill:

Constant Result Killed by
MAX_DOCUMENTS ✅ killed relay producer: rejects rather than truncating past the count limit
MAX_METADATA_BYTES ✅ killed applies the default metadata ceiling of 8 MiB
MAX_PATH_BYTES ✅ killed 2 tests — IPC UTF-8 path overflow, relay unterminated path
MAX_VISITED_ENTRIES ✅ killed applies the default visited-entry ceiling of 100,000
MAX_DEPTH ✅ killed applies the default depth ceiling of 256
WORKTREE_MAX_SNAPSHOTS ✅ killed retains at most 8 worktree snapshots by default
WORKTREE_MAX_RETAINED_BYTES ✅ killed caps aggregate retained bytes at 32 MiB by default

An earlier pass had 6 of these surviving. The cause was the same each time: tests that seeded state relative to the constant under test (budget.visitedEntries = MAX_VISITED_ENTRIES - 1) pass at any constant value, including one large enough to be no bound at all. The new tests assert against literal ceilings (99,998 / 100,000, 8 * 1024 * 1024, 256, 8, 32 MiB), so raising a constant now fails the suite.

One case worth noting: the 32 MiB retention test first failed because a single snapshot big enough to reach 32 MiB already exceeds the 8 MiB per-listing ceiling. The aggregate cap only binds across several worktrees, which is the case it exists for — so the test now uses 5 snapshots × 8,000 documents, each individually legal.

Proof of no regressions

  • Full suite: 3,474 files / 37,142 tests pass. The only 2 failures are src/relay/agent-exec-handler.test.ts, which also fail on the base branch — pre-existing, unrelated.
  • Mobile: 342 files / 2,486 tests pass, 0 type errors.
  • Typecheck: node clean (0); web shows exactly the 6 pre-existing errors present on base (worktree-logic ×4, worktree-metadata-merge ×1, worktree-name-suggestions.test ×1).
  • Lint: 0 warnings / 0 errors — identical to baseline. (The baseline itself moved from 27/0 to 0/0 when fix(quality): enforce performance-safe lint baseline #11074 landed on main; confirmed by running oxlint on a clean origin/main checkout, so the 0 is main's and not suppression added here.)

Two findings during verification, both chased down rather than waved off:

A real regression, found and fixed. filesystem.test.ts mocks fs/promises; switching discovery to opendir broke 2 markdown tests there because the mock had no opendir export. It was caught only when the mutation scope widened to all of src/main/ipc — a narrower earlier scope had missed it. Fixed by delegating opendir to the existing readdir fixtures, keeping one source of directory contents.

A flake, correctly classified. terminal-history-incremental-restore.test.ts failed twice in one full-suite run. Rather than assume, I traced the import graph — no slice file is reachable from that test across 155 modules — and ran an interleaved A/B under CPU contention, 5 rounds, alternating base and slice. The failure reproduced on base, not on the slice. It's a pre-existing vi.waitFor timing sensitivity, untouched by the slice chain.

Not included

The src/relay/protocol.ts frame-decoder hardening (buffered-chunk caps, oversized-payload streaming discard, bounded JSON stringify/parse) is deliberately left for a later relay slice. This PR takes only the -33008 error code from that area.

Made with Orca 🐋

Markdown link completion listed every .md/.mdx/.markdown file under a
workspace root with no ceiling on count, aggregate metadata, path length,
directories visited, or recursion depth, and the renderer retained one
document array per worktree forever. A large monorepo — or a symlink-free
but very deep tree — could grow both the main-process listing and the
renderer cache without limit.

- Add a shared listing budget (20,000 documents / 8 MiB metadata /
  64 KiB path / 100,000 visited entries / depth 256) enforced by the
  local, SSH, and relay producers alike. Caller-supplied limits are
  clamped down, never up, so the ceiling is enforced rather than advisory.
- Stream directory entries with opendir instead of materializing each
  directory's full entry array.
- Bound renderer retention to an LRU of 8 worktree snapshots capped at
  32 MiB aggregate.
- Surface capacity failures as a typed error (relay code -33008) and a
  single toast rather than a silent empty list.
nwparker and others added 7 commits July 27, 2026 18:17
The completion retention limits evict by scope, and eviction can drop a scope
whose editor is still open. Nothing re-populated it: `provideCompletionItems`
only reads the maps, and the sole writer runs from an effect keyed on language,
document list, runtime environment, and worktree — none of which change while
you type. So an evicted editor offered zero `[[` completions indefinitely,
looking exactly like a workspace with no markdown files.

Register a refill callback per model. A completion request that misses now
re-supplies once from the owning editor and re-reads, so eviction costs a
recomputation instead of the feature. The callback reaches current props
through a ref, since it outlives the render that registered it, and is
released on unmount.

Refills are bounded too (512): a closure and a key, no document snapshot.

Co-authored-by: Orca <help@stably.ai>
@OrcaWin

OrcaWin commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Moving this PR to draft because it has been untouched for more than 80 days (latest recorded PR activity or commit timestamp: 2026-07-28T05:10:40Z; checked on 2026-10-27). This keeps the review queue focused on active work while preserving the proposal. Please mark it ready for review when work resumes.

This branch has not been deployed

No deployments
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.

2 participants