|
| 1 | +# macOS RSS retention under concurrent SSE streaming |
| 2 | + |
| 3 | +Unit opened 2026-07-31. Work lands directly on `dev`. Parallel experiments run in |
| 4 | +separate git worktrees driven by subagents — no side branch for this unit. |
| 5 | + |
| 6 | +## Symptom |
| 7 | + |
| 8 | +A long-lived proxy on macOS arm64 (Bun 1.3.14) climbs monotonically in RSS under |
| 9 | +concurrent agentic streaming and never returns memory to the OS. Measured on the |
| 10 | +live process (pid 63737, `/api/system/memory` watchdog ring, 60s cadence): |
| 11 | + |
| 12 | +| Time | RSS | external | heapUsed | |
| 13 | +|---|---|---|---| |
| 14 | +| 23:08:55 | 1.67 GiB | 35.8 MiB | 50.7 MiB | |
| 15 | +| 23:17:57 | 3.30 GiB | 1104.6 MiB | 1410.4 MiB | |
| 16 | +| 23:23:58 | 3.87 GiB | 1571.0 MiB | 1974.4 MiB | |
| 17 | +| 23:27:58 | 4.62 GiB | 157.3 MiB | 175.1 MiB | |
| 18 | + |
| 19 | +The last row is the diagnostic one: `external` fell to a tenth of its peak while |
| 20 | +RSS stayed pinned at 4.62 GiB. `external` oscillates; RSS only ratchets upward, |
| 21 | +tracking the high-water mark of the spikes. |
| 22 | + |
| 23 | +The continuation store is not the cause — it held 85 entries / 48 MB at the time |
| 24 | +of the 4.62 GiB sample, under 1% of resident memory. |
| 25 | + |
| 26 | +## Constraint |
| 27 | + |
| 28 | +Process restart is REJECTED as the solution. The existing drain-and-restart |
| 29 | +action stays as an operator escape hatch and must never be reported as the fix. |
| 30 | + |
| 31 | +## Evidence |
| 32 | + |
| 33 | +### E1 — upstream status (external research, Tier-2 proven) |
| 34 | + |
| 35 | +- `ReadableStream.tee()` slow-branch accumulation is a standards-level hazard: |
| 36 | + [whatwg/streams#1235](https://github.com/whatwg/streams/issues/1235), open. |
| 37 | + Chunks accumulate in the lagging branch without upstream backpressure. |
| 38 | +- Bun tracks RSS retention separately from JS leaks: |
| 39 | + [oven-sh/bun#21560](https://github.com/oven-sh/bun/issues/21560), open, includes |
| 40 | + macOS Apple Silicon, reports stable heap with rising RSS that does not fall after GC. |
| 41 | +- Diagnosis of that pattern: [oven-sh/bun#30590](https://github.com/oven-sh/bun/pull/30590) |
| 42 | + (closed unmerged 2026-06-26) attributes it to freed pages retained in libpas |
| 43 | + caches and mimalloc per-thread heaps — allocator retention, not a live-object leak. |
| 44 | +- Closer to the fetch path: [oven-sh/bun#28743](https://github.com/oven-sh/bun/pull/28743), |
| 45 | + open — HTTP-thread allocations freed on the JS thread land in a delayed-free list. |
| 46 | +- No Bun-specific `tee()` retention issue or fix exists. Do not conflate #1235 with one. |
| 47 | + |
| 48 | +### E2 — two corrections to earlier assumptions |
| 49 | + |
| 50 | +1. **A Bun version bump is not available.** [PR #32120](https://github.com/oven-sh/bun/pull/32120) |
| 51 | + merged 2026-06-21 but ships in no stable release. Latest stable is v1.3.14 |
| 52 | + (2026-05-13), which the repository already bundles. Adopting a canary as the |
| 53 | + default runtime was rejected by the user and is moot regardless. |
| 54 | +2. **#32120 is not Windows-only.** Its issue [#32111](https://github.com/oven-sh/bun/issues/32111) |
| 55 | + reproduces on Darwin arm64. It is an async-stream client-abort crash, not a |
| 56 | + retention defect, so it does not explain this symptom either way. |
| 57 | + |
| 58 | +### E3 — isolated reproduction (Bun 1.3.14, macOS arm64, 3 runs each) |
| 59 | + |
| 60 | +Scripts under `/tmp/bun-tee-isolation.iJNNpB`. 64 KiB chunks; in-process runs |
| 61 | +stream 4000 chunks (250 MiB); the HTTP run uses 4 concurrent slow clients. |
| 62 | + |
| 63 | +| Experiment | peak external | peak RSS | RSS after GC + 5s | |
| 64 | +|---|---|---|---| |
| 65 | +| EXP-1 tee, lagging branch | 249.8 MiB | 292.7 MiB | 292.7 MiB | |
| 66 | +| EXP-2 single reader | 4.1 MiB | 46.0 MiB | 46.0 MiB | |
| 67 | +| EXP-3 HTTP, tee | 363–461 MiB | 2312–3031 MiB | unchanged | |
| 68 | +| EXP-3 HTTP, single reader | 10–22 MiB | 1655–2044 MiB | unchanged | |
| 69 | + |
| 70 | +Findings: |
| 71 | + |
| 72 | +- Rate-mismatched `tee()` amplifies `external` by ~60x in-process and ~30x over HTTP. |
| 73 | +- `Bun.gc(true)` releases the `external` accounting but RSS does not come back |
| 74 | + within the observation window. |
| 75 | +- Repeated equal-size spikes settle at the first peak rather than compounding — |
| 76 | + high-water-mark behavior, consistent with allocator retention. |
| 77 | +- **Counter-evidence that must not be lost:** removing tee still leaves 1.65–2.04 GiB |
| 78 | + pinned RSS in the HTTP topology. `tee()` is the dominant amplifier, NOT the sole |
| 79 | + cause. Bun's HTTP buffering and allocator behavior are also material. |
| 80 | + |
| 81 | +### E4 — what the macOS code path actually does |
| 82 | + |
| 83 | +macOS carries a double cost, which is worse than the original hypothesis assumed. |
| 84 | + |
| 85 | +- `src/server/responses/core.ts:1686` tees every passthrough SSE body. |
| 86 | +- `src/server/responses/core.ts:1749` — the client branch is ALSO wrapped in the JS |
| 87 | + relay `relaySseWithFailedTail`. The native handoff is gated to |
| 88 | + `process.platform === "win32"`, so macOS never gets it. |
| 89 | +- The inspection branch has no bound: |
| 90 | + - `src/server/relay.ts:505` — `buffer` accumulates incomplete SSE frames uncapped. |
| 91 | + - `src/server/relay.ts:485` — `completedItemsByOutputIndex` retains completed output |
| 92 | + items and is not cleared after the completed-response callback. |
| 93 | + - `JSON.parse` runs twice per event (request-log metadata + continuation capture), |
| 94 | + plus regex framing and `split` per chunk. This is what makes the branch lag. |
| 95 | +- On client disconnect the inspection reader is not explicitly cancelled; it settles |
| 96 | + only indirectly once `upstream.abort()` errors the stream. |
| 97 | + |
| 98 | +Crucially, `relaySseEagerBounded` (`src/server/relay-eager.ts`) already receives the |
| 99 | +same inspector hooks — terminal outcome, log metadata, continuation capture, |
| 100 | +first-output timing — and pauses its producer at an 8 MiB queue bound. **No |
| 101 | +inspection feature depends on the second tee branch.** macOS is excluded by the |
| 102 | +platform gate at `core.ts:1626`, not by a capability gap. |
| 103 | + |
| 104 | +What the eager path does NOT do today: client-facing image-gen alias restoration, |
| 105 | +Responses item-ID repair, and the synthetic `response.failed` tail on mid-stream |
| 106 | +reset (it errors the client stream instead). |
| 107 | + |
| 108 | +### E5 — instrumentation inventory |
| 109 | + |
| 110 | +Reusable: `startServer(0)` (`src/server/index.ts:253`), the watchdog sampler, the |
| 111 | +authenticated `GET /api/system/memory`, `ocx observe memory --json`, and the |
| 112 | +offline fake-upstream pattern (local `Bun.serve` + provider `baseUrl` + |
| 113 | +`allowPrivateNetwork`, as in `tests/claude-messages-endpoint.test.ts:68`). |
| 114 | + |
| 115 | +Must be built: a load harness, a configurable fake SSE upstream, concurrent-client |
| 116 | +orchestration, and sub-second sampling. The watchdog samples at 60s |
| 117 | +(`src/server/memory-watchdog.ts:53`), far too coarse to see the spikes. |
| 118 | + |
| 119 | +## Work-phase map (dependency-ordered) |
| 120 | + |
| 121 | +Each phase closes with something independently verifiable. |
| 122 | + |
| 123 | +| Phase | Doc | Outcome | |
| 124 | +|---|---|---| |
| 125 | +| 1 | `010_measurement_harness.md` | Reproducible harness + captured baseline series | |
| 126 | +| 2 | `020_inspection_bounds.md` | Bound the inspection branch; measure the delta | |
| 127 | +| 3 | `030_macos_single_reader.md` | Let macOS select the bounded single-reader path without losing rewrite/failed-tail behavior | |
| 128 | +| 4 | `040_allocator_residual.md` | Address or document the non-tee residual RSS | |
| 129 | + |
| 130 | +Ordering rationale: nothing can be judged before the harness exists (phase 1). |
| 131 | +Bounding the inspector (phase 2) is a local, low-risk change that attacks the |
| 132 | +proven lag mechanism and must be measured on its own before the larger path |
| 133 | +change. The single-reader migration (phase 3) is the structural fix and depends |
| 134 | +on phase 2's measurements to size its benefit. The allocator residual (phase 4) |
| 135 | +is last because E3 shows it is independent of our stream shape — it may end in a |
| 136 | +documented NOOP with upstream references rather than a code change. |
| 137 | + |
| 138 | +## Scope boundary |
| 139 | + |
| 140 | +IN: `src/server/responses/core.ts`, `src/server/relay.ts`, |
| 141 | +`src/server/relay-eager.ts`, `src/lib/bun-stream-caps.ts`, `src/config.ts` |
| 142 | +(streamMode surface), `src/server/memory-watchdog.ts`, `tests/`, and this unit. |
| 143 | + |
| 144 | +OUT: rewriting the runtime in another language; adopting a Bun prerelease as the |
| 145 | +default; provider adapters, routing, auth, GUI. The dirty worktree files owned by |
| 146 | +a concurrent task (subagents-workspace, `Subagents.tsx`, subagent i18n keys, `go/`) |
| 147 | +are not to be touched. |
| 148 | + |
| 149 | +## Open questions carried into phase 1 |
| 150 | + |
| 151 | +- How much of the live 4.62 GiB is tee amplification versus the HTTP/allocator |
| 152 | + residual that EXP-3 exposed? The harness must be able to attribute this. |
| 153 | +- Does the inspection branch actually lag in production traffic, or only under the |
| 154 | + synthetic pacing used in EXP-1? Instrumentation must record real lag, not assume it. |
0 commit comments