Skip to content

iOS: evict pooled iroh sessions that lose every path without a closure callback - #9190

Closed
azooz2003-bit wants to merge 1 commit into
mainfrom
issue-9178-session-close
Closed

azooz2003-bit wants to merge 1 commit into
mainfrom
issue-9178-session-close

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 30, 2026 •

Copy link
Copy Markdown
Collaborator

In the 2026-07-29 13:03 outage export (#9178), the foreground control session lost both its iroh paths (transportPathEvent closed for relay and privateNetwork) and then sat in the client session pool as a corpse for the remaining 73 s of the log: no sessionClosed, no close attribution, no eviction. CmxIrohClientSessionPool only detected death through the connection's waitUntilClosed() callback, and the iroh boundary never fired it.

Mechanism. The pool's per-session selected-path observation task now consumes the CmxIrohObservedConnectionPath value it previously discarded. .unavailable arms a bounded 15 s eviction deadline on the pool's injected CmxIrohRelayClock; any usable path (direct, privateNetwork, relay) disarms it. When the deadline fires, the pool re-reads the connection's live path state (level-triggered, so a recovery without an intervening change event still survives) and only then invalidates the session: connection closed, control owner released, closure recorded with a new append-only DiagnosticSessionLifecycleKind.allPathsClosed and a noRoute failure kind, so the next export names this eviction exactly. Timers are cancelled at every other pool-departure path (closure watcher, lane-failure invalidation, runtime deactivation/reconfiguration).

15 s sits above a normal path migration (the iroh detector fails over in ~1-3 RTT) and below the point where recovery visibly stalls; the constant is documented at the declaration.

Verification: 2 new behavior tests (CmxIrohClientSessionPoolPathEvictionTests) driving the real pool with a test connection: eviction fires after grace with correct attribution, and a path recovery inside the grace window disarms it (held-clock determinism, no wall-clock sleeps in the decision path). Full CmuxIrohTransport suite 496/496; CMUXMobileCore 306/306. A red/green commit split was not practical because the eviction API is new; the tests pin the outage behavior directly.

Fixes #9178

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Evicts iOS iroh sessions from the pool when all paths are lost and the closure callback never fires. Prevents zombie sessions and adds clear noRoute attribution, addressing cmux #9178.

  • Bug Fixes
    • CmxIrohClientSessionPool now consumes observed selected-path changes and arms a 15s eviction via CmxIrohRelayClock on .unavailable; any usable path cancels it.
    • At the deadline, re-checks live path state (level-triggered) and, if still unavailable, evicts: closes the connection, releases the control owner, and logs DiagnosticSessionLifecycleKind.allPathsClosed with noRoute.
    • Cancels eviction timers on every other pool exit (closure watcher, invalidation, runtime deactivation/reconfiguration).
    • Adds behavior tests covering eviction after grace and disarm on path recovery.

Written for commit fe5cc02. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Sessions with no usable network path are now evicted after a grace period instead of remaining open indefinitely.
    • Sessions are preserved when network connectivity recovers during the grace period.
    • Diagnostic reporting now identifies sessions closed because all network paths were unavailable.
  • Tests

    • Added coverage for path-loss eviction, recovery, and diagnostic events.

…lback

The 2026-07-29 13:03 outage export shows a foreground session whose relay
and privateNetwork paths both closed at 13:03:14 while the session object
stayed in the client pool for the remaining 73s of the log: no
sessionClosed, no close attribution, no eviction. The pool only noticed
death via waitUntilClosed(), and the iroh boundary never fired it.

The pool's selected-path observation now consumes the observed value it
previously discarded. When a session's selected path becomes .unavailable
it arms a bounded 15s eviction via the injected relay clock; any usable
path disarms it. At the deadline the pool re-reads live path state
(level-triggered, not event-trusting) and, if still unavailable,
invalidates the session with a new append-only allPathsClosed lifecycle
kind and a noRoute failure, closing the connection and releasing the
control owner. Eviction timers are cancelled wherever the session leaves
the pool (closure watcher, invalidation, runtime deactivation).

Fixes #9178

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

All-paths-closed eviction

Layer / File(s) Summary
Eviction control flow and lifecycle classification
Packages/Shared/CMUXMobileCore/..., Packages/Shared/CmuxIrohTransport/...
Adds allPathsClosed = 11 and schedules bounded eviction for sessions whose selected path remains unavailable, including cleanup during invalidation and closure.
Deterministic eviction validation
Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/...
Adds controllable clocks and tests for post-grace eviction, diagnostic attribution, and recovery that cancels pending eviction.

Estimated code review effort: 4 (Complex) | ~40 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SelectedPathObserver
  participant CmxIrohClientSessionPool
  participant Session
  participant DiagnosticLog
  SelectedPathObserver->>CmxIrohClientSessionPool: report unavailable path
  CmxIrohClientSessionPool->>CmxIrohClientSessionPool: arm grace-window eviction
  CmxIrohClientSessionPool->>Session: recheck path state
  CmxIrohClientSessionPool->>Session: invalidate with allPathsClosed/noRoute
  Session->>DiagnosticLog: record closure and lifecycle removal
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error Production CmxIrohClientSessionPool adds a 15s eviction task that uses clock.sleep(until:) to coordinate invalidation, which the rule forbids in shipped code. Replace the deadline task with a real signal/state transition (e.g. path-state callback/async stream/actor message). Keep timed waits only in tests or non-runtime scaffolding.
Cmux Architecture Rethink ❌ Error Adds a pool-owned grace timer/eviction map to paper over missing closure callbacks; this delayed-repair path is exactly what the rule forbids. Move the no-path→closed transition into the session/transport state machine (or bridge) and let the pool react to that single authoritative event; remove the pool-owned deadline.
✅ Passed checks (23 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main behavior change: evicting pooled iroh sessions that lose all paths without closure callbacks.
Description check ✅ Passed The description includes a summary, testing, review trigger, and checklist, and the missing demo video is non-critical here.
Linked Issues check ✅ Passed The changes implement bounded eviction, emit sessionClosed/close attribution, and add recovery tests, matching #9178.
Out of Scope Changes check ✅ Passed The enum, clock injection, and test-helper changes support the eviction work and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Cmux Swift Actor Isolation ✅ Passed PASS: The new eviction state stays inside the CmxIrohClientSessionPool actor, and the injected CmxIrohRelayClock protocol is Sendable; no MainActor coupling was introduced.
Cmux Browser Automation Off-Main ✅ Passed PR only changes CMUXMobileCore/CmuxIrohTransport session-pool code/tests; no browser.* commands, WebKit/AppKit waits, or routing policy are touched.
Cmux Expensive Synchronous Load ✅ Passed The PR only adds async path-eviction logic in an actor/test helpers; no RestorableAgentSessionIndex.load(), JSON/file scans, or @MainActor interactive loads were introduced.
Cmux Cache Substitution Correctness ✅ Passed No cache-substitution regression: eviction re-reads live path state at expiry, and selectedObservedPath still queries the live session.
Cmux No Hacky Sleeps ✅ Passed Diff is Swift-only; the rule excludes Swift, and the only sleeps are test scaffolding or injected-clock, cancellation-aware deadlines.
Cmux Algorithmic Complexity ✅ Passed PASS: New eviction handling uses keyed O(1) lookups/cancels; any collection scans are sequential or test-only, and the PR doesn’t introduce nested rescans in hot production paths.
Cmux Swift Concurrency ✅ Passed No new Dispatch/Combine/completion-handler patterns; the new eviction Task is stored in a map and cancelled on recovery/session exit.
Cmux Swift @Concurrent ✅ Passed The diff only adds actor-isolated eviction/tasks and test clocks; no invalid @concurrent or missing @concurrent on new nonisolated async work appears.
Cmux Swift Package Boundaries ✅ Passed All production changes are in package targets (CMUXMobileCore and CmuxIrohTransport); the new eviction logic is not in the app target, and tests live under the package test target.
Cmux Swiftpm Lockfiles ✅ Passed Commit changes only Swift source/tests; no Package.resolved, Package.swift, .gitignore, workflow, or Xcode package-ref diffs were present, and package .gitignores don’t ignore Package.resolved.
Cmux Swift Logging ✅ Passed No added print/NSLog/dump/Logger usage in the changed Swift files or HEAD patch; the new test code only uses DiagnosticLog APIs.
Cmux User-Facing Error Privacy ✅ Passed Production changes only add internal diagnostic taxonomy/session-pool eviction logic; no user-facing error text or raw upstream/provider details were introduced.
Cmux Full Internationalization ✅ Passed Changed files add transport logic, diagnostics, and tests only; no localized Swift UI/text, xcstrings, or web locale files were touched.
Cmux Swiftui State Layout ✅ Passed Touched files are diagnostics, transport, and tests; no SwiftUI views or state/layout patterns from the rule were introduced.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR only touches diagnostics, session-pool transport logic, and tests; no NSWindow/NSPanel/WindowGroup code or cmuxAuxiliaryWindowIdentifiers changes are present.
Cmux Source Artifacts ✅ Passed Diff only touches intentional source and test files; no artifact, cache, build, or scratch paths appear.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Patch adds private eviction logic only; no DEBUG/test-only accessor or seam appears in production Sources.
Cmux No Ambient Global State ✅ Passed Production changes stay inside an actor and an enum case; no new file-scope funcs/vars or singleton-style namespace was introduced, and eviction state is actor-owned with injected clock.
✨ 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 issue-9178-session-close

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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
`@Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolPathEvictionTests.swift`:
- Around line 128-133: Replace the fixed 50ms Task.sleep in the test assertion
around selectedObservedPath with deterministic synchronization using
HoldingGraceClock state, preferably verifying that release() did not re-arm an
eviction task. If state inspection is unavailable, use a bounded polling loop
that waits for the relevant condition without relying on a single wall-clock
delay, while preserving the observedCloseCallCount() == 0 assertion.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a5f3d2d4-7250-49fa-aea5-60f3b22eceae

📥 Commits

Reviewing files that changed from the base of the PR and between 3136fca and fe5cc02.

📒 Files selected for processing (4)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift
  • Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionPool.swift
  • Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolPathEvictionTests.swift
  • Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolTests.swift

Comment on lines +128 to +133
// Give a mistaken eviction every chance to land before asserting.
await Task.yield()
try await Task.sleep(nanoseconds: 50_000_000)
#expect(await connection.observedCloseCallCount() == 0)
#expect(await pool.selectedObservedPath() == .relay(url: "https://relay.example"))
}

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 | 🔵 Trivial | ⚡ Quick win

Fixed real-time sleep before a negative assertion.

The fixed Task.sleep(nanoseconds: 50_000_000) before asserting observedCloseCallCount() == 0 is a wall-clock-dependent, single fixed-duration wait immediately preceding a correctness assertion. If a mistaken eviction landed slightly later than 50ms (e.g. under CI load), this passes without ever exercising the failure path; conversely it could occasionally flake. Since HoldingGraceClock already tracks state, prefer a deterministic check (e.g. assert no eviction task was re-armed after release()) or a bounded poll loop instead of a single fixed wait.

♻️ Example bounded-poll alternative
-        // Give a mistaken eviction every chance to land before asserting.
-        await Task.yield()
-        try await Task.sleep(nanoseconds: 50_000_000)
-        `#expect`(await connection.observedCloseCallCount() == 0)
+        // Poll for a bounded window to give a mistaken eviction every chance
+        // to land, without depending on a single fixed-duration wait.
+        for _ in 0..<10 {
+            `#expect`(await connection.observedCloseCallCount() == 0)
+            try? await Task.sleep(nanoseconds: 5_000_000)
+        }
         `#expect`(await pool.selectedObservedPath() == .relay(url: "https://relay.example"))

As per coding guidelines, {cmuxTests,cmuxUITests,ios/cmuxUITests,Packages/**/Tests,tests,tests_v2,web/tests,webviews/test}/**: "Do not use fixed sleeps, measured wall-clock assertions, or hard absolute latency ceilings in correctness tests." Note this conflicts with the **/*Tests.swift carve-out ("Test-only synchronization or sleeps are allowed") that also matches this filename; flagging per the more specific, directly-applicable test-timing rule.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Give a mistaken eviction every chance to land before asserting.
await Task.yield()
try await Task.sleep(nanoseconds: 50_000_000)
#expect(await connection.observedCloseCallCount() == 0)
#expect(await pool.selectedObservedPath() == .relay(url: "https://relay.example"))
}
// Poll for a bounded window to give a mistaken eviction every chance
// to land, without depending on a single fixed-duration wait.
for _ in 0..<10 {
`#expect`(await connection.observedCloseCallCount() == 0)
try? await Task.sleep(nanoseconds: 5_000_000)
}
`#expect`(await pool.selectedObservedPath() == .relay(url: "https://relay.example"))
}
🤖 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
`@Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolPathEvictionTests.swift`
around lines 128 - 133, Replace the fixed 50ms Task.sleep in the test assertion
around selectedObservedPath with deterministic synchronization using
HoldingGraceClock state, preferably verifying that release() did not re-arm an
eviction task. If state inspection is unavailable, use a bounded polling loop
that waits for the relevant condition without relying on a single wall-clock
delay, while preserving the observedCloseCallCount() == 0 assertion.

Source: Coding guidelines

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Status after connectivity v2: session eviction on lost paths ships in v2 (unavailableSelectedPathEvictsTheSessionAndTheNextOperationRedials), and the 15s all-paths-closed grace eviction is being ported into CmxConnectivityPeerSession by #9241. Looks fully superseded once 9241 lands.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: session whose iroh paths all closed is never marked closed (no sessionClosed, stale pool entry)

3 participants