Skip to content

Swift 6.1 runtime crash when calling @objc async protocol method in target with mixed Swift 5 and Swift 6 dependencies #81846

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

Open
yevgold opened this issue May 29, 2025 · 3 comments
Labels
bug A deviation from expected or documented behavior. Also: expected but undesirable behavior. crash Bug: A crash, i.e., an abnormal termination of software triage needed This issue needs more specific labels

Comments

@yevgold
Copy link

yevgold commented May 29, 2025

Description

We are hitting an EXC_BAD_ACCESS runtime crash in Swift.CheckedContinuation.resume(returning: __owned τ_0_0) -> () () when calling an @objc async protocol method. This occurs when using the Swift 6.1 compiler and does not happen with Swift 6.0. It's reproducible only when there are both Swift 5 and Swift 6 language mode dependencies that include such protocols and the methods have the same return type.

Reproduction

Repro steps:
Build and run the attached sample app with Xcode 16.3

RESULT: app crashes

We narrowed down the repro to a small project with 3 targets:

Static library compiled with Swift 5 language mode:

@objc
public protocol P5 {
  // This method's return type must match the return type of doSomethingElse() in P6.
  // Otherwise, no crash.
  func doSomething() async
}

public class Impl5: P5 {
  public init() {}

  public func doSomething() async {}
}

public class Swift5Class {
  public init() {}

  public func foo() async throws {
    let p: P5 = Impl5()
    await p.doSomething()
  }
}

Similar static library but compiled with Swift 6 language mode:

@objc
public protocol P6 {
  // This method's return type must match the return type of doSomething() in P5.
  // Otherwise, no crash.
  func doSomethingElse() async
}

public class Impl6: P6 {
  public init() {}

  public func doSomethingElse() async {}
}

public class Swift6Class {
  public init() {}

  public func bar() async throws {
    let p: P6 = Impl6()
    await p.doSomethingElse()
  }
}

App target that links in the above 2 libraries:

import S5
import S6

@main
struct SampleApp {

  static func main() async throws {
    print("Starting call to Swift 5 class")
    let s5 = Swift5Class()
    try? await s5.foo()

    // Crash before reaching next line.
    print("Finished call to Swift 5 class")

    // No crash if the next line is commented out.
    let _ = Swift6Class()
  }
}

Observations:

  • no crash when the same code is built with Swift 6.0
  • annotating the protocols as Sendable and making the classes final did not eliminate the crash
  • the language mode of the app target does not matter. It crashes whether it's built with Swift 5 or Swift 6
  • no crash if the 2 libraries are both built with the same language mode
  • no crash if we use the implementation directly instead of calling through the protocol, ie: await Impl().doSomething() // no crash
  • the location of the protocol definitions and class implementations does not matter. The crash still happens even if there is a single protocol and impl defined in a shared library. What matters is that the calls to the protocol methods are in 2 different targets with different language modes.

Stack dump

Thread 3 Queue : com.apple.root.default-qos.cooperative (concurrent)

#0	0x00000001959b6c90 in swift_retain ()
#1	0x000000024a2d3074 in Swift.CheckedContinuation.resume(returning: __owned τ_0_0) -> () ()
#2	0x0000000104d5f958 in _resumeCheckedContinuation<()>(_:_:) ()
#3	0x0000000104d5f828 in @objc completion handler block implementation for @escaping @callee_unowned @convention(block) () -> () with result type () ()
#4	0x0000000104d60530 in @objc closure #1 in Impl5.doSomething() ()
#5	0x0000000104d60638 in partial apply for @objc closure #1 in Impl5.doSomething() ()
#6	0x0000000104d5fab4 in thunk for @escaping @callee_guaranteed @Sendable @async () -> () ()
#7	0x0000000104d5fc10 in partial apply for thunk for @escaping @callee_guaranteed @Sendable @async () -> () ()
#8	0x0000000104d5fcec in thunk for @escaping @isolated(any) @callee_guaranteed @async () -> () ()
#9	0x0000000104d5fe50 in partial apply for thunk for @escaping @isolated(any) @callee_guaranteed @async () -> () ()
#10	0x0000000104d601a0 in specialized thunk for @escaping @isolated(any) @callee_guaranteed @async () -> (@out A) ()
#11	0x0000000104d602e8 in partial apply for specialized thunk for @escaping @isolated(any) @callee_guaranteed @async () -> (@out A) ()

Expected behavior

No crash should occur

Environment

swift-driver version: 1.120.5 Apple Swift version 6.1 (swiftlang-6.1.0.110.21 clang-1700.0.13.3)
Target: arm64-apple-macosx15.0

Additional information

Swift forums post: https://forums.swift.org/t/xcode-16-3-unsafecontinuation-resume-exc-bad-access-crash/79821

@yevgold yevgold added bug A deviation from expected or documented behavior. Also: expected but undesirable behavior. crash Bug: A crash, i.e., an abnormal termination of software triage needed This issue needs more specific labels labels May 29, 2025
@mikeash
Copy link
Contributor

mikeash commented Jun 6, 2025

Thank you for the nice sample project, that was really easy to get going and reproduce the crash.

This is an interesting one. The issue relates to the continuations used to call ObjC methods as Swift async functions. Swift 5 uses unchecked continuations, but Swift 6 uses checked continuations.

As part of bridging them across, the compiler emits a completion block that resumes the continuation. The completion block is pretty general, so the compiler gives it a generic name and marks it as weak so that multiple copies can be coalesced. Here, the symbol name is $sIeyB_ytTz_ which demangles to @objc completion handler block implementation for @escaping @callee_unowned @convention(block) () -> () with result type ().

The problem is both language modes emit this, but they aren't actually the same. In this project, the Swift 6 version won. The Swift 5 code therefore creates an unchecked continuation, but creates a block that resumes it as if it were a checked continuation. They don't have the same machine-level representation, so this goes badly wrong.

This would need a compiler fix to change the name of at least one of these and avoid the incorrect coalescing.

As a workaround, everything is fine as long as the two versions don't see each other's internal symbols at build time, so if you can keep Swift 5 and Swift 6 code in separate dynamic libraries, that should work reliably.

@mikeash
Copy link
Contributor

mikeash commented Jun 6, 2025

I also tried to make a small example at the command line, which didn't crash until I changed it to also call the bar function from the Swift 6 class. Then that one crashed. It just so happened that that the Swift 5 version of this symbol won in that build. I think the order in which the libraries are linked determines which one wins. But it doesn't really matter, neither one is correct for everything.

@mikeash
Copy link
Contributor

mikeash commented Jun 6, 2025

I’m told that using mergeable libraries should also work here, and still give you the load-time performance of static libraries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug A deviation from expected or documented behavior. Also: expected but undesirable behavior. crash Bug: A crash, i.e., an abnormal termination of software triage needed This issue needs more specific labels
Projects
None yet
Development

No branches or pull requests

2 participants