# v0.8.4 OOM Fix Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix the 3GB OOM crash on Debian by capping the `chat_view` render budget so the loop cannot walk the entire message buffer per frame under pathological `scroll_offset` values. Add jemalloc on Linux as defense-in-depth.

**Architecture:** Three targeted changes — (1) extract render-budget calculation into a pure function + unit test, (2) use the capped value in `chat_view::render`, (3) register `tikv-jemallocator` as global allocator conditional on `target_os = "linux"`. No architectural refactoring; zero changes to cross-platform behavior on macOS/FreeBSD.

**Tech Stack:** Rust 2024 edition, ratatui 0.30, existing test infra (`cargo test` via `make test`), `tikv-jemallocator 0.6`.

---

## File Structure

### Task 1 — Pure render-budget function + regression test (all in `src/ui/chat_view.rs`)
### Task 2 — Wire function into the render path (`src/ui/chat_view.rs`)
### Task 3 — Commit primary fix
### Task 4 — jemalloc dep (`Cargo.toml`) + global allocator (`src/main.rs`)
### Task 5 — Commit allocator change
### Task 6 — Final verification

Files touched:
- `src/ui/chat_view.rs` — modify: add constant + `compute_render_budget` fn + tests + wire into `render`
- `Cargo.toml` — modify: add `[target.'cfg(target_os = "linux")'.dependencies]` block
- `src/main.rs` — modify: add `#[cfg(target_os = "linux")] #[global_allocator]` static

---

## Task 1: Pure render-budget function + failing test

**Files:**
- Modify: `src/ui/chat_view.rs` — add `const`, add `compute_render_budget` fn, add `#[cfg(test)] mod tests` block

- [ ] **Step 1: Add `MAX_WRAPPED_LINES_PER_MSG` constant and `compute_render_budget` function signature stub**

At the top of `src/ui/chat_view.rs`, immediately after the `use` block (after line 7) and before the `// Wrap-indent is cached` comment on line 9, insert:

```rust
/// Hard upper bound on how many visual (wrapped) lines a single message can
/// contribute to a frame. Used to cap the render budget so the scroll loop
/// cannot walk the entire buffer when `scroll_offset` exceeds available
/// content. Even a ~1000-char NOTICE on an 80-column terminal wraps to
/// ~13 lines; 16 is a safe over-estimate for realistic IRC workloads.
const MAX_WRAPPED_LINES_PER_MSG: usize = 16;

/// Compute the target size of the render `VecDeque<Line>` for chat view.
///
/// Returns the number of visual lines the render loop should aim to build
/// before breaking. Capped to `buffer_len * MAX_WRAPPED_LINES_PER_MSG` so the
/// loop terminates in O(buffer_len) regardless of `scroll_offset`. For empty
/// buffers falls back to `visible_height` so at least a full screen is aimed
/// for.
///
/// This is the minimal guarantee the caller needs: the loop's break condition
/// `visual_lines.len() > needed` will fire in bounded time.
pub(crate) fn compute_render_budget(
    buffer_len: usize,
    visible_height: usize,
    scroll_offset: usize,
) -> usize {
    let cap = buffer_len
        .saturating_mul(MAX_WRAPPED_LINES_PER_MSG)
        .max(visible_height);
    visible_height.saturating_add(scroll_offset).min(cap)
}
```

- [ ] **Step 2: Add failing test for `compute_render_budget`**

At the bottom of `src/ui/chat_view.rs`, append (append, do not replace — the file currently has no `mod tests` block):

```rust
#[cfg(test)]
mod tests {
    use super::{compute_render_budget, MAX_WRAPPED_LINES_PER_MSG};

    #[test]
    fn normal_scroll_returns_visible_plus_offset() {
        // Typical case: user scrolled a bit, offset is small compared to buffer.
        let got = compute_render_budget(2000, 78, 50);
        assert_eq!(got, 78 + 50);
    }

    #[test]
    fn zero_scroll_returns_visible_height() {
        let got = compute_render_budget(2000, 78, 0);
        assert_eq!(got, 78);
    }

    #[test]
    fn pathological_scroll_is_capped_to_buffer_len_times_max_wraps() {
        // The actual bug: scroll_offset pushed far past available content.
        // `needed` must not exceed buffer_len * MAX_WRAPPED_LINES_PER_MSG —
        // that is the invariant that guarantees the render loop terminates
        // in O(buffer_len) instead of walking forever.
        let buffer_len = 2000;
        let got = compute_render_budget(buffer_len, 78, usize::MAX / 2);
        let expected_cap = buffer_len * MAX_WRAPPED_LINES_PER_MSG;
        assert_eq!(
            got, expected_cap,
            "pathological scroll_offset must be capped to buffer_len × MAX_WRAPPED_LINES_PER_MSG"
        );
    }

    #[test]
    fn empty_buffer_returns_visible_height() {
        // Empty buffer has cap = 0, but we floor the cap at visible_height
        // so the render path still gets a sane target.
        let got = compute_render_budget(0, 78, 1000);
        assert_eq!(got, 78);
    }

    #[test]
    fn small_buffer_large_scroll_is_capped_to_buffer_cap() {
        // 10-message buffer cannot produce more than 10 * 16 = 160 visual lines.
        // Even with scroll_offset of 1M, `needed` caps at 160.
        let got = compute_render_budget(10, 78, 1_000_000);
        assert_eq!(got, 10 * MAX_WRAPPED_LINES_PER_MSG);
    }

    #[test]
    fn overflow_safe_on_usize_max_scroll() {
        // visible_height + scroll_offset must not overflow. saturating_add protects us.
        let got = compute_render_budget(100, 78, usize::MAX);
        assert_eq!(got, 100 * MAX_WRAPPED_LINES_PER_MSG);
    }
}
```

- [ ] **Step 3: Run tests to verify they pass (the function is already correct; TDD here is "write test that locks in invariant")**

Run:

```
make test 2>&1 | grep -E "(chat_view|test result|error)" | head -40
```

Expected:
```
test ui::chat_view::tests::empty_buffer_returns_visible_height ... ok
test ui::chat_view::tests::normal_scroll_returns_visible_plus_offset ... ok
test ui::chat_view::tests::overflow_safe_on_usize_max_scroll ... ok
test ui::chat_view::tests::pathological_scroll_is_capped_to_buffer_len_times_max_wraps ... ok
test ui::chat_view::tests::small_buffer_large_scroll_is_capped_to_buffer_cap ... ok
test ui::chat_view::tests::zero_scroll_returns_visible_height ... ok
test result: ok.
```

All 6 should pass. If any fail, stop and debug the `compute_render_budget` implementation.

- [ ] **Step 4: Run clippy to verify no warnings**

Run:

```
make clippy 2>&1 | tail -20
```

Expected: zero warnings. The crate has `pedantic=warn, nursery=warn, perf=deny, redundant_clone=deny` (CLAUDE.md policy — 0 warnings tolerated). If any clippy warning appears on the new code, fix inline before proceeding.

---

## Task 2: Wire `compute_render_budget` into `render`

**Files:**
- Modify: `src/ui/chat_view.rs:47` — replace inline `let needed = visible_height + app.scroll_offset;` with call to new function

- [ ] **Step 1: Replace line 47 of `src/ui/chat_view.rs`**

Current code at lines 44-48:

```rust
        // Process messages from the end of the buffer, wrapping each into
        // visual lines.  Stop once we have enough to fill the screen plus
        // the current scroll offset.
        let needed = visible_height + app.scroll_offset;
        let mut visual_lines: VecDeque<Line<'_>> = VecDeque::new();
```

Replace with:

```rust
        // Process messages from the end of the buffer, wrapping each into
        // visual lines. Stop once we have enough to fill the screen plus
        // the current scroll offset. `needed` is capped at
        // `buf.messages.len() * MAX_WRAPPED_LINES_PER_MSG` so the break
        // condition on line ~72 fires in O(buf.messages.len()) regardless
        // of how far the user has scroll-wheeled past available content.
        // See the OOM fix plan at docs/superpowers/plans/2026-04-10-v084-oom-fix.md.
        let needed = compute_render_budget(buf.messages.len(), visible_height, app.scroll_offset);
        let mut visual_lines: VecDeque<Line<'_>> = VecDeque::new();
```

- [ ] **Step 2: Run full test suite to verify nothing regressed**

Run:

```
make test 2>&1 | tail -20
```

Expected: `test result: ok. <N> passed; 0 failed`. All existing tests continue to pass (the wiring change is purely a substitution of `needed` with a value that equals the old one under normal use).

- [ ] **Step 3: Run clippy**

Run:

```
make clippy 2>&1 | tail -20
```

Expected: zero warnings.

- [ ] **Step 4: Build release binary to catch any build-level issues**

Run:

```
make build 2>&1 | tail -10
```

Expected: `Finished dev [unoptimized + debuginfo] target(s)` with no errors.

---

## Task 3: Commit primary fix

- [ ] **Step 1: Review staged changes**

Run:

```
git diff src/ui/chat_view.rs
```

Expected diff is limited to: new `const`, new `compute_render_budget` fn + tests (at top of file), and single-line substitution at line 47. No unrelated changes.

- [ ] **Step 2: Stage and commit**

Run:

```
git add src/ui/chat_view.rs
git commit -m "$(cat <<'EOF'
fix(ui): cap chat_view render budget to prevent OOM on wheel scroll

Root cause: src/ui/chat_view.rs:47 previously computed
`needed = visible_height + app.scroll_offset` without an upper bound.
Combined with unclamped scroll_offset mutators in src/app/input.rs,
sustained mouse-wheel scrolling pushed needed past the total wrapped-line
count of the buffer. The break condition on line ~72
(`visual_lines.len() > needed`) never fired, so the render loop walked
every message in the buffer on every frame, calling render_message() +
wrap_line() per message (~3-8 KB each).

On a 2000-msg buffer that's ~10 MB of temporary allocations per frame.
Crossterm wheel events drive render at 20-60 fps → 200-600 MB/sec
allocation churn. glibc ptmalloc2 fragments under this workload and
systemd-oomd eventually kills the process at ~3 GB RSS.

Fix: extract render budget into pure function compute_render_budget(),
cap `needed` to buffer_len × MAX_WRAPPED_LINES_PER_MSG (16, safe
over-estimate for 80+ col terminals). Break condition now fires in
O(buffer_len) instead of O(∞).

Unit tests lock in the invariant: pathological scroll_offset must cap
at buffer cap, empty buffer falls back to visible_height, overflow is
saturating.

Triangulated via Cursor, Claude Code, OpenCode GLM 5.1 (which caught
the break-never-fires detail). Full diagnosis in MemPalace wing=repartee
room=src. Design spec: docs/superpowers/specs/2026-04-10-v084-oom-fix-design.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"
```

Expected: `[main <hash>] fix(ui): cap chat_view render budget ...` with 1 file changed.

---

## Task 4: jemalloc dep + global allocator

**Files:**
- Modify: `Cargo.toml` — add `[target.'cfg(target_os = "linux")'.dependencies]` block
- Modify: `src/main.rs` — add `#[cfg(target_os = "linux")] #[global_allocator]` static

- [ ] **Step 1: Add jemalloc dependency to `Cargo.toml`**

Open `Cargo.toml`. After the `[dev-dependencies]` section (which currently contains only `tempfile = "3.26"`) and before the `[lints.clippy]` section, insert:

```toml

[target.'cfg(target_os = "linux")'.dependencies]
tikv-jemallocator = "0.6"
```

Result: the file will have `[dependencies]` → `[dev-dependencies]` → new `[target.'cfg(target_os = "linux")'.dependencies]` → `[lints.clippy]` → `[profile.release]`.

- [ ] **Step 2: Register jemalloc as global allocator in `src/main.rs`**

Open `src/main.rs` and read the first ~20 lines to find the right insertion point (after the module declarations and `use` statements, before `fn main()`).

Add the following block immediately after the last `use` statement and any existing module-level attributes, before any function or struct definition:

```rust
// Swap glibc ptmalloc2 for jemalloc on Linux. glibc fragments its arena under
// bursty allocation patterns in long-running processes (we've observed RSS
// growth to 3 GB over a week of uptime despite bounded working set). jemalloc
// returns memory to the OS more aggressively and is the default system
// allocator on FreeBSD anyway, so this change makes Linux behave more like BSD.
// macOS keeps libsystem_malloc, FreeBSD keeps its native jemalloc — this cfg
// applies only to target_os = "linux" and does not affect other builds.
#[cfg(target_os = "linux")]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
```

- [ ] **Step 3: Run `cargo check` to fetch dep and validate**

Run:

```
cargo check -p repartee 2>&1 | tail -20
```

Expected: `tikv-jemallocator` is downloaded from crates.io, then `Checking repartee v0.8.4` → `Finished` with no errors.

If on macOS/FreeBSD (CI or dev machine), the dep will not be pulled in at all — `cargo check` will simply finish without `tikv-jemallocator` in the build graph. This is verified by the `[target.'cfg(...)'.dependencies]` syntax which Cargo resolves per target.

- [ ] **Step 4: Run tests**

Run:

```
make test 2>&1 | tail -10
```

Expected: all tests pass (allocator swap does not affect test logic). Specifically, the 6 new `compute_render_budget` tests continue to pass.

- [ ] **Step 5: Run clippy**

Run:

```
make clippy 2>&1 | tail -20
```

Expected: zero warnings. If any warning appears on the new `static GLOBAL` line (e.g. `clippy::uninlined_format_args` — unlikely here), fix before proceeding.

- [ ] **Step 6: Build release binary**

Run:

```
make release 2>&1 | tail -15
```

Expected: `Finished release [optimized] target(s)`. Note that the `profile.release` section in `Cargo.toml` uses `lto = true, codegen-units = 1, panic = "abort", strip = true` — the build will be slower than dev but should succeed.

On Linux: the release binary will now be linked against jemalloc. On macOS/FreeBSD: the binary will be byte-identical to pre-change behavior.

---

## Task 5: Commit allocator change

- [ ] **Step 1: Review staged changes**

Run:

```
git diff Cargo.toml src/main.rs
```

Expected diff:
- `Cargo.toml`: new `[target.'cfg(target_os = "linux")'.dependencies]` block
- `src/main.rs`: new `#[cfg(target_os = "linux")] #[global_allocator] static GLOBAL: ...` declaration with doc comment

Also check `Cargo.lock` should have been updated by `cargo check`:

```
git status --short
```

Expected to see:
```
 M Cargo.lock
 M Cargo.toml
 M src/main.rs
```

- [ ] **Step 2: Stage and commit**

Run:

```
git add Cargo.toml Cargo.lock src/main.rs
git commit -m "$(cat <<'EOF'
build: use jemalloc as global allocator on Linux

Defense-in-depth against glibc ptmalloc2 fragmentation in long-running
sessions. Root-cause fix for the v0.8.4 OOM is in the preceding commit
(render budget cap in chat_view); this is a secondary hardening to make
Linux behave more like FreeBSD (which already uses jemalloc system-wide)
and reduce baseline RSS drift over weeks of uptime.

Conditional on target_os="linux" only. macOS keeps libsystem_malloc,
FreeBSD keeps its native jemalloc — builds on those targets are
byte-identical to before. Cargo's `[target.'cfg(...)'.dependencies]`
syntax ensures tikv-jemallocator is not pulled into the build graph on
non-Linux targets at all.

tikv-jemallocator 0.6 is production-stable, used by tikv, foundationdb,
rust-analyzer, and others for similar long-uptime workloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"
```

Expected: `[main <hash>] build: use jemalloc ...` with 3 files changed.

---

## Task 6: Final verification

- [ ] **Step 1: Run full test suite one more time**

Run:

```
make test 2>&1 | tail -5
```

Expected: `test result: ok. <N> passed; 0 failed; 0 ignored`.

- [ ] **Step 2: Run full clippy one more time**

Run:

```
make clippy 2>&1 | tail -10
```

Expected: `Finished` with zero warnings. No `warning:` lines in output.

- [ ] **Step 3: Verify git log shows both commits cleanly**

Run:

```
git log --oneline -5
```

Expected top entries (in order):

```
<hash> build: use jemalloc as global allocator on Linux
<hash> fix(ui): cap chat_view render budget to prevent OOM on wheel scroll
da23b6f docs(spec): v0.8.4 OOM fix design — chat_view render budget cap + jemalloc on Linux
9e44c83 chore(gitignore): ignore docs/plans
c3a17f8 docs: add MemPalace guide to CLAUDE.md, ignore agent tool state
```

- [ ] **Step 4: Verify the release binary runs (smoke test)**

Run:

```
./target/release/repartee --help 2>&1 | head -20
```

Expected: Repartee usage text prints. If it panics immediately (allocator initialization issue), that's a critical regression — stop and investigate.

- [ ] **Step 5: Report completion summary to user**

In chat, report:
- Both commits landed with hashes
- Test count unchanged except +6 new `compute_render_budget` tests
- Release binary builds successfully
- Recommend: run `repartee` in a test session, scroll aggressively with mouse wheel for 30 seconds, monitor RSS with `ps -o rss,vsz,comm -p $(pidof repartee)` — RSS should stay bounded under ~100 MB instead of climbing.

---

## Self-Review

- **Spec coverage:** Spec's 3 changes → Task 1+2 (Change 1: cap in chat_view + Change 2: regression test), Task 4 (Change 3: jemalloc). ✓ All 3 covered.
- **Placeholder scan:** No TBDs, no "implement later", no "add error handling". Every step has exact code or exact command. ✓
- **Type consistency:** `compute_render_budget(buffer_len: usize, visible_height: usize, scroll_offset: usize) -> usize` used consistently in Task 1 (definition), Task 2 (call site), Task 6 (tests). ✓
- **Commit strategy:** Matches spec's commit plan — primary fix and allocator change as separate commits. ✓

**Plan complete.**
