The "NonisolatedNonsendingByDefault" Conformance Trap

Context

I recently encountered a subtle protocol conformance issue when adding a gRPC interceptor in a package enabled with NonisolatedNonsendingByDefault feature flag. I'm sharing this to see if others have faced similar issues and to discuss how we might improve the developer experience for such "invisible" mismatches.

The Problem

When NonisolatedNonsendingByDefault is enabled in a target, the compiler automatically infers closure parameters in protocol implementations as nonisolated(nonsending). This led to a cryptic conformance failure when implementing a protocol from a library (in this case, grpc-swift-2) that was likely compiled without this feature enabled.

Minimal Reproducible Example

// Target has .enableUpcomingFeature("NonisolatedNonsendingByDefault")

struct LoggingInterceptor: ClientInterceptor {
  func intercept<Input, Output>(
    request: StreamingClientRequest<Input>,
    context: ClientContext,
    next: (StreamingClientRequest<Input>, ClientContext) async throws -> StreamingClientResponse<Output>
  ) async throws -> StreamingClientResponse<Output> where Input: Sendable, Output: Sendable {
    // Inference: 'next' is treated as nonisolated(nonsending)
    try await next(request, context)
  }
}
// gRPC Swift 2's ClientInterceptor
public protocol ClientInterceptor: Sendable {
  func intercept<Input: Sendable, Output: Sendable>(
    request: StreamingClientRequest<Input>,
    context: ClientContext,
    next: (
      _ request: StreamingClientRequest<Input>,
      _ context: ClientContext
    ) async throws -> StreamingClientResponse<Output>
  ) async throws -> StreamingClientResponse<Output>
}

The Compiler Error:

error: type 'LoggingInterceptor' does not conform to protocol 'ClientInterceptor'
note: candidate has non-matching type '... next: nonisolated(nonsending) (StreamingClientRequest<Input>, ClientContext) async throws -> ...'
note: protocol requires function 'intercept' with type '... next: (StreamingClientRequest<Input>, ClientContext) async throws -> ...'

The Root Cause & Fix

The gap lies in the invisible inference.

  1. The library's protocol defines the closure as a standard @concurrent closure without these modern isolation markers.

  2. The implementing target, however, "upgrades" the signature by adding nonisolated(nonsending) during inference, causing a signature mismatch.

The fix was to explicitly add @concurrent to the closure to match the protocol's expected signature:


next: @concurrent (StreamingClientRequest<Input>, ClientContext) async throws -> StreamingClientResponse<Output>

Questions for Discussion

  1. Diagnostic Clarity: The current note: candidate has non-matching type is technically accurate but fails to highlight why the inference changed. Could the compiler explicitly link @concurrent?

  2. Library Future-proofing: How should library authors define protocols to be resilient against these inference changes? Should attributes like @Sendable be applied more aggressively in library headers?

I would love to hear thoughts from the community on how we can make this migration path smoother for developers who might spend hours debugging "invisible" code differences.

2 Likes