Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/iroh-release-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ on:
- relay-expiry
- direct-only
- private-path
- cold-start

permissions:
contents: read
Expand Down Expand Up @@ -59,7 +60,7 @@ jobs:
strategy:
fail-fast: false
matrix:
mode: ${{ fromJSON(inputs.mode == 'all' && '["automatic","relay-only","relay-expiry","direct-only","private-path"]' || format('["{0}"]', inputs.mode)) }}
mode: ${{ fromJSON(inputs.mode == 'all' && '["automatic","relay-only","relay-expiry","direct-only","private-path","cold-start"]' || format('["{0}"]', inputs.mode)) }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Complete the cold-start workflow wiring.

The new matrix value skips both automatic-mode setup conditions. It also has no TAG case. With set -u, the gate exits when it expands $TAG.

Include cold-start in the conditions at Lines 83 and 89. Add a unique cold-start) TAG=... ;; case before invoking the script.

Based on the PR objective, cold-start must execute the automatic transport gate.

🤖 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 @.github/workflows/iroh-release-gate.yml at line 63, Update the workflow
conditions governing automatic-mode setup to include the cold-start matrix
value, specifically the branches near lines 83 and 89. Extend the TAG selection
case statement with a unique cold-start) assignment before invoking the gate
script, ensuring cold-start follows the automatic transport gate path and
initializes TAG under set -u.

runs-on: ${{ vars.MACOS_RUNNER_STREAMED_VALIDATION || 'warp-macos-15-arm64-6x' }}
timeout-minutes: 120
steps:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ public actor CmxIrohClientSession {
public typealias PrivateFallbackContextProvider = @Sendable () async throws -> CmxIrohClientContext

private let endpoint: any CmxIrohEndpoint
/// Bound on each dial phase. A peer that has not answered by then is
/// almost always a stale-hint or half-ready target (issue 9724 recorded a
/// 16s hang against a just-relaunched Mac); failing fast hands control to
/// the retry layer, which redials with fresher hints. Cancellation crosses
/// the FFI boundary, so the raced attempt aborts promptly.
private let dialPhaseTimeout: Duration
private let targetIdentity: CmxIrohPeerIdentity
private let dialPlan: CmxIrohDialPlan
private let credential: CmxIrohAdmissionCredential
Expand Down Expand Up @@ -44,6 +50,7 @@ public actor CmxIrohClientSession {
privateFallbackAuthorization: CmxIrohPrivateFallbackAuthorization? = nil,
privateFallbackValidator: (any CmxIrohPrivateFallbackValidating)? = nil,
privateFallbackContextProvider: PrivateFallbackContextProvider? = nil,
dialPhaseTimeout: Duration = .seconds(5),
protocolConfiguration: CmxIrohProtocolConfiguration = .cmuxMobileV1
) throws {
self.endpoint = endpoint
Expand All @@ -53,6 +60,7 @@ public actor CmxIrohClientSession {
self.privateFallbackAuthorization = privateFallbackAuthorization
self.privateFallbackValidator = privateFallbackValidator
self.privateFallbackContextProvider = privateFallbackContextProvider
self.dialPhaseTimeout = dialPhaseTimeout
self.protocolConfiguration = protocolConfiguration
headerCodec = try CmxIrohStreamHeaderCodec(configuration: protocolConfiguration)
}
Expand Down Expand Up @@ -278,17 +286,42 @@ public actor CmxIrohClientSession {
controlReceiveBuffer.removeAll(keepingCapacity: false)
}

/// Dials one address within `dialPhaseTimeout`, cancelling the attempt at
/// the bound so it cannot outlive the budget on a hung or stale path.
private func connectBounded(
to address: CmxIrohEndpointAddress
) async throws -> any CmxIrohConnection {
let endpoint = endpoint
let alpn = protocolConfiguration.alpn
let bound = dialPhaseTimeout
return try await withThrowingTaskGroup(
of: (any CmxIrohConnection)?.self
) { group in
group.addTask {
try await endpoint.connect(to: address, alpn: alpn)
}
group.addTask {
try await ContinuousClock().sleep(for: bound)
return nil
}
defer { group.cancelAll() }
guard let first = try await group.next(), let connection = first else {
throw CmxIrohClientSessionError.dialTimedOut
}
return connection
}
}

private func establishConnection() async throws -> CmxIrohConnectedControl {
var establishedConnection: (any CmxIrohConnection)?
var publicConnectionError: (any Error)?
if !dialPlan.publicPaths.isEmpty {
do {
establishedConnection = try await endpoint.connect(
establishedConnection = try await connectBounded(
to: CmxIrohEndpointAddress(
identity: targetIdentity,
pathHints: dialPlan.publicPaths
),
alpn: protocolConfiguration.alpn
)
Comment on lines +320 to +324

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add coverage for timeout-to-private fallback.

A public .dialTimedOut error must continue to the validated private fallback. The new test covers a terminal timeout without fallback. The existing fallback test uses .failure, not .hang.

Add a regression test with [.hang, .connection(...)]. Assert that the public path times out, the private path is validated and dialed, and admission completes.

Based on the PR objective, a timed-out public dial must proceed to private fallback.

🤖 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/Sources/CmuxIrohTransport/CmxIrohClientSession.swift`
around lines 320 - 324, Add a regression test for the CmxIrohClientSession
connection flow using the dial sequence [.hang, .connection(...)]. Assert the
public attempt produces a timeout, the validated private fallback is selected
and dialed, and admission completes successfully; keep the existing
terminal-timeout and failure-based fallback coverage unchanged.

)
} catch {
try Task.checkCancellation()
Expand Down Expand Up @@ -326,12 +359,11 @@ public actor CmxIrohClientSession {
authorization
)
try Task.checkCancellation()
establishedConnection = try await endpoint.connect(
establishedConnection = try await connectBounded(
to: CmxIrohEndpointAddress(
identity: targetIdentity,
pathHints: fallbackPaths
),
alpn: protocolConfiguration.alpn
)
)
}
guard let establishedConnection else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public enum CmxIrohClientSessionError: Error, Equatable, Sendable {
/// A role-invalid frame appeared during the admission barrier.
case invalidAdmissionFrame

/// A dial phase exceeded its bound before the peer answered.
case dialTimedOut

/// An operation required an admitted control stream.
case notConnected

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ extension CmxIrohClientSessionError: DiagnosticFailureProviding {
.identityMismatch
case .admissionDenied:
.admissionDenied
case .dialTimedOut:
.timedOut
case .alreadyClosed, .notConnected, .unexpectedEndOfStream:
.connectionClosed
case .invalidAdmissionFrame, .invalidMaximumByteCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ public actor CmxIrohHostRuntime {
let registrationClock: any CmxIrohRelayClock
let registrationRetrySchedule: CmxIrohRetrySchedule
let registrationRetryJitter: @Sendable () -> Double
/// How long automatic-mode startup waits for relay attachment before
/// publishing the bootstrap binding anyway. Correctness over liveness only
/// within this bound; a timeout never fails activation.
let automaticRelayReadinessTimeout: Duration
let handleTransport: TransportHandler
let handleBinding: BindingHandler
let handleRoute: RouteHandler
Expand Down Expand Up @@ -129,6 +133,7 @@ public actor CmxIrohHostRuntime {
registrationRetryJitter: @escaping @Sendable () -> Double = {
Double.random(in: 0 ... 1)
},
automaticRelayReadinessTimeout: Duration = .seconds(5),
handleTransport: @escaping TransportHandler,
handleBinding: @escaping BindingHandler = { _, _, _ in },
handleRoute: @escaping RouteHandler = { _, _ in },
Expand All @@ -147,6 +152,7 @@ public actor CmxIrohHostRuntime {
self.registrationClock = registrationClock
self.registrationRetrySchedule = registrationRetrySchedule
self.registrationRetryJitter = registrationRetryJitter
self.automaticRelayReadinessTimeout = automaticRelayReadinessTimeout
self.handleTransport = handleTransport
self.handleBinding = handleBinding
self.handleRoute = handleRoute
Expand Down Expand Up @@ -275,6 +281,7 @@ public actor CmxIrohHostRuntime {
bindingID: policy.binding.bindingID
)
var publishedPolicy = policy
var relayActivatedDuringStart = false
let requiresRelayReadiness = !protocolConfiguration
.allowsNATTraversalAfterAdmission
if requiresRelayReadiness {
Expand Down Expand Up @@ -314,6 +321,58 @@ public actor CmxIrohHostRuntime {
// The online event that released the barrier is already folded
// into `readyPolicy`; do not immediately publish a third copy.
registrationRefreshPending = false
} else if let relayCoordinator {
// Register-when-ready for automatic mode: the bootstrap
// registration above happens before relay attachment, so its
// advertised hints describe a half-ready endpoint that phones
// dial and lose against (issue 9724). Start relay activation
// now (async, its coordinator owns retries exactly as in the
// scheduled path below) and give the relay a bounded window
// to become usable so the first published binding carries
// dialable hints. Unlike the strict relay-only barrier,
// nothing here may fail or stall activation: on timeout or
// broker error the bootstrap policy stands and the standard
// refresh-on-online path repairs the registration.
scheduleRelayActivation(
relayCoordinator,
binding: policy.binding,
endpointID: endpointID,
bootstrap: policy.relayBootstrap,
revision: revision
)
relayActivatedDuringStart = true
do {
try await connectivityEngine.waitForUsableHomeRelay(
timeout: automaticRelayReadinessTimeout
)
try requireCurrent(revision)
let readyPolicy = try await resolvePolicy(
engine: connectivityEngine,
expectedEndpointID: endpointID,
revision: revision,
allowCachedFallback: false
)
try requireCurrent(revision)
if readyPolicy.binding.bindingID == policy.binding.bindingID {
await admissionController.update(
keys: readyPolicy.grantVerificationKeys,
acceptor: grantPeer(for: readyPolicy.binding),
pairingEnabled: readyPolicy.pairingEnabled
)
try requireCurrent(revision)
localBinding = readyPolicy.binding
endpointAttestation = readyPolicy.attestation
?? endpointAttestation
lanRendezvous = readyPolicy.lanRendezvous
publishedPolicy = readyPolicy
registrationRefreshPending = false
}
} catch is CancellationError {
throw CancellationError()
} catch {
// Supersession still aborts; readiness failures do not.
try requireCurrent(revision)
}
}
let publishedFreshBinding: Bool
if let registration = publishedPolicy.registration,
Expand Down Expand Up @@ -355,7 +414,8 @@ public actor CmxIrohHostRuntime {
registrationRefreshPending = false
scheduleRegistrationRefresh(revision: revision)
}
if let relayCoordinator, !requiresRelayReadiness {
if let relayCoordinator, !requiresRelayReadiness,
!relayActivatedDuringStart {
scheduleRelayActivation(
relayCoordinator,
binding: policy.binding,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ struct CmxIrohClientSessionTests {
credential = try .pairGrant("e30.e30.AA")
}

@Test("a hung dial fails at the phase bound instead of hanging")
func hungDialFailsAtThePhaseBound() async throws {
let endpoint = TestDialingIrohEndpoint(
localIdentity: localIdentity,
dialResults: [.hang]
)
let session = try CmxIrohClientSession(
endpoint: endpoint,
targetIdentity: remoteIdentity,
dialPlan: try testIrohDialPlan(publicPaths: [try publicRelayHint()]),
credential: credential,
dialPhaseTimeout: .milliseconds(40)
)
let clock = ContinuousClock()
let started = clock.now
await #expect(throws: CmxIrohClientSessionError.dialTimedOut) {
try await session.connect()
}
#expect(clock.now - started < .seconds(2))
Comment on lines +31 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the wall-clock assertion.

ContinuousClock and the two-second ceiling make this test depend on runner scheduling. Keep the typed .dialTimedOut assertion. If cancellation needs separate coverage, expose a fixture completion signal and await it.

Proposed fix
-        let clock = ContinuousClock()
-        let started = clock.now
         await `#expect`(throws: CmxIrohClientSessionError.dialTimedOut) {
             try await session.connect()
         }
-        `#expect`(clock.now - started < .seconds(2))

As per coding guidelines, tests must not use wall-clock assertions or hard absolute latency ceilings.

📝 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
let clock = ContinuousClock()
let started = clock.now
await #expect(throws: CmxIrohClientSessionError.dialTimedOut) {
try await session.connect()
}
#expect(clock.now - started < .seconds(2))
await `#expect`(throws: CmxIrohClientSessionError.dialTimedOut) {
try await session.connect()
}
🤖 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/CmxIrohClientSessionTests.swift`
around lines 31 - 36, Remove the ContinuousClock setup, started timestamp, and
elapsed-time assertion from CmxIrohClientSessionTests. Keep the await
session.connect() check asserting the typed
CmxIrohClientSessionError.dialTimedOut result; add cancellation completion
signaling only if separate cancellation coverage is required.

Source: Coding guidelines

}

@Test
func publicDialAdmitsControlAndPreservesFollowingRPCBytes() async throws {
let events = TestIrohEventRecorder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,20 @@ extension CmxIrohHostRuntimeTests {
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
// The in-start readiness pass may wait this long for the relay,
// but a hung credential installation must never block activation
// or binding publication beyond it.
automaticRelayReadinessTimeout: .milliseconds(20),
handleTransport: { session, _ in await session.close() },
handleBinding: { _, _, _ in await bindings.record() }
)
let start = Task { try await runtime.start() }
await gate.waitUntilSuspended()

try await start.value
Comment on lines +110 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Remove the wall-clock dependency from this test.

automaticRelayReadinessTimeout: .milliseconds(20) makes start.value depend on scheduler timing. CI load can make this test flaky. Inject a controllable relay-readiness clock or readiness signal, then advance or complete it after gate.waitUntilSuspended().

As per coding guidelines, tests must await completion signals or use injected virtual clocks instead of fixed-duration waits.

🤖 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/CmxIrohHostRuntimeLifecycleRaceTests.swift`
around lines 110 - 120, Remove the fixed .milliseconds(20) timeout from the
runtime setup in CmxIrohHostRuntimeLifecycleRaceTests and inject a controllable
relay-readiness clock or completion signal instead. After
gate.waitUntilSuspended(), explicitly advance or complete that readiness
mechanism before awaiting start.value, while preserving the existing transport
and binding handlers.

Source: Coding guidelines

#expect(await bindings.count() == 1)

await gate.resume()
try await start.value
await runtime.stop()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,57 @@ struct CmxIrohHostRuntimeTests {
await runtime.stop()
}

@Test("automatic startup publishes relay-ready hints, not the bootstrap snapshot")
func automaticStartupPublishesRelayReadyHints() async throws {
let fixture = try HostRuntimeFixture()
let now = Date()
let readyBinding = try HostRuntimeFixture.binding(
endpointID: fixture.endpointID.endpointID,
publicHintObservedAt: now,
publicHintExpiresAt: now.addingTimeInterval(30 * 60)
)
let readyDiscovery = try HostRuntimeFixture.discovery(
binding: readyBinding,
relays: HostRuntimeFixture.relayURLs,
revision: 2
)
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
// The broker's bootstrap round returns a binding with no usable path
// hints (the endpoint has not attached to its home relay yet); the
// post-attach round returns the relay-hinted binding. Relay token
// issuance is the earliest moment the endpoint can come online.
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery,
subsequentRegistrationBindings: [readyBinding],
subsequentDiscoveries: [readyDiscovery]
)
let routes = HostRuntimeRouteRecorder()
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
handleTransport: { session, _ in await session.close() },
handleRoute: { binding, pathHints in
await routes.record(binding: binding, pathHints: pathHints)
},
handleRelayCredential: { _, _ in await endpoint.emit(.online) }
)

try await runtime.start()

#expect(await broker.observedRelayIssueCount() == 1)
let published = await routes.values()
let firstHints = try #require(published.first?.pathHints)
#expect(
firstHints.contains { $0.kind == .relayURL },
"the binding advertised at activation must carry post-relay-attach hints"
)
#expect(await broker.observedRegistrationCount() == 2)
await runtime.stop()
}

@Test
func unavailableRelayPolicyStillAllowsDirectDiscovery() async throws {
let fixture = try HostRuntimeFixture()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ actor TestDialingIrohEndpoint: CmxIrohEndpoint {
func connect(
to address: CmxIrohEndpointAddress,
alpn _: Data
) throws -> any CmxIrohConnection {
) async throws -> any CmxIrohConnection {
dialedAddresses.append(address)
guard !dialResults.isEmpty else {
throw TestIrohTransportError.unsupported
Expand All @@ -41,6 +41,9 @@ actor TestDialingIrohEndpoint: CmxIrohEndpoint {
return connection
case let .failure(error):
throw error
case .hang:
try await Task.sleep(for: .seconds(3600))
throw TestIrohTransportError.unsupported
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
enum TestIrohDialResult {
case connection(TestIrohConnection)
case failure(TestIrohTransportError)
/// Suspends until the dial attempt is cancelled, like a peer that never
/// answers on a stale path hint.
case hang
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@ public enum MobileIrohReleaseGateScenario: String, Equatable, Sendable {
case relayRollover = "relay_rollover"
/// Hold the initial credential and require the relay to close at expiry.
case relayExpiry = "relay_expiry"
/// Dial a Mac that launched moments ago; the orchestrator enforces the
/// launch-to-usable-session deadline, this scenario marks the report.
case coldStartDial = "cold_start_dial"
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ extension MobileShellComposite {
var unrefreshedExpiryDisconnectVerified = false

switch scenario {
case .standard:
case .standard, .coldStartDial:
// Cold-start proves the same usable-session legs as standard;
// its timing assertion lives at the orchestrator script layer.
try await verifyReversibleWorkspaceRename(
workspace: workspace,
marker: marker
Expand Down
Loading