Skip to content

Commit 4c8bc61

Browse files
committed
docs(plan): open the macOS RSS retention unit with measured evidence
Under concurrent agentic streaming the proxy climbs from 1.67 GiB to 4.62 GiB in 20 minutes and never returns memory to the OS. The telling sample is the last one: external fell to a tenth of its peak while RSS stayed pinned, so this is high-water-mark retention rather than live data. Two assumptions that shaped earlier discussion turned out to be wrong and are corrected here. A Bun bump is not on the table -- PR #32120 merged but ships in no stable release, and v1.3.14 is already bundled. And #32111 is not Windows-only; it reproduces on Darwin arm64, so extending the eager relay to macOS inherits its crash risk rather than escaping it. Isolated runs on Bun 1.3.14/macOS arm64 (3 each) put numbers on the mechanism: rate-mismatched tee() drives peak external to 249.8 MiB against 4.1 MiB for a single reader, and 363-461 MiB against 10-22 MiB over HTTP. The counter-evidence matters just as much -- dropping tee still leaves 1.65-2.04 GiB pinned, so tee is the dominant amplifier, not the sole cause. Phase 4 exists to own that residual and may honestly close as a NOOP. macOS also turns out to pay twice: it tees every SSE body and then wraps the client branch in a JS pump, because the native handoff is gated to win32. The inspection branch has no bound at all and parses each event twice, which is what makes it lag behind and leaves tee holding chunks. Restart is not a fix and is not treated as one anywhere in this unit. Refs: whatwg/streams#1235, oven-sh/bun#21560, oven-sh/bun#28743
1 parent 5dee96b commit 4c8bc61

5 files changed

Lines changed: 1796 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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

Comments
 (0)