Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions devlog/_plan/260822_260822-bun14-followup-memory/000_plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 000_plan — unit map

- 000_research.md — claim ledger + gap analysis
- 010_memory_diagnostics.md — extraMemorySize observability (PR parent, base dev)
- 020_watchdog_gc_relief.md — measurement-FIRST GC evaluation (Phase A harness),
conditional idle-gated production hook (Phase B) per the 260731 gate
- 030_smol_workers.md — smol:true gated on per-worker large-fixture A/B
- 040_macmini_measurement.md — live measurement protocol (feeds 020 Phase A)

Stack shape: PR-A(010, base dev) → PR-B(020 Phase A harness + evaluation,
base PR-A head) → conditional PR for Phase B only on gate PASS;
PR-C(030, base dev, lands per-call-site with A/B evidence).
One decade doc = one work-phase = one PABCD cycle (LOOP-UNIT-CHAIN-01).
Audit round 1: FAIL (4 findings) → docs revised: 020 restructured
measurement-first honoring 260731_macos_rss_retention/040_allocator_residual
gate; 010 static-import sync seam; 030 pre-landing A/B gate; separate
lastReliefAt. See ledger.
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 000 — Bun 1.4 follow-up memory patches: research and claim ledger

Date: 2026-08-22
Unit: 260822_260822-bun14-followup-memory
Question: from today's viewpoint (bundled Bun 1.4.0, released 2026-08-19), which
ADDITIONAL memory patches are possible and worthwhile in opencodex?

## Method

Luna 5-lane discovery swarm (official releases / GitHub issues+PRs / JSC-runtime /
community / server-SSE-proxy), then Tier-2 proof by the main agent via `gh api`
against oven-sh/bun. App-side baseline re-audited against
devlog/_fin/260813_bun_canary_dogfood/050_memory_patch_roadmap.md and current src/.

## Claim ledger (Tier-2 proven unless noted)

| # | Claim | Proof | Status |
|---|---|---|---|
| C1 | No Bun 1.4.x patch release exists after v1.4.0 (2026-08-19). | `gh api repos/oven-sh/bun/releases` → latest tag `bun-v1.4.0`; bun-v1.4.1/2/3 404. | verified |
| C2 | Bun PR #36467 (TLS Bun.serve use-after-free on `server.stop(true)` sibling-socket close) merged 2026-07-31, sha 529adec09, and IS an ancestor of bun-v1.4.0 (`compare/bun-v1.4.0...sha` → status=behind). Already in our bundled runtime; no action. | gh api pulls/36467 + compare | verified |
| C3 | Bun PR #32662 (fetch: release buffered response body + error reader on streaming abort) merged 2026-07-22, sha 4b7241669, ancestor of v1.4.0. In bundled runtime. | gh api pulls/32662 + compare | verified |
| C4 | Bun PR #35093 (fetch: error body stream when fully-buffered response aborted) merged 2026-07-28, sha 789be97db, ancestor of v1.4.0. In bundled runtime. | gh api pulls/35093 + compare | verified |
| C5 | Bun issue #34917: `--max-old-space-size`, `BUN_JSC_gcMaxHeapSize`, `BUN_JSC_forceRAMSize` are NOT reliable heap caps on the 1.4 line; still OPEN (created 2026-07-21, closed:null). Container/OOM bounding must come from app-side watchdog + supervision, not JSC flags. | gh api issues/34917 | verified |
| C6 | `Bun.gc(true)` on 1.4 asks JSC to collect AND asks mimalloc to release fragmented non-JS pages (allocator shared with JSC since the 1.4 Rust/allocator work). | Bun docs (bun.com/reference/bun/gc) opened by L3; local probe `typeof Bun.gc === "function"` on 1.4.0. | verified (docs) |
| C7 | `bun:jsc` heapStats exposes `extraMemorySize`/`heapCapacity`; `Bun.unsafe.mimallocDump` exists on 1.4.0. | local probe on bundled 1.4.0: `{"heapSize":…,"heapCapacity":…,"extraMemorySize":…}`, mimallocDump:function | verified (executed) |
| C8 | `new Worker(url, {smol:true})` works on bundled 1.4.0 (selects JSC Small heap growth policy per Bun docs). | local probe: "smol worker OK" | verified (executed) |
| C9 | RSS retention after GC (issue #27514) and SSE-proxy reader-cancel segfault (#31159) were closed as DUPLICATES, not demonstrated fixed; #26321 (Windows file-stream RSS) duplicate-closed too. Continued A/B measurement remains necessary. | gh issue pages opened by L2/L5 | verified |
| C10 | Community: Bun 1.4 advertises up to ~35% memory reduction (allocator rewrite, thread-local page purging, lazy zeroing); no long-running independent RSS measurements yet. | Reddit announcements (L4), snippet-grade | lead |
| C11 | Medium post claims 1.4-era HTTP long-connection RSS still grew 280→340MB over 7 days; page returned 403. | unreachable | candidate — unverified |

## App-side baseline (what is already done — do not re-patch)

- 260813 roadmap patches #1–#4 ALL landed since: native-main hardened-identity LRU
(src/codex/native-main-claim.ts:25-33), installation-salt LRU
(src/lab/subject/installation-salt.ts:7-17), mode-hint capability LRU
(src/codex/features.ts:1097-1106), Lab ledger event-id process index REMOVED
(no `eventIdIndexByLedger` in src/lab/ledger/store.ts).
- `Bun.serve({ idleTimeout: 255 })` (src/server/index.ts:736) and per-request
`server.timeout(req, 0)` for streaming (src/server/responses/fetch-helpers.ts:113)
already implement the SSE-timeout guidance the swarm surfaced.
- eager-relay vs legacy-tee runtime gate: src/lib/bun-stream-caps.ts
(MIN_FIXED_BUN_VERSION="1.4.0").
- Memory watchdog: warn-only ring sampler (src/server/memory-watchdog.ts), exposed at
/api/system/memory with bun:jsc heapSize/heapCapacity/objectCount.
- 36-store bounded-memory audit closed (devlog/_fin/260813…/050): only remaining
investigation is model-cache generation tombstones — needs an authority-token
redesign, NOT an eviction patch; excluded from this unit.

## Gap analysis → patch set for THIS unit

What Bun 1.4 newly makes possible, that opencodex does not use yet:

1. **Diagnostics gap** — /api/system/memory and the watchdog ignore
`extraMemorySize` (JSC-visible native memory) and the watchdog samples carry no
JSC data at all. On 1.4, extraMemorySize is the counter that moved most
(external-memory reporting fixes #31422/#32653/#34142). → doc 010.
2. **Reclaim gap** — nothing in the tree ever calls `Bun.gc`. On 1.4 a full
`Bun.gc(true)` also purges mimalloc pages (C6) — the exact mitigation for the
"heap shrinks, RSS stays" pattern (#27514) that JSC flags cannot deliver (C5).
A config-gated, rate-limited watchdog relief hook is now worth having. → doc 020.
3. **Worker heap gap** — history/restore/policy workers are short-lived batch jobs;
`smol: true` (C8) bounds their JSC heap growth policy at a small perf cost,
reducing peak RSS during storage jobs. → doc 030.
4. **Proof gap** — every claim above is config/diagnostic-grade until measured.
macmini-cf (arm64, bun 1.3.14 installed → good A/B host) runs the live
measurement protocol. → doc 040.

Explicit non-goals: no Bun runtime patching/fork (upstream 1.4.0 already carries
C2–C4); no JSC env-var "caps" (C5 proves them unreliable); no smol for the main
proxy process (throughput cost, unmeasured); no model-cache tombstone work.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# 010 — Memory diagnostics: extraMemorySize in samples and API

Depends on: 000. Standalone PR (parent of the stack, targets dev).

## Why

Bun 1.4's biggest memory changes are external-memory reporting fixes
(#31422/#32653/#34142 per 260813 canary table). The counter that reflects them is
`heapStats().extraMemorySize` — JSC-visible native memory. Today
/api/system/memory reports jscHeap {heapSize, heapCapacity, objectCount} but NOT
extraMemorySize, and watchdog samples carry no JSC counters at all, so the exact
signal 1.4 improved is invisible in our 6h ring.

## Changes

### src/server/management/system-routes.ts
jscHeap block gains one field:
```diff
jscHeap = {
heapSize: stats.heapSize,
heapCapacity: stats.heapCapacity,
objectCount: stats.objectCount,
+ ...(typeof stats.extraMemorySize === "number" ? { extraMemorySize: stats.extraMemorySize } : {}),
};
```

**Unavailable is not zero.** An older Bun, or any runtime whose `heapStats()`
omits `extraMemorySize`, has not measured zero native memory — it has measured
nothing. Coercing that to `0` would put a fabricated sample into a series whose
entire purpose is to show whether native memory grows, and it would disagree with
the watchdog and doctor layers, which both type the field as optional. Omit the
key instead, and let every consumer distinguish absent from zero.
Type of local `jscHeap` widens accordingly.

### src/server/memory-watchdog.ts — SYNC-SAFE seam (audit finding 4)

defaultSample() is synchronous and MUST stay synchronous. Dynamic import is
async, so the seam is a STATIC import: this repository is Bun-native (AGENTS.md
runtime constraint — the proxy and `bun test` always run under Bun), so
`import { heapStats } from "bun:jsc"` at module top is justified; tsc strict
passes with the pinned Bun 1.4 types. The CALL is still guarded:

```diff
+import { heapStats } from "bun:jsc";
...
export type MemorySampleBase = {
...
arrayBuffers: number;
+ /** JSC heapStats().heapSize, when introspection is available. */
+ jscHeapSize?: number;
+ /** JSC heapStats().extraMemorySize — JSC-visible native memory. */
+ jscExtraMemorySize?: number;
};
...
function defaultSample(now: () => number): MemorySample {
const usage = process.memoryUsage();
+ let jscHeapSize: number | undefined;
+ let jscExtraMemorySize: number | undefined;
+ try {
+ const stats = heapStats();
+ jscHeapSize = stats.heapSize;
+ jscExtraMemorySize = stats.extraMemorySize;
+ } catch { /* introspection failure must never break sampling */ }
const base = { ..., jscHeapSize, jscExtraMemorySize };
```
observedMemoryCounter() UNCHANGED — thresholding remains rss/external/
arrayBuffers. Observability only, no behavior change. Injected `opts.sample`
seam already lets tests supply samples without bun:jsc.

### src/cli/doctor.ts
Service memory line appends `jscExtra=…` when the API returns
`body.jscHeap.extraMemorySize`. jsShare heuristic unchanged.

## Tests
tests/memory-watchdog.test.ts: injected sample with jsc fields round-trips
through snapshot(); default sampler under bun test records numeric jsc fields.
system-routes test: /api/system/memory exposes jscHeap.extraMemorySize.

## Measurement claim
None (diagnostics only) → goalplan c3 rationale: config/diagnostic-only.
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 020 — GC relief: measurement-first evaluation, then gated production hook

Depends on: 010 (diagnostics land first so the evaluation can read
extraMemorySize). THIS DOC LANDS NO PRODUCTION GC CALL BY ITSELF.

## Prior-decision constraint (controlling)

devlog/_fin/260731_macos_rss_retention/040_allocator_residual.md:139-161 bans
threshold/idle-triggered production `Bun.gc(true)` and defines the ONLY path
back: three fresh-process runs showing (a) ≥50% of post-load RSS growth gone by
60s after one GC, (b) repeatable across real workloads, (c) idle-only with
measured stop time and unchanged tail latency in a concurrent control, (d)
release-notes/API support on macOS. Written against Bun 1.3.x; Bun 1.4's
shared-allocator purge (000 C6) could flip the result — measure first.

## Phase A (this unit): harness-only evaluation on Bun 1.4

### Child GC control channel (audit r2 finding 1)

The measured proxy is a spawned child
(scripts/macos-rss-retention-harness.ts:626-638) that today only handles
SIGINT/SIGTERM (harness-child.ts:54-96) — no GC control exists. Add one:

- scripts/macos-rss-retention-harness-child.ts: subscribe `process.on("SIGUSR2")`;
handler runs `const t0 = Bun.nanoseconds(); Bun.gc(true); const dur =
Bun.nanoseconds() - t0` and writes `{type:"gc", at:Date.now(),
durationMs:dur/1e6}` to stdout JSONL (same channel as "ready").
- scripts/macos-rss-retention-harness.ts: after each load cell (outside the
latency-measurement window), `processHandle.kill("SIGUSR2")`, await the
`gc` event line (timestamped receipt), then take the +5s and +60s samples.
- GC duration evidence = the child-reported durationMs, not parent guesswork.
(SIGUSR2 is available on darwin/linux — this harness is darwin-targeted;
Windows is out of scope for it, matching the existing script name.)

### Tail-latency control cells (audit r2 finding 2, r3 finding 1)

The gate's criterion (c) needs a causally connected control WITHOUT
contaminating the RSS criterion (a). The two criteria use SEPARATE cell types:

- RSS-retention cells: load stream → intervention (GC via SIGUSR2 with receipt,
or matched idle wait in the control arm) → process stays IDLE through the +5s
and +60s samples. No probe traffic; the +60s sample is pure post-GC idle
evidence for criterion (a).
- Latency cells (separate fresh-process runs): load stream → intervention →
identical POST-INTERVENTION probe stream in both arms; probe-stream p99 delta
(GC arm − control arm) ≤ max(5ms, 5%) is the oracle for criterion (c), with
the GC pause (child durationMs) reported explicitly. RSS numbers from these
cells are recorded but non-normative.

Deliverable: numbers table in this unit (three fresh-process runs × matched
pairs, per 040 on macmini-cf and locally); verdict PASS/FAIL against the
260731 gate, criterion by criterion.

## Phase B (conditional follow-up cycle, only on Phase-A PASS)

- Idle gate: relief only when `getActiveTurnCount() === 0`
(src/server/lifecycle.ts:263 — existing export, no new seam needed). Defer
while busy; re-check next tick.
- Rate limit: OWN `lastReliefAt` (decoupled from lastWarnAt so warn cadence
never suppresses first relief), floor 30min.
- Config: restart-only startup configuration from the config file
(`memoryWatchdog: { gcRelief?: boolean; warnThresholdMb?: number }` in
OcxConfig). NOT in the /api/settings PUT allowlist; restart-only semantics
documented. warnThresholdMb validated at load: integer 256..65536, else
ignored+warn.
- Wiring chain (audit r2 finding 3 — all three layers named):
1. src/server/index.ts:729 — `acquireServerBackgroundLifecycle(applyPolicy,
{ memoryWatchdog: config.memoryWatchdog })`;
2. src/server/background-lifecycle.ts:129-143 —
`acquireServerBackgroundLifecycle` gains the optional second param and
forwards it to `startProcessLoops(applyPolicy, opts)` (both the
first-owner branch and no change for the re-acquire branch: watchdog
options are first-owner-only, restart-only by definition);
3. startProcessLoops passes `{ gcRelief, warnThresholdBytes, gc, isIdle }`
into startMemoryWatchdog.
- Test seam (audit r3 finding 2): StartServerDeps (src/server/index.ts:437)
gains an optional `memoryWatchdogDeps?: { gc?: () => void; sample?: () =>
MemorySampleBase; now?: () => number; intervalMs?: number; isIdle?: () =>
boolean }` forwarded through acquireServerBackgroundLifecycle alongside the
persisted config options into startMemoryWatchdog (deps override config-
derived defaults; production callers pass nothing). The startup integration
test injects gc spy + over-threshold sample + isIdle=true + short intervalMs
through this seam and asserts snapshot().gcRelief === true and the spy fired.

- snapshot() exposes reliefCount, lastReliefAt, gcRelief.
- Windows caveat: mimalloc page scavenging disabled by design (#34181) —
relief mainly helps darwin/linux RSS.
- docs-site: troubleshooting page gains the new config keys (restart-only).

## Tests (Phase B)
gcRelief on + above threshold + idle → gc called once; busy → deferred;
second tick within 30min → suppressed by lastReliefAt even when a warn fired
earlier; gc throwing → tick survives; gcRelief absent → never called; config
bounds validation; the startup integration test above.

## Measurement claim
Phase A IS the measurement (040). Phase B lands only with that evidence
attached — goalplan c3 satisfied by construction.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 030 — smol workers: bounded JSC heap for storage/history batch workers

Depends on: 000. Sibling PR (no shared files with 010/020) — but landing is
GATED on a local large-fixture A/B (audit finding 3).

## Why

history-job/restore-job/policy-job spawn short-lived Workers for batch work.
`smol: true` (probe-verified on bundled 1.4.0) selects JSC's Small heap growth
policy → lower peak RSS during storage jobs, at a GC-frequency cost.

## Risk (audit finding 3 — must be measured before landing)

These workers are NOT small-payload: policy cleanup materializes all archive
candidates (src/storage/policy.ts:347-379); cleanup snapshots full thread/log/
memory/goal rows and serializes an aggregate backup
(src/storage/cleanup.ts:765-785,872-914,1171-1269); history reads complete
SQLite result sets with rollout buffers (src/codex/history-provider.ts:586-619,
709-732). smol is a growth-policy choice, not a payload bound — a large job
could GC-thrash or slow past the worker timeout.

## Pre-landing gate: per-worker large-fixture A/B

For each worker (history, restore, policy): build a large fixture (≥100MB
aggregate rows / large rollout set), run the job smol-off vs smol-on ×3,
record peak RSS (Subprocess/process sampling), elapsed wall time, completion
status. Acceptance to land each call site: completion success, elapsed within
+25% of baseline, peak RSS reduced. A worker failing the gate keeps its
full-size heap and the doc records the numbers — partial landing (subset of
the three call sites) is an acceptable outcome.

## Changes (only for call sites that pass the gate)

src/codex/history-job.ts:309, src/storage/restore-job.ts:170,
src/storage/policy-job.ts:303 — one-line `, { smol: true }` (pinned Bun 1.4
types include smol; no cast needed per audit).

## Tests
Existing worker suites stay green (smol changes GC policy, not messaging).
The A/B harness script + numbers are the landing evidence, committed into this
unit.

## Measurement claim
Local A/B is the primary evidence (host-independent fixtures); macmini optional.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 040 — macmini-cf live measurement protocol

Depends on: 010 landed on a testable branch. Feeds 020 Phase A verdict.

## Host facts (verified 2026-08-21)
ssh macmini-cf reachable (BatchMode OK), arm64, bun 1.3.14 installed → natural
1.3.14-vs-1.4.0 A/B host. zsh -lc PATH discipline.

## Protocol
1. Install test build: `npm pack` locally → scp tarball → `npm i -g <tarball>`
on macmini-cf. Record ocx --version + bunVersion/bunRevision/bunRuntimeSource
from /api/system/memory.
2. Baseline: default config; drive SSE churn (harness waves: SSE-normal,
SSE-slow, SSE-abort, idle-recovery); sample /api/system/memory every 60s
≥30min. Capture extraMemorySize (010).
3. GC evaluation (020 Phase A): matched no-GC/GC cell pairs using the TWO CELL
TYPES 020 defines, never one concurrent stream shared by both measurements.
RSS cells stay idle through their +5s/+60s samples so the criterion-a
evidence is uncontaminated; latency cells run their own identical
post-intervention probe stream, and their p99 is the criterion-c oracle.
Running an RSS cell under concurrent load reintroduces exactly the
allocator-residual noise 020 split the cells apart to remove. Child-side
SIGUSR2 GC with reported durationMs; three fresh-process runs; p99 latency
delta ≤ max(5ms, 5%) acceptance; evaluate the 260731 gate verbatim,
criterion by criterion.
4. smol A/B (030): if remote numbers wanted beyond the local gate, trigger
storage jobs on both builds; record peak RSS delta.
5. Evidence: scalar-counter JSON only (watchdog privacy contract), committed
into this unit.

## Acceptance mapping
goalplan c3: 020 carries macmini+local GC-gate numbers incl. latency pairs;
030 carries local A/B numbers; 010 records config/diagnostic-only rationale.
Loading
Loading