How to fix a crash in SecAddSharedWebCredential under Swift 6?

Something strange is happening with SecAddSharedWebCredential: it simply always crashes when compiled in Swift 6 mode and I couldn't find a workaround.

The code to reproduce the crash is trivial:

Task { @MainActor in
	SecAddSharedWebCredential("example.com" as CFString, "account" as CFString, "password" as CFString) { error in
		// ...
	}
}

It doesn't matter if the parameters are correct or not. In fact if they are, the password is stored in the KeyChain but at some point before calling the user callback it crashes in _dispatch_assert_queue_fail on some non-main thread with practically nothing useful in the stack trace.

What's baffling is also that there's no mention of this on the Internet. What am I doing wrong? Is there a workaround for now?

Thanks!

P.S. I understand I should probably file a bug, but the reason I posted it here is I'm hoping someone could suggest a temporary workaround for Swift 6.

It appears the Swift compiler is incorrectly inferring that, since you called SecAddSharedWebCredential() from a main-actor-isolated function, and since its completion handler does not specify any isolation nor @Sendable, it must also be isolated to the main actor. This is a bug in the Swift compiler; please file a bug report. (I believe I've seen a similar one previously, but didn't find it with a quick search.)

To work around it, you can move the call to SecAddSharedWebCredential() to a nonisolated helper function:

nonisolated func addSWC(_ fqdn: CFString, _ account: CFString, _ password: CFString?, _ completionHandler: @escaping @Sendable (CFError?) -> Void) {
  SecAddSharedWebCredential(fqdn, account, password) { error in
    completionHandler(error)
  }
}

Task { @MainActor in
  addSWC("example.com" as CFString, "account" as CFString, "password" as CFString) { error in
    // ...
  }
}

However, that sort of defeats the purpose of having @MainActor in there, doesn't it? You can use withCheckedThrowingContinuation() to turn SecAddSharedWebCredential() into a proper async Swift function:

nonisolated func addSWC(_ fqdn: String, _ account: String, _ password: String?) async throws {
  return try await withCheckedThrowingContinuation { continuation in
    SecAddSharedWebCredential(fqdn as CFString, account as CFString, password as CFString) { error in
      if let error = error as? NSError {
        continuation.resume(throwing: error)
      } else {
        continuation.resume()
      }
    }
  }
}

Task { @MainActor in
  try await addSWC("example.com", "account", "password")
  // ...
}
1 Like

Interesting. I extended your example to this file:

import Foundation
import Security

let task = Task { @MainActor in
	SecAddSharedWebCredential("example.com" as CFString, "account" as CFString, "password" as CFString) { error in
		print(error as Any)
	}
}

await task.value

and compiled and ran it:

swiftc -swift-version 6 SecAddSharedWebCredential.swift
./SecAddSharedWebCredential

no crash. But when I run it in lldb,

lldb ./SecAddSharedWebCredential
(lldb) target create "./SecAddSharedWebCredential"
Current executable set to '/Users/<me>/Source/SwiftReductions/SecAddSharedWebCredential' (arm64).
(lldb) r
Process 62251 launched: '/Users/<me>/Source/SwiftReductions/SecAddSharedWebCredential' (arm64)
Process 62251 stopped
* thread #2, queue = 'com.apple.root.default-qos', stop reason = EXC_BREAKPOINT (code=1, subcode=0x18141b9c0)
    frame #0: 0x000000018141b9c0 libdispatch.dylib`_dispatch_assert_queue_fail + 120
libdispatch.dylib`_dispatch_assert_queue_fail:
->  0x18141b9c0 <+120>: brk    #0x1

libdispatch.dylib`dispatch_assert_queue_not:
    0x18141b9c4 <+0>:   pacibsp 
    0x18141b9c8 <+4>:   stp    x29, x30, [sp, #-0x10]!
    0x18141b9cc <+8>:   mov    x29, sp
Target 0: (SecAddSharedWebCredential) stopped.

Fortunately, the backtrace has the information we need:

(lldb) bt
* thread #2, queue = 'com.apple.root.default-qos', stop reason = EXC_BREAKPOINT (code=1, subcode=0x18141b9c0)
  * frame #0: 0x000000018141b9c0 libdispatch.dylib`_dispatch_assert_queue_fail + 120
    frame #1: 0x000000018141b948 libdispatch.dylib`dispatch_assert_queue + 196
    frame #2: 0x0000000261bd6e08 libswift_Concurrency.dylib`swift_task_isCurrentExecutorImpl(swift::SerialExecutorRef) + 284
    frame #3: 0x0000000100003494 SecAddSharedWebCredential`closure #1 (Swift.Optional<__C.CFErrorRef>) -> () in closure #1 @Swift.MainActor @Sendable () async -> () in SecAddSharedWebCredential + 84
    frame #4: 0x0000000100003664 SecAddSharedWebCredential`reabstraction thunk helper from @escaping @callee_guaranteed (@guaranteed Swift.Optional<__C.CFErrorRef>) -> () to @escaping @callee_unowned @convention(block) (@unowned Swift.Optional<__C.CFErrorRef>) -> () + 64
    frame #5: 0x000000018471a3d8 Security`__SecAddSharedWebCredential_block_invoke_2 + 52
    frame #6: 0x0000000181417854 libdispatch.dylib`_dispatch_call_block_and_release + 32
    frame #7: 0x00000001814195b4 libdispatch.dylib`_dispatch_client_callout + 20
    frame #8: 0x000000018141c700 libdispatch.dylib`_dispatch_queue_override_invoke + 916
    frame #9: 0x000000018142b4cc libdispatch.dylib`_dispatch_root_queue_drain + 392
    frame #10: 0x000000018142bcd8 libdispatch.dylib`_dispatch_worker_thread2 + 156
    frame #11: 0x00000001815c839c libsystem_pthread.dylib`_pthread_wqthread + 228

Frame 3 is our closure, frame 2 is Swift 6 asserting that the closure is on the actor it expects to be.

What's going on is, SecAddSharedWebCredential has been imported from C incorrectly — we have:

public func SecAddSharedWebCredential(
    _ fqdn: CFString,
    _ account: CFString,
    _ password: CFString?,
    _ completionHandler: @escaping (CFError?) -> Void
)

Which says "this completion handler will be called on the same actor as you called SecAddSharedWebCredential on. This is wrong (it calls back on some arbitrary global dispatch queue).

It should have been imported as:

public func SecAddSharedWebCredential(
    _ fqdn: CFString,
    _ account: CFString,
    _ password: CFString?,
    _ completionHandler: @escaping @Sendable (CFError?) -> Void
)

(sending would work as well as @Sendable here since the callback is called only once, but I doubt the clang importer could know that?)

This is a real problem with the clang importer and Swift 6 — importing ObjC closures without @Sendable is no longer a conservative choice.

Fortunately, we can work around it in our own code:

import Foundation
import Security

let closure = { @Sendable error in
    print(error as Any)
}

let task = Task { @MainActor in
	SecAddSharedWebCredential("example.com" as CFString, "account" as CFString, "password" as CFString, closure) 
}

await task.value

(unfortunately just adding @Sendable in the inline closure doesn't work, that creates an @MainActor @Sendable closure which has the same problem; we actually have to declare the closure outside of the global actor isolated scope)

swiftc -swift-version 6 SecAddSharedWebCredential.swift
lldb ./SecAddSharedWebCredential                       
(lldb) target create "./SecAddSharedWebCredential"
Current executable set to '/Users/<me>/Source/SwiftReductions/SecAddSharedWebCredential' (arm64).
(lldb) r
Process 62701 launched: '/Users/<me>/Source/SwiftReductions/SecAddSharedWebCredential' (arm64)
Optional(Error Domain=NSOSStatusErrorDomain Code=-4 "SecAddSharedWebCredentialSync not supported on this platform" (kCFMessagePortTransportError / kCSIdentityDeletedErr / unimpErr: /  / unimplemented core routine) UserInfo={numberOfErrorsDeep=0, NSDescription=SecAddSharedWebCredentialSync not supported on this platform})
Process 62701 exited with status = 0 (0x00000000) 
5 Likes

It works, thank you!

For what it's worth, I think Apple wants you to use Authentication Services for this sort of thing these days? It's been a while since I worked in this area so I'm not up to speed on the current API guidelines.

Nope, I checked, those mostly trigger UI-based methods. They haven't "modernized" saving web credentials programmatically yet, or I couldn't find anything.

1 Like