# Phase 20.1: MLX Metal Memory Lifecycle - Context

**Gathered:** 2026-03-27
**Status:** Ready for planning
**Source:** Debug sessions + 6 parallel research agents

<domain>
## Phase Boundary

Fix the MLX-Swift IOAccelerator memory leak that causes claude-tts-companion to consume 29+ GB RAM after a single TTS notification. The service is currently DISABLED due to this issue.

Each `generateAudio()` call creates ~1.7-6 GB of unreclaimable Metal driver-level allocations (IOAccelerator regions). Growth is linear and unbounded — no plateau. After a 14-chunk Telegram notification (~20 generateAudio calls), the process reaches 30.9 GB.

This is a confirmed MLX framework bug (ml-explore/mlx issue #1086, #755, #1271, hexgrad/kokoro #152). The static MetalAllocator pools buffers by design and only frees on process exit.

</domain>

<decisions>
## Implementation Decisions

### Cache Management (inside kokoro-ios fork)

- Set `Memory.cacheLimit` to 0 or near-zero (≤20MB) — forces MLX to deallocate cached buffers
- Call `Memory.clearCache()` after each `generateAudio()` call — flushes buffer pool
- Test `mlx.core.metal.set_cache_enabled(false)` equivalent if available in mlx-swift
- These reduce IOAccelerator growth by ~30-70% but do NOT eliminate it (driver-level allocations persist)
- CRITICAL: MLX API calls MUST happen inside the kokoro-ios dylib, NOT the main binary (dual Metal device singleton causes resource limit crash — see mlx-metal-resource-crash.md)

### Process Restart Strategy (main binary)

- Add synthesis counter in TTSEngine tracking total `generateAudio()` calls
- After N calls (e.g., 10-15), trigger graceful `exit(0)` — launchd auto-restarts within 2 seconds
- This is the ONLY way to 100% reclaim IOAccelerator memory
- ThrottleInterval in launchd plist should be ≤5 seconds for fast restart
- SIGTERM handler already exists in main.swift for clean shutdown

### Batch-Then-Play Pattern (preserve)

- Synthesize ALL chunks first, THEN schedule all buffers on AVAudioEngine
- Zero GPU work during playback — eliminates audio stutters from memory bus contention
- This pattern is already implemented and working; must not regress

### Claude's Discretion

- Exact synthesis count threshold for restart (start with 10, tune based on profiling)
- Whether to use `exit(0)` or `exit(42)` with a specific code for "planned restart"
- Logging format for memory diagnostics
- Whether to add a /health endpoint field showing synthesis count and estimated memory

</decisions>

<canonical_refs>

## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Debug Sessions (root cause evidence)

- `.planning/debug/tts-periodic-audio-stutters.md` — Full stutter debug timeline
- `.planning/debug/profile-mlx-metal-memory.md` — vmmap profiling data
- `.planning/debug/spike-mlx-metal-memory.md` — Spike with exact memory measurements
- `.planning/debug/mlx-metal-resource-crash.md` — Why MLX API calls crash from main binary

### Source Code

- `plugins/claude-tts-companion/Sources/claude-tts-companion/TTSEngine.swift` — Synthesis engine with batch-then-play
- `plugins/claude-tts-companion/Sources/claude-tts-companion/AudioStreamPlayer.swift` — AVAudioEngine wrapper
- `plugins/claude-tts-companion/Sources/claude-tts-companion/SubtitleSyncDriver.swift` — Streaming subtitle sync
- `plugins/claude-tts-companion/Package.swift` — kokoro-ios dependency (terrylica/kokoro-ios fork)

### Upstream References

- ml-explore/mlx issue #1086 — "Leak memory on exit" (intentional by design)
- ml-explore/mlx issue #755 — Memory leak in MLX/Metal/MPS
- ml-explore/mlx PR #390 — `set_cache_enabled(False)` workaround
- hexgrad/kokoro issue #152 — Same symptom with Kokoro Python

</canonical_refs>

<specifics>
## Specific Ideas

### vmmap Profiling Data (from spike)

| State                    | IOAccelerator Regions | Physical Footprint |
| ------------------------ | --------------------- | ------------------ |
| Fresh                    | 6                     | 18 MB              |
| After 2 generateAudio()  | 1,645                 | 2.5 GB             |
| After 4 generateAudio()  | 2,784                 | 4.5 GB             |
| After 6 generateAudio()  | 3,644                 | 5.5 GB             |
| After 20 generateAudio() | 12,338                | 30.9 GB            |

### Python Workaround

`mlx.core.metal.set_cache_enabled(False)` — disables buffer caching entirely. Check if mlx-swift exposes equivalent API.

### launchd Restart Pattern

```swift
// After N synthesis calls, exit cleanly for launchd restart
let count = synthesisCounter.increment()
if count >= threshold {
    logger.warning("Synthesis count \(count) reached threshold — exiting for memory reclaim")
    exit(0)  // launchd KeepAlive restarts automatically
}
```

</specifics>

<deferred>
## Deferred Ideas

- Upstream fix to ml-explore/mlx-swift for Metal resource lifecycle
- XPC service isolation (run synthesis in separate process)
- Metal 4 residency sets for explicit GPU memory management (requires future macOS)
- Subprocess pool architecture (high complexity, low ROI for this use case)

</deferred>

---

_Phase: 20.1-mlx-metal-memory-lifecycle_
_Context gathered: 2026-03-27 via debug sessions + research agents_
