Skip to content

fix(residency): a failed eviction unload must not over-commit memory (OOM guard)#454

Closed
alichherawalla wants to merge 1 commit into
mainfrom
fix/residency-evict-unload-failure
Closed

fix(residency): a failed eviction unload must not over-commit memory (OOM guard)#454
alichherawalla wants to merge 1 commit into
mainfrom
fix/residency-evict-unload-failure

Conversation

@alichherawalla

@alichherawalla alichherawalla commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Problem (audit A2)

makeRoomFor did await reg.unload().catch(log); this.residents.delete(key) and returned fits from the pre-unload plan. A victim whose native unload rejected (still holding RAM) was deleted from the budget map and counted as freed → the main load path (activeModelService/index.ts:169, which honors fits) loaded the incoming model on top → OOM/jetsam. Same 'failure treated as success' class as the KB embedding bug.

Fix

Only delete + count a victim as evicted when its unload() actually resolves. On the first rejection, keep that resident and return fits:false → the caller surfaces the clean 'insufficient-memory' error instead of over-committing.

Tests (fails-before/passes-after)

  • A rejecting unload keeps the resident and returns fits:false (was: deleted + fits:true).
  • With two victims, the one that truly unloaded is reported evicted; the failed one stays resident.

Provit

Pending — exercised by the memory/OOM journeys (KUF: Model routing); runs when the oracle's back. Self-audit below.

Follow-up (not here)

ensureResident and whisperStore call makeRoomFor without honoring fits — a separate pre-existing gap.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved model residency handling so failed unloads no longer appear successful.
    • If an eviction cannot complete, the app now correctly reports that there is not enough room and keeps the affected model resident.
    • When multiple items are being evicted, only the ones that were actually removed are reported as freed, preventing inaccurate load decisions.

makeRoomFor swallowed a rejecting native unload (.catch) and deleted the resident
anyway, then returned fits based on the pre-unload PLAN. So a victim whose unload
failed (still holding its RAM) was treated as freed → the caller (activeModelService,
which honors fits at index.ts:169) loaded the incoming model on top → OOM/jetsam.

Now: only delete + count a victim as evicted when its unload actually resolves. On the
first unload rejection, keep that resident and return fits:false, so the caller surfaces
a clean 'insufficient-memory' error instead of over-committing and crashing.

Test (fails-before/passes-after): a rejecting unload keeps the resident and returns
fits:false; with two victims, the one that truly unloaded is reported evicted and the
failed one stays resident.

Follow-up (noted, not in this PR): ensureResident and whisperStore call makeRoomFor
without honoring fits — a separate pre-existing gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alichherawalla

Copy link
Copy Markdown
Collaborator Author

Self-audit

SOLID / abstraction

  • Fix stays inside the owning service (ModelResidencyManager.makeRoomFor), the single owner of the budget/eviction state machine. No caller change, no concrete/Platform.OS branch.
  • Single source of truth: fits now reflects what was ACTUALLY freed, not the optimistic plan — the budget map and the return value can't disagree.
  • Verdict: clean. (Deliberately narrow: does not touch ensureResident/whisperStore ignoring fits — flagged as a separate follow-up so this PR stays one concern.)

Tests — no false green

  • Drives the REAL makeRoomFor + real planEviction; mocks only hardwareService (the memory-probe boundary) and the residents' unload fns (native boundary). Deleting the fix makes both tests fail (resident deleted, fits true).
  • Fails-before/passes-after on both the single-victim and two-victim (partial-free-then-fail) cases.

Provit (on-device E2E)

  • Pending (oracle down). Covered by the memory/OOM eviction journeys; runs when the vision gateway is reachable. Not merge-ready until then per CLAUDE.md.

Platform parity

  • Platform-agnostic (residency manager is shared). One test guards both. The OOM this prevents is worse on iOS (jetsam) but the code path is identical.

Standards

  • No UI/copy change. N/A.

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The eviction loop in ModelResidencyManager.makeRoomFor now treats native unload failures as fatal, halting eviction, retaining the failed resident, and returning fits: false with only successfully unloaded victims. Two regression tests were added covering single and multi-victim unload failure scenarios.

Changes

Eviction Failure Handling

Layer / File(s) Summary
Eviction loop hard-stop on unload failure
src/services/modelResidency/index.ts
makeRoomFor now builds evicted only from successfully unloaded residents; on the first unload failure, it logs the error, keeps the failing resident registered, and returns fits: false immediately instead of always deleting victims.
Regression tests for unload failure scenarios
__tests__/unit/services/modelResidency.test.ts
New tests assert that a single failing unload yields fits: false, evicted: [], and a retained resident, and that a later failing unload among multiple victims yields fits: false with only prior successful evictions reported.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ModelResidencyManager
  participant ResidentUnload

  Caller->>ModelResidencyManager: makeRoomFor(plan)
  loop for each planned victim
    ModelResidencyManager->>ResidentUnload: unload()
    alt unload succeeds
      ResidentUnload-->>ModelResidencyManager: success
      ModelResidencyManager->>ModelResidencyManager: add to evicted, delete resident
    else unload throws
      ResidentUnload-->>ModelResidencyManager: error
      ModelResidencyManager->>ModelResidencyManager: keep resident, log error
      ModelResidencyManager-->>Caller: return { evicted, fits: false }
    end
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the core residency eviction fix and its OOM-guard impact.
Description check ✅ Passed The description explains the bug, fix, tests, and follow-up, but it omits several template sections like Type of Change and Checklist.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/residency-evict-unload-failure

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Prevent memory over-commit when residency eviction unload fails

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Treat native unload rejections as non-evicted; return fits:false to prevent OOM.
• Preserve resident entries until unload resolves; report partial evictions accurately.
• Add unit tests for rejecting unloads and mixed success/failure eviction plans.
Diagram

graph TD
AMS["ActiveModelService"] --> MRM["ModelResidencyManager.makeRoomFor"] --> PE["planEviction(...)"] --> FITS{Plan fits?}
FITS -->|no| RETNO["return {fits:false}"] --> ERR["Caller surfaces insufficient-memory"]
FITS -->|yes| UNLOAD["Unload planned victims"] --> OK{Each unload resolves?}
OK -->|yes| RES[("Residents map")] --> RETYES["return {fits:true, evicted[]}"] --> LOAD["Caller proceeds to load"]
OK -->|no| RETPART["return {fits:false, evicted:partial}"] --> ERR
subgraph Legend
direction LR
_svc["Service/Module"] ~~~ _dec{Decision} ~~~ _state[(State)]
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Continue evicting after a failure and recompute actual fit
  • ➕ May still succeed in making room if later victims unload successfully
  • ➕ Avoids refusing loads that could fit despite one failed victim
  • ➖ More complex control flow and bookkeeping (must recompute usedMB vs budget from actual successful evictions)
  • ➖ Could evict more than necessary before determining final fits
2. Throw on unload failure instead of returning fits:false
  • ➕ Makes unload failure explicit and harder for callers to ignore
  • ➕ Can preserve error details for telemetry/diagnostics
  • ➖ Potentially breaking API/behavior change for existing callers
  • ➖ May couple low-level native unload errors to higher-level load flows
3. Mark failed-unload residents as non-evictable (quarantine) until manual release
  • ➕ Prevents repeated eviction attempts that will likely fail again
  • ➕ Makes residency state reflect reality (still resident, cannot be freed)
  • ➖ Requires additional state and policy decisions (when/how to clear quarantine)
  • ➖ May reduce ability to recover memory automatically

Recommendation: The PR’s conservative approach is the right safety default: only count an eviction when unload resolves, and refuse on first unload rejection to prevent over-commit/OOM. If refusal rate becomes an issue, a follow-up could continue evicting remaining victims and recompute actual fit based on successful unloads, but that should be added carefully with tests and clear accounting.

Files changed (2) +55 / -3

Bug fix (1) +16 / -3
index.tsRefuse to over-commit when victim unload fails; only delete on success +16/-3

Refuse to over-commit when victim unload fails; only delete on success

• Changes makeRoomFor to only delete residents and record evictions after unload() resolves. On the first unload rejection, it logs the failure, keeps the resident, and returns fits:false with any victims already successfully evicted, preventing callers from loading on top of still-held RAM.

src/services/modelResidency/index.ts

Tests (1) +39 / -0
modelResidency.test.tsAdd regression tests for unload-failure eviction semantics +39/-0

Add regression tests for unload-failure eviction semantics

• Adds unit coverage ensuring makeRoomFor returns fits:false and retains a resident when a planned victim unload rejects. Also verifies partial success reporting: successfully-unloaded victims are returned in evicted while the failed victim remains resident.

tests/unit/services/modelResidency.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/services/modelResidency/index.ts`:
- Around line 246-265: `makeRoomFor` now preserves the residency invariant by
only deleting after a successful `unload()`, but `handleMemoryWarning` and
`reclaimSttForGeneration` still bypass that behavior by deleting on unload
failure. Centralize the eviction state transition in a shared helper on
`ModelResidency` that attempts `unload()`, deletes only on success, and returns
the same “kept resident/refuse to over-commit” outcome on failure. Then update
`makeRoomFor`, `handleMemoryWarning`, and `reclaimSttForGeneration` to use that
helper so every residency-owned eviction/reclaim path follows the same
unload/delete rule.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3852102e-9a9c-423c-9062-dd2bd9c7c54e

📥 Commits

Reviewing files that changed from the base of the PR and between a0535cd and 4341c8d.

📒 Files selected for processing (2)
  • __tests__/unit/services/modelResidency.test.ts
  • src/services/modelResidency/index.ts

Comment on lines +246 to +265
const evicted: string[] = [];
for (const victim of plan.evict) {
const reg = this.residents.get(victim.key);
if (!reg) continue;
await reg.unload().catch(err => logger.log(`[ModelResidency] unload ${victim.key} failed:`, err));
this.residents.delete(victim.key);
try {
await reg.unload();
this.residents.delete(victim.key);
evicted.push(victim.key);
} catch (err) {
// Native unload FAILED — the model may still be holding its RAM. Deleting it
// here (the old behaviour) made the budget believe memory was freed when it
// wasn't, so the caller loaded the incoming model on top → OOM/jetsam. Keep it
// resident and report room as NOT made; the caller refuses/defers the load
// instead of over-committing and crashing. (Successfully-evicted victims stay
// evicted — they are genuinely gone.)
logger.log(`[ModelResidency] unload ${victim.key} failed — keeping it resident, refusing to over-commit:`, err);
return { evicted, fits: false };
}
}
return { evicted: plan.evict.map(e => e.key), fits: plan.fits };
return { evicted, fits: plan.fits };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Apply this unload/delete invariant to every residency-owned eviction path.

makeRoomFor now only deletes after unload() resolves, but handleMemoryWarning and reclaimSttForGeneration still catch unload failures and delete anyway. A failed native unload in those paths can still leave RAM allocated while the budget map undercounts it, so a later load can over-commit.

Consider centralizing this into one helper and using it from all eviction/reclaim paths.

Suggested direction
+  private async unloadAndRelease(key: string, context: string): Promise<boolean> {
+    const reg = this.residents.get(key);
+    if (!reg) return true;
+    try {
+      await reg.unload();
+      this.residents.delete(key);
+      return true;
+    } catch (err) {
+      logger.log(`[ModelResidency] ${context} unload ${key} failed — keeping it resident:`, err);
+      return false;
+    }
+  }

Then have makeRoomFor, memory-warning reclaim, and STT reclaim all rely on this single state transition.

As per coding guidelines, “Fix the seam instead of patching around missing abstractions, and migrate paths through the owning service without bypasses.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/modelResidency/index.ts` around lines 246 - 265, `makeRoomFor`
now preserves the residency invariant by only deleting after a successful
`unload()`, but `handleMemoryWarning` and `reclaimSttForGeneration` still bypass
that behavior by deleting on unload failure. Centralize the eviction state
transition in a shared helper on `ModelResidency` that attempts `unload()`,
deletes only on success, and returns the same “kept resident/refuse to
over-commit” outcome on failure. Then update `makeRoomFor`,
`handleMemoryWarning`, and `reclaimSttForGeneration` to use that helper so every
residency-owned eviction/reclaim path follows the same unload/delete rule.

Source: Coding guidelines

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 25 rules

Grey Divider


Informational

1. No integration test for makeRoomFor 📘 Rule violation ▣ Testability
Description
The PR significantly changes ModelResidencyManager.makeRoomFor() behavior on eviction unload()
failure, but only adds unit tests and no integration test that exercises the behavior through a real
entry point (e.g., activeModelService.loadTextModel). This violates the requirement to include
both unit and integration coverage for significant behavior changes.
Code

src/services/modelResidency/index.ts[R246-263]

+    const evicted: string[] = [];
    for (const victim of plan.evict) {
      const reg = this.residents.get(victim.key);
      if (!reg) continue;
-      await reg.unload().catch(err => logger.log(`[ModelResidency] unload ${victim.key} failed:`, err));
-      this.residents.delete(victim.key);
+      try {
+        await reg.unload();
+        this.residents.delete(victim.key);
+        evicted.push(victim.key);
+      } catch (err) {
+        // Native unload FAILED — the model may still be holding its RAM. Deleting it
+        // here (the old behaviour) made the budget believe memory was freed when it
+        // wasn't, so the caller loaded the incoming model on top → OOM/jetsam. Keep it
+        // resident and report room as NOT made; the caller refuses/defers the load
+        // instead of over-committing and crashing. (Successfully-evicted victims stay
+        // evicted — they are genuinely gone.)
+        logger.log(`[ModelResidency] unload ${victim.key} failed — keeping it resident, refusing to over-commit:`, err);
+        return { evicted, fits: false };
+      }
Relevance

⭐ Low

Repo history/tools show src/services/modelResidency/index.ts doesn’t exist here; integration-test
requirement not evidenced for this code.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1573621 requires both unit and integration tests for significantly changed
behavior. The PR changes makeRoomFor() control flow to return early with fits:false when a
victim unload() rejects, and adds only unit coverage for this scenario, without adding/updating
any integration test to cover the behavior through the actual service entry point(s) that rely on
fits.

Rule 1573621: Require both unit and integration tests for new or significantly changed features
src/services/modelResidency/index.ts[246-263]
tests/unit/services/modelResidency.test.ts[402-439]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ModelResidencyManager.makeRoomFor()` now returns `fits:false` and keeps a resident when a planned eviction `unload()` rejects, but there is no integration test in this PR that validates the end-to-end behavior through a real entry point (e.g., `activeModelService.loadTextModel` / `activeModelService.checkMemoryForModel`).

## Issue Context
A unit regression test was added for the manager, but the compliance rule requires both unit and integration tests for significant behavior changes.

## Fix Focus Areas
- __tests__/integration/models/activeModelService.test.ts[131-171]
- src/services/modelResidency/index.ts[246-263]
- __tests__/unit/services/modelResidency.test.ts[402-439]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Callers ignore fits flag 🐞 Bug ☼ Reliability
Description
ModelResidencyManager.makeRoomFor() can now return { fits:false } when an eviction unload fails,
but whisperStore.loadModel() does not check fits and proceeds to load Whisper anyway, which can
still over-commit memory and OOM/jetsam. ensureResident() also discards fits, so any future use
of it can bypass the refusal signal and load even when residency explicitly could not make room.
Code

src/services/modelResidency/index.ts[R250-263]

+      try {
+        await reg.unload();
+        this.residents.delete(victim.key);
+        evicted.push(victim.key);
+      } catch (err) {
+        // Native unload FAILED — the model may still be holding its RAM. Deleting it
+        // here (the old behaviour) made the budget believe memory was freed when it
+        // wasn't, so the caller loaded the incoming model on top → OOM/jetsam. Keep it
+        // resident and report room as NOT made; the caller refuses/defers the load
+        // instead of over-committing and crashing. (Successfully-evicted victims stay
+        // evicted — they are genuinely gone.)
+        logger.log(`[ModelResidency] unload ${victim.key} failed — keeping it resident, refusing to over-commit:`, err);
+        return { evicted, fits: false };
+      }
Relevance

⭐ Low

Core API/file (ModelResidencyManager/makeRoomFor) not present in this repo per tooling, so “callers
ignore fits” can’t be enforced.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new makeRoomFor behavior returns fits:false on unload rejection, but
whisperStore.loadModel does not read the return value and always loads Whisper anyway, bypassing
the refusal signal. ensureResident similarly destructures only { evicted } from makeRoomFor,
so it would also proceed with a load even when fits:false.

src/services/modelResidency/index.ts[226-266]
src/stores/whisperStore.ts[118-145]
src/services/modelResidency/index.ts[268-283]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`makeRoomFor()` now explicitly refuses loads (`fits:false`) if a planned eviction unload rejects, but at least one caller (`whisperStore.loadModel`) ignores that signal and loads the model anyway. This bypasses the new OOM guard and can still lead to over-commit/jetsam under memory pressure or unload failure.

### Issue Context
- `makeRoomFor()` returns `{ fits:false }` both when the plan can’t fit and when a planned eviction unload fails.
- `whisperStore.loadModel` calls `makeRoomFor(...)` and then unconditionally calls `whisperService.loadModel(...)`.
- `ensureResident` also ignores `fits` (drops it), so it can load even when residency refused.

### Fix Focus Areas
- src/stores/whisperStore.ts[118-145]
- src/services/modelResidency/index.ts[268-283]

### Proposed fix
1. In `whisperStore.loadModel`, capture the return value of `makeRoomFor` and **bail out** when `fits === false` (throw an error that will be caught by the surrounding `try/catch`, or set store error state explicitly) before calling `whisperService.loadModel`.
  - Example behavior: throw `new Error('Not enough free memory to load Whisper right now.')` when `fits` is false.
2. In `ModelResidencyManager.ensureResident`, also honor `fits` (either throw on `!fits`, or extend `EnsureResult` to include `fits` and have callers handle it). At minimum, don’t proceed to `handlers.load()` when `makeRoomFor` returned `fits:false`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@sonarqubecloud

Copy link
Copy Markdown

@alichherawalla

Copy link
Copy Markdown
Collaborator Author

Closing — bundling the fix into #510 (refactor/parse-once-boundary). A falsifying red-flow test for this bug is committed on that branch (integration-level, mocks only the native boundary; verified red on HEAD, flips green when the fix lands).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant