Skip to content

Commit 73e64ba

Browse files
authored
Merge pull request #274 from okta/OKTA-1144194-DeadlockFix
Adapt CredentialActor to use a GCD-based serial queue to avoid mixing thread isolation patterns
2 parents 5c710b0 + 1c96a7e commit 73e64ba

6 files changed

Lines changed: 384 additions & 77 deletions

File tree

Sources/AuthFoundation/Migration/SDKVersion.swift

Lines changed: 38 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,18 @@ import Foundation
1616
import CommonSupport
1717
#endif
1818

19-
#if canImport(UIKit)
20-
import UIKit
21-
#endif
22-
23-
#if os(watchOS)
24-
import WatchKit
25-
#endif
26-
2719
#if canImport(Android)
2820
import Android
2921
#endif
3022

3123
private let deviceModel: String = {
3224
var system = utsname()
3325
uname(&system)
34-
let model = withUnsafePointer(to: &system.machine.0) { ptr in
26+
let model = withUnsafeBytes(of: &system.machine) { buf in
27+
guard let ptr = buf.baseAddress?.assumingMemoryBound(to: CChar.self)
28+
else {
29+
return "unknown"
30+
}
3531
return String(cString: ptr)
3632
}
3733
return model
@@ -52,22 +48,16 @@ private let systemName: String = {
5248
return "android"
5349
#elseif os(Linux)
5450
return "linux"
55-
#elseif os(Android)
56-
return "android"
51+
#elseif os(Windows)
52+
return "windows"
53+
#else
54+
return "unknown"
5755
#endif
5856
}()
5957

6058
private let systemVersion: String = {
61-
#if os(iOS) || os(tvOS) || (swift(>=5.10) && os(visionOS))
62-
return MainActor.nonisolatedUnsafe {
63-
UIDevice.current.systemVersion
64-
}
65-
#elseif os(watchOS)
66-
return WKInterfaceDevice.current().systemVersion
67-
#else
68-
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
69-
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
70-
#endif
59+
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
60+
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
7161
}()
7262

7363
/// Utility class that allows SDK components to register their name and version for use in HTTP User-Agent values.
@@ -101,47 +91,56 @@ public struct SDKVersion: Sendable {
10191
@discardableResult
10292
public static func register(sdk: SDKVersion) -> SDKVersion {
10393
lock.withLock {
104-
guard _sdkVersions.filter({ $0.name == sdk.name }).isEmpty else {
105-
return sdk
106-
}
107-
108-
_sdkVersions.append(sdk)
109-
110-
let sdkVersionString = _sdkVersions
111-
.sorted(by: { $0.name.rawValue < $1.name.rawValue })
112-
.map(\.displayName)
113-
.joined(separator: " ")
114-
_userAgent = "\(sdkVersionString) \(systemName)/\(systemVersion) Device/\(deviceModel)"
94+
_register(sdk: sdk)
95+
}
96+
}
11597

98+
private static func _register(sdk: SDKVersion) -> SDKVersion {
99+
guard _sdkVersions.filter({ $0.name == sdk.name }).isEmpty else {
116100
return sdk
117101
}
102+
103+
_sdkVersions.append(sdk)
104+
105+
let sdkVersionString = _sdkVersions
106+
.sorted(by: { $0.name.rawValue < $1.name.rawValue })
107+
.map(\.displayName)
108+
.joined(separator: " ")
109+
_userAgent = "\(sdkVersionString) \(systemName)/\(systemVersion) Device/\(deviceModel)"
110+
111+
return sdk
118112
}
119113

120114
/// Convenience function used to register an SDK
121115
/// - Parameters:
122116
/// - name: SDK name.
123117
/// - versionString: SDK version.
124118
/// - Returns: The resulting SDKVersion object.
125-
@inlinable
126119
@discardableResult
127120
public static func register(_ name: Name, version versionString: String) -> SDKVersion? {
128-
let sdk = version(for: name) ?? register(sdk: SDKVersion(sdk: name, version: versionString))
129-
guard sdk.version == versionString
130-
else {
131-
return nil
121+
lock.withLock {
122+
let sdk = _version(for: name) ?? _register(sdk: SDKVersion(sdk: name, version: versionString))
123+
guard sdk.version == versionString
124+
else {
125+
return nil
126+
}
127+
return sdk
132128
}
133-
return sdk
134129
}
135130

136131
/// Returns the version information for the given SDK.
137132
/// - Parameter sdkName: SDK name to search for.
138133
/// - Returns: Version information for the given SDK name.
139134
public static func version(for sdkName: Name) -> SDKVersion? {
140135
lock.withLock {
141-
_sdkVersions.first(where: { $0.name == sdkName })
136+
_version(for: sdkName)
142137
}
143138
}
144139

140+
private static func _version(for sdkName: Name) -> SDKVersion? {
141+
_sdkVersions.first(where: { $0.name == sdkName })
142+
}
143+
145144
// MARK: Private properties / methods
146145
private static let lock = Lock()
147146
nonisolated(unsafe) private static var _sdkVersions: [SDKVersion] = []

Sources/AuthFoundation/User Management/Credential.swift

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
3131
get {
3232
assert(SDKVersion.authFoundation != nil)
3333

34-
return withIsolationSync { @CredentialActor in
34+
return CredentialActor.sync {
3535
TaskData.coordinator.default
3636
}
3737
}
3838
set {
3939
assert(SDKVersion.authFoundation != nil)
4040

41-
withIsolationSync { @CredentialActor in
41+
CredentialActor.sync {
4242
TaskData.coordinator.default = newValue
4343
}
4444
}
@@ -48,9 +48,9 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
4848
public static var allIDs: [String] {
4949
assert(SDKVersion.authFoundation != nil)
5050

51-
return withIsolationSync { @CredentialActor in
51+
return CredentialActor.sync {
5252
TaskData.coordinator.allIDs
53-
} ?? []
53+
}
5454
}
5555

5656
/// The default grace interval used when refreshing tokens using ``Credential/refreshIfNeeded(graceInterval:completion:)`` or ``Credential/refreshIfNeeded(graceInterval:)``.
@@ -74,7 +74,7 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
7474
public static func with(id: String, prompt: String? = nil, authenticationContext: (any TokenAuthenticationContext)? = nil) throws -> Credential? {
7575
assert(SDKVersion.authFoundation != nil)
7676

77-
return try withIsolationSyncThrowing { @CredentialActor in
77+
return try CredentialActor.sync {
7878
try TaskData.coordinator.with(id: id,
7979
prompt: prompt,
8080
authenticationContext: authenticationContext)
@@ -102,7 +102,7 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
102102
public static func find(where expression: @Sendable @escaping (Token.Metadata) -> Bool, prompt: String? = nil, authenticationContext: (any TokenAuthenticationContext)? = nil) throws -> [Credential] {
103103
assert(SDKVersion.authFoundation != nil)
104104

105-
return try withIsolationSyncThrowing { @CredentialActor in
105+
return try CredentialActor.sync {
106106
try TaskData.coordinator.find(where: expression,
107107
prompt: prompt,
108108
authenticationContext: authenticationContext)
@@ -129,7 +129,7 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
129129
) throws -> Credential {
130130
assert(SDKVersion.authFoundation != nil)
131131

132-
return try withIsolationSyncThrowing { @CredentialActor in
132+
return try CredentialActor.sync {
133133
try TaskData.coordinator.store(token: token, tags: tags, security: options)
134134
}
135135
}
@@ -181,7 +181,7 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
181181
throw CredentialError.missingCoordinator
182182
}
183183

184-
metadata = try withIsolationSyncThrowing { @CredentialActor in
184+
metadata = try CredentialActor.sync {
185185
let metadata = try Token.Metadata(token: self.token, tags: tags)
186186
try coordinator.tokenStorage.setMetadata(metadata)
187187
return metadata
@@ -239,7 +239,7 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
239239
throw CredentialError.missingCoordinator
240240
}
241241

242-
try withIsolationSyncThrowing { @CredentialActor in
242+
try CredentialActor.sync {
243243
try coordinator.remove(credential: self)
244244
}
245245
}
@@ -282,7 +282,7 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
282282
if let coordinator,
283283
shouldRemove(for: type)
284284
{
285-
try withIsolationSyncThrowing { @CredentialActor in
285+
try await CredentialActor.run {
286286
try coordinator.remove(credential: self)
287287
}
288288
}
@@ -376,27 +376,28 @@ public final class Credential: Equatable, OAuth2ClientDelegate {
376376
nonisolated(unsafe) private var _metadata: Token.Metadata?
377377
var metadata: Token.Metadata {
378378
get {
379-
lock.withLock {
380-
if let metadata = _metadata {
381-
return metadata
382-
}
383-
384-
let result: Token.Metadata
385-
386-
let id = id
387-
if let coordinator,
388-
let metadata = withIsolationSync({
389-
try? await coordinator.tokenStorage.metadata(for: id)
390-
})
391-
{
392-
result = metadata
393-
} else {
394-
result = Token.Metadata(id: id)
395-
}
379+
// Return cached value under lock, if present
380+
if let cached = lock.withLock({ _metadata }) {
381+
return cached
382+
}
396383

397-
_metadata = result
398-
return result
384+
// Fetch from storage outside the lock to avoid both re-entrant
385+
// lock access (self.id -> self.token -> lock) and holding the
386+
// lock while blocking on withIsolationSync.
387+
let result: Token.Metadata
388+
let id = id
389+
if let coordinator,
390+
let metadata = CredentialActor.sync({
391+
try? coordinator.tokenStorage.metadata(for: id)
392+
})
393+
{
394+
result = metadata
395+
} else {
396+
result = Token.Metadata(id: id)
399397
}
398+
399+
lock.withLock { _metadata = result }
400+
return result
400401
}
401402
set {
402403
lock.withLock {

Sources/AuthFoundation/User Management/Internal/CredentialCoordinatorImpl.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -208,14 +208,14 @@ extension CredentialCoordinatorImpl: OAuth2ClientDelegate {
208208
return
209209
}
210210

211-
withIsolationSync {
212-
do {
213-
try await self.tokenStorage.replace(token: token.id,
214-
with: newToken,
215-
security: nil)
216-
} catch {
217-
print("Error happened refreshing: \(error)")
211+
do {
212+
try CredentialActor.sync {
213+
try self.tokenStorage.replace(token: token.id,
214+
with: newToken,
215+
security: nil)
218216
}
217+
} catch {
218+
print("Error happened refreshing: \(error)")
219219
}
220220
}
221221
}

Sources/AuthFoundation/Utilities/AsyncUtilities.swift

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,93 @@ extension Task where Success == Never, Failure == Never {
3535
}
3636
}
3737

38+
private final class DispatchQueueExecutor: SerialExecutor {
39+
private let queue: DispatchQueue
40+
41+
init(queue: DispatchQueue) {
42+
self.queue = queue
43+
}
44+
45+
func enqueue(_ job: UnownedJob) {
46+
self.queue.async {
47+
job.runSynchronously(on: self.asUnownedSerialExecutor())
48+
}
49+
}
50+
51+
func asUnownedSerialExecutor() -> UnownedSerialExecutor {
52+
UnownedSerialExecutor(ordinary: self)
53+
}
54+
55+
func checkIsolated() {
56+
dispatchPrecondition(condition: .onQueue(self.queue))
57+
}
58+
}
59+
60+
/// Strips `@CredentialActor` isolation from a closure's type signature.
61+
///
62+
/// Swift's type system treats `@CredentialActor () -> T` and `() -> T` as
63+
/// distinct types. When executing on the actor's serial GCD queue (via
64+
/// `queue.sync`), we are *semantically* within the actor's isolation domain
65+
/// — the custom ``DispatchQueueExecutor`` guarantees this, and its
66+
/// `checkIsolated()` method provides a runtime assertion.
67+
///
68+
/// However, the compiler can't statically verify that `queue.sync` provides
69+
/// the actor's isolation. There is currently no first-class Swift API to
70+
/// express "Trust me, I'm on this global actor's executor." Until an API exists,
71+
/// `unsafeBitCast` is used to reinterpret the closure type.
72+
///
73+
/// This is safe because:
74+
/// 1. The closure's ABI representation is identical with or without the
75+
/// `@CredentialActor` attribute — it only affects compile-time checking.
76+
/// 2. The ``sync`` guarantees execution on the
77+
/// actor's serial queue before invoking the result.
78+
@inline(__always)
79+
private nonisolated func stripIsolation<T>(
80+
_ body: @CredentialActor @Sendable @escaping () throws -> T
81+
) -> @Sendable () throws -> T {
82+
unsafeBitCast(body, to: (@Sendable () throws -> T).self)
83+
}
84+
3885
/// Shared actor used to coordinate multithreaded interactions within the Credential storage subsystem.
3986
@globalActor
4087
@_documentation(visibility: private)
4188
public final actor CredentialActor {
4289
public static let shared = CredentialActor()
43-
90+
91+
private let queue = DispatchQueue(label: "com.okta.credential-actor")
92+
private let executor: DispatchQueueExecutor
93+
94+
private init() {
95+
self.executor = DispatchQueueExecutor(queue: queue)
96+
}
97+
98+
nonisolated public var unownedExecutor: UnownedSerialExecutor {
99+
executor.asUnownedSerialExecutor()
100+
}
101+
44102
/// Convenience for running a block within the context of the ``CredentialActor``.
45103
/// - Parameter body: Block to execute.
46104
/// - Returns: Result of the block.
47105
public static func run<T: Sendable>(_ body: @CredentialActor @Sendable () throws -> T) async rethrows -> T {
48106
try await body()
49107
}
108+
109+
/// Synchronously executes a block on the ``CredentialActor``'s serial queue,
110+
/// blocking the caller until completion.
111+
///
112+
/// This dispatches directly to the actor's GCD queue using `DispatchQueue.sync`,
113+
/// bypassing the Swift cooperative thread pool. This prevents deadlocks that can occur
114+
/// when all cooperative threads are blocked by `DispatchGroup.wait()`.
115+
///
116+
/// > Warning: This is intended for the exclusive use of synchronous use-cases that interact with the credential storage sub-system. It may be made public at some point, but is being kept `internal` until it is required.
117+
///
118+
/// - Parameter body: Block to execute on the actor's serial queue.
119+
/// - Returns: The result of the block.
120+
/// - Throws: Any error thrown by the block.
121+
nonisolated static func sync<T: Sendable>(_ body: @CredentialActor @Sendable @escaping () throws -> T) rethrows -> T {
122+
let rawBody = stripIsolation(body)
123+
return try shared.queue.sync {
124+
try rawBody()
125+
}
126+
}
50127
}

Tests/AuthFoundationTests/CredentialRevokeTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ final class CredentialTests: XCTestCase {
5252

5353

5454
func testRemove() async throws {
55-
XCTAssertNoThrow(try credential.remove())
55+
try credential.remove()
5656

5757
let hasCredential = await coordinator.credentialDataSource.hasCredential(for: token)
5858
XCTAssertFalse(hasCredential)

0 commit comments

Comments
 (0)