Repository navigation
iOS: evict pooled iroh sessions that lose every path without a closure callback #9190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
155 changes: 155 additions & 0 deletions
155
...rohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolPathEvictionTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import CMUXMobileCore | ||
| import Foundation | ||
| import Testing | ||
| @testable import CmuxIrohTransport | ||
|
|
||
| /// Completes eviction grace windows immediately so the all-paths-closed | ||
| /// deadline fires as soon as it is armed. | ||
| private struct ImmediateGraceClock: CmxIrohRelayClock { | ||
| private let date = Date(timeIntervalSince1970: 1_800_000_000) | ||
|
|
||
| func now() -> Date { date } | ||
| func sleep(until _: Date) async throws {} | ||
| } | ||
|
|
||
| /// Holds every grace-window sleep until the test releases it, so path | ||
| /// recovery can be observed disarming the eviction deterministically. | ||
| private actor HoldingGraceClock: CmxIrohRelayClock { | ||
| private let date = Date(timeIntervalSince1970: 1_800_000_000) | ||
| private var waiters: [CheckedContinuation<Void, Never>] = [] | ||
| private var sleepCount = 0 | ||
|
|
||
| nonisolated func now() -> Date { date } | ||
|
|
||
| func sleep(until _: Date) async throws { | ||
| sleepCount += 1 | ||
| await withCheckedContinuation { waiters.append($0) } | ||
| } | ||
|
|
||
| func release() { | ||
| let resumable = waiters | ||
| waiters = [] | ||
| for waiter in resumable { waiter.resume() } | ||
| } | ||
|
|
||
| func observedSleepCount() -> Int { sleepCount } | ||
|
|
||
| func waitForSleeper() async -> Bool { | ||
| for _ in 0..<400 { | ||
| if sleepCount > 0 { return true } | ||
| try? await Task.sleep(nanoseconds: 5_000_000) | ||
| } | ||
| return sleepCount > 0 | ||
| } | ||
| } | ||
|
|
||
| @Suite | ||
| struct CmxIrohClientSessionPoolPathEvictionTests { | ||
| @Test | ||
| func sessionWithNoUsablePathIsEvictedAndAttributedAfterGrace() async throws { | ||
| let fixture = try PoolFixture() | ||
| let connection = TestIrohConnection( | ||
| remoteIdentity: fixture.remoteIdentity, | ||
| bidirectionalStreams: [fixture.controlStream()], | ||
| selectedPath: .privateNetwork | ||
| ) | ||
| let endpoint = TestDialingIrohEndpoint( | ||
| localIdentity: fixture.localIdentity, | ||
| dialResults: [.connection(connection)] | ||
| ) | ||
| let diagnosticLog = DiagnosticLog() | ||
| let pool = try await fixture.pool( | ||
| endpoint: endpoint, | ||
| generation: 1, | ||
| diagnosticLog: diagnosticLog, | ||
| clock: ImmediateGraceClock() | ||
| ) | ||
|
|
||
| let transport = try CmxIrohByteTransportFactory(sessionPool: pool) | ||
| .makeTransport(for: fixture.request) | ||
| try await transport.connect() | ||
| #expect(await pool.selectedObservedPath() == .privateNetwork) | ||
|
|
||
| await connection.setObservedSelectedPath(.unavailable) | ||
|
|
||
| let evicted = await pollUntilTrue { | ||
| let pathUnavailable = await pool.selectedObservedPath() == .unavailable | ||
| let closed = await connection.observedCloseCallCount() > 0 | ||
| return pathUnavailable && closed | ||
| } | ||
| #expect(evicted) | ||
|
|
||
| let events = await drainedEvents(from: diagnosticLog) | ||
| let closure = events.last { $0.code == .sessionClosed } | ||
| #expect(closure != nil) | ||
| #expect(closure?.diagnosticFailureKind == .noRoute) | ||
| let lifecycleRemoval = events.last { event in | ||
| event.code == .transportSessionLifecycle | ||
| && event.diagnosticSessionLifecycleKind == .allPathsClosed | ||
| } | ||
| #expect(lifecycleRemoval != nil) | ||
| } | ||
|
|
||
| @Test | ||
| func pathRecoveryWithinGraceDisarmsTheEviction() async throws { | ||
| let fixture = try PoolFixture() | ||
| let connection = TestIrohConnection( | ||
| remoteIdentity: fixture.remoteIdentity, | ||
| bidirectionalStreams: [fixture.controlStream()], | ||
| selectedPath: .privateNetwork | ||
| ) | ||
| let endpoint = TestDialingIrohEndpoint( | ||
| localIdentity: fixture.localIdentity, | ||
| dialResults: [.connection(connection)] | ||
| ) | ||
| let clock = HoldingGraceClock() | ||
| let pool = try await fixture.pool( | ||
| endpoint: endpoint, | ||
| generation: 1, | ||
| clock: clock | ||
| ) | ||
|
|
||
| let transport = try CmxIrohByteTransportFactory(sessionPool: pool) | ||
| .makeTransport(for: fixture.request) | ||
| try await transport.connect() | ||
|
|
||
| await connection.setObservedSelectedPath(.unavailable) | ||
| #expect(await clock.waitForSleeper()) | ||
|
|
||
| // The path recovers while the grace window is still pending; releasing | ||
| // the window afterwards must not evict the healthy session. | ||
| await connection.setObservedSelectedPath(.relay(url: "https://relay.example")) | ||
| let recovered = await pollUntilTrue { | ||
| await pool.selectedObservedPath() == .relay(url: "https://relay.example") | ||
| } | ||
| #expect(recovered) | ||
| await clock.release() | ||
|
|
||
| // 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")) | ||
| } | ||
| } | ||
|
|
||
| private func pollUntilTrue( | ||
| _ condition: @escaping () async -> Bool | ||
| ) async -> Bool { | ||
| for _ in 0..<400 { | ||
| if await condition() { return true } | ||
| try? await Task.sleep(nanoseconds: 5_000_000) | ||
| } | ||
| return await condition() | ||
| } | ||
|
|
||
| private func drainedEvents(from log: DiagnosticLog) async -> [DiagnosticEvent] { | ||
| for _ in 0..<400 { | ||
| let report = await log.snapshot() | ||
| if report.events.contains(where: { $0.code == .sessionClosed }) { | ||
| return report.events | ||
| } | ||
| try? await Task.sleep(nanoseconds: 5_000_000) | ||
| } | ||
| return await log.snapshot().events | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 assertingobservedCloseCallCount() == 0is 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. SinceHoldingGraceClockalready tracks state, prefer a deterministic check (e.g. assert no eviction task was re-armed afterrelease()) or a bounded poll loop instead of a single fixed wait.♻️ Example bounded-poll alternative
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.swiftcarve-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
🤖 Prompt for AI Agents
Source: Coding guidelines