Skip to content

Navigator.next: nonisolated + main-actor hop, safe from any context#3

Merged
Sajjon merged 2 commits into
mainfrom
fix_crash
May 3, 2026
Merged

Navigator.next: nonisolated + main-actor hop, safe from any context#3
Sajjon merged 2 commits into
mainfrom
fix_crash

Conversation

@Sajjon
Copy link
Copy Markdown
Owner

@Sajjon Sajjon commented May 3, 2026

Summary

Fix a runtime trap in Navigator.next(_:) when called from a Combine sink that resumed on the cooperative thread pool.

Repro

A view-model awaits an async use case that bridges back to Combine via Future { promise in Task { promise(.success(value)) } } (e.g. Zesame's CombineWrapper). The Future doesn't preserve the caller's @MainActor isolation, so the downstream .sink { navigator.next(.x) } runs on the cooperative pool. With next(_:) annotated @MainActor, the runtime isolation check (_swift_task_checkIsolatedSwift) trapped with EXC_BREAKPOINT.

Fix

Mark next(_:) nonisolated and bridge to main inside:

  • On main: fast-path via MainActor.assumeIsolated — no Task hop, no allocation.
  • Off main: dispatch the subject send to Task { @MainActor in … }, so coordinator subscribers always receive on the main actor regardless of which scheduler the upstream pipeline used.

Type changes

  • Navigator<NavigationStep> becomes @unchecked Sendable. Safe in this codebase: navigationSubject is the only mutable state and every write goes through next(_:) which guarantees the main-actor hop. Documented at the type declaration with a follow-up note: drop the @unchecked once Combine's PassthroughSubject gains native Sendable conformance.
  • The next(_:) extension picks up a where NavigationStep: Sendable constraint because the step value is captured across the actor hop. All current consumers (Zhip, SignUpDemo) already declare Sendable on their step enums, so this is non-breaking.

Test plan

  • xcodebuild build -scheme NanoViewController-Package -destination 'iPhone 17, iOS 26.1'BUILD SUCCEEDED
  • just test — all 75 tests pass under Swift 6 language mode
  • swiftformat --lint + swiftlint --strict — clean (pre-commit hooks ran)
  • Pull into Zhip and confirm the trapping pattern (Future { Task { promise(.success) } }.sink { navigator.next(.x) }) no longer crashes

🤖 Generated with Claude Code

Previously `next(_:)` was @MainActor-isolated, which trapped at runtime
when called from a Combine sink that resumed on the cooperative thread
pool — typical pattern: a view-model awaits an async use case that
bridges back via `Future { promise in Task { promise(.success(value)) }
}` (e.g. Zesame's CombineWrapper), which does not preserve the caller's
MainActor isolation. The `.sink { navigator.next(.x) }` then ran off-main
and the runtime isolation check (`_swift_task_checkIsolatedSwift`) trapped
with EXC_BREAKPOINT.

Fix: mark `next(_:)` `nonisolated`. When called on the main thread it
fast-paths through `MainActor.assumeIsolated` (no Task hop). When called
off-main it dispatches the subject send to `Task { @mainactor in … }`, so
coordinator subscribers always receive on the main actor regardless of
which scheduler the upstream pipeline used.

The class becomes `@unchecked Sendable` so the off-main hop can capture
self across the actor boundary. Safe because `navigationSubject` is the
only mutable state and every write goes through `next(_:)` which now
guarantees the main-actor hop. Remove `@unchecked` once Combine's
`PassthroughSubject` gains native `Sendable`.

Adds a `where NavigationStep: Sendable` constraint on the `next(_:)`
extension because the step value is captured across the actor hop. All
nav-step types in current consumers (Zhip, the SignUpDemo example) are
already Sendable, so this is a non-breaking addition for them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a Swift Concurrency runtime isolation trap when Navigator.next(_:) is invoked from Combine pipelines that may resume off-main (e.g., after bridging async work back into Future), by making next(_:) callable from any context and hopping to the main actor internally.

Changes:

  • Mark Navigator as @unchecked Sendable and document the intended safety invariants.
  • Change next(_:) to nonisolated and ensure the underlying PassthroughSubject.send executes on the main actor (fast-path on main, otherwise hop via Task { @MainActor in … }).
  • Constrain the next(_:) extension to NavigationStep: Sendable so the step can be captured across the actor hop.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +61 to +66
/// `@unchecked Sendable` because `PassthroughSubject` itself does not yet
/// conform to `Sendable` (Apple's Combine has not been audited for Swift 6
/// strict concurrency). The unchecked claim is safe here because every
/// mutation of `navigationSubject` is funnelled through ``next(_:)``, which
/// hops to the main actor before sending; the lazy `navigation` accessor is
/// `@MainActor`-isolated. Remove `@unchecked` when Combine's
@@ -73,13 +83,22 @@ public final class Navigator<NavigationStep> {
public init() {}
}

Copilot review flagged the previous wording "every mutation of
navigationSubject is funnelled through next(_:)" as an overclaim:
PassthroughSubject also mutates internal state during subscription,
cancellation, and demand changes, not just `send`. Reworded to be
precise — `next(_:)` covers every send, the @mainactor `navigation`
accessor covers every read, and Combine's own subscription bookkeeping
is documented thread-safe (so a sink torn down off-main via
AnyCancellable.deinit doesn't race our sends).

The other review comment (next(_:) Sendable constraint is source-
breaking) doesn't apply: the package is at 0.1.x where source breaks
are intentional, and every current consumer (Zhip, SignUpDemo) already
declares Sendable on their step enums. Already covered in the PR body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Sajjon Sajjon merged commit 6d549c2 into main May 3, 2026
1 check passed
Sajjon added a commit to Sajjon/Zhip that referenced this pull request May 3, 2026
Picks up the Navigator.next isolation fix
(Sajjon/NanoViewController#3): `next(_:)` is now
nonisolated and hops to the main actor internally, so view-models can
safely emit navigation steps from Combine sinks that resume on the
cooperative pool — unblocking the Zesame-CombineWrapper crash documented
in commit f8ff88e.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Sajjon Sajjon deleted the fix_crash branch May 13, 2026 18:39
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.

2 participants