Skip to content

Commit 8076a44

Browse files
authored
NonisolatedNonsendingByDefault and other concurrency fixes (#63)
### Motivation - Fixes #17 - Fixes #23 - Fixes #32 - Fixes #43 - Fixes #44 These 5 fixes are intertwined and were difficult to split out, so here's one PR that fixes them all. ### Modifications - The PR enables NonisolatedNonsendingByDefault, which caused an error in the existing combineLatest implementation, which we wanted to replace anyway. - So we vendor in the code from Swift Async Algoritms (the PR is open here: apple/swift-async-algorithms#360), the plan is to remove this copy when it lands in async algos. - Updating the combineLatest implementation to the correct one requires the async sequences to be Sendable, which in turn requires Swift 6.2 for us to be able to spell `any (AsyncSequence & Sendable)` correctly, it doesn't work on 6.1. - We also enabled the explicit sendable warning in CI, helping us ensure all our public API is explicitly annotated as Sendable or not. ### Result Addressed 5 important issues that impact the API and concurrency. ### Test Plan Brought over tests for the new combineLatest implementation, added more unit tests for failure cases when using the ConfigReader with multiple providers.
1 parent 3c88372 commit 8076a44

File tree

14 files changed

+1616
-264
lines changed

14 files changed

+1616
-264
lines changed

Package.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ let enableAllTraitsExplicit = ProcessInfo.processInfo.environment["ENABLE_ALL_TR
6161

6262
let enableAllTraits = spiGenerateDocs || previewDocs || enableAllTraitsExplicit
6363
let addDoccPlugin = previewDocs || spiGenerateDocs
64+
let enableAllCIFlags = enableAllTraitsExplicit
6465

6566
traits.insert(
6667
.default(
@@ -77,6 +78,7 @@ let package = Package(
7778
traits: traits,
7879
dependencies: [
7980
.package(url: "https://github.com/apple/swift-system", from: "1.5.0"),
81+
.package(url: "https://github.com/apple/swift-collections", from: "1.3.0"),
8082
.package(url: "https://github.com/swift-server/swift-service-lifecycle", from: "2.7.0"),
8183
.package(url: "https://github.com/apple/swift-log", from: "1.6.3"),
8284
.package(url: "https://github.com/apple/swift-metrics", from: "2.7.0"),
@@ -92,6 +94,10 @@ let package = Package(
9294
name: "SystemPackage",
9395
package: "swift-system"
9496
),
97+
.product(
98+
name: "DequeModule",
99+
package: "swift-collections"
100+
),
95101
.product(
96102
name: "Logging",
97103
package: "swift-log",
@@ -179,8 +185,16 @@ for target in package.targets {
179185
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0409-access-level-on-imports.md
180186
settings.append(.enableUpcomingFeature("InternalImportsByDefault"))
181187

188+
// https://docs.swift.org/compiler/documentation/diagnostics/nonisolated-nonsending-by-default/
189+
settings.append(.enableUpcomingFeature("NonisolatedNonsendingByDefault"))
190+
182191
settings.append(.enableExperimentalFeature("AvailabilityMacro=Configuration 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"))
183192

193+
if enableAllCIFlags {
194+
// Ensure all public types are explicitly annotated as Sendable or not Sendable.
195+
settings.append(.unsafeFlags(["-Xfrontend", "-require-explicit-sendable"]))
196+
}
197+
184198
target.swiftSettings = settings
185199
}
186200

Sources/Configuration/ConfigProviderHelpers.swift

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ extension ConfigProvider {
4242
/// - updatesHandler: The closure that processes the async sequence of value updates.
4343
/// - Returns: The value returned by the handler closure.
4444
/// - Throws: Provider-specific errors or errors thrown by the handler.
45-
public func watchValueFromValue<Return>(
46-
forKey key: AbsoluteConfigKey,
47-
type: ConfigType,
48-
updatesHandler: (
49-
ConfigUpdatesAsyncSequence<Result<LookupResult, any Error>, Never>
45+
nonisolated(nonsending)
46+
public func watchValueFromValue<Return>(
47+
forKey key: AbsoluteConfigKey,
48+
type: ConfigType,
49+
updatesHandler: (
50+
ConfigUpdatesAsyncSequence<Result<LookupResult, any Error>, Never>
51+
) async throws -> Return
5052
) async throws -> Return
51-
) async throws -> Return {
53+
{
5254
let (stream, continuation) = AsyncStream<Result<LookupResult, any Error>>
5355
.makeStream(bufferingPolicy: .bufferingNewest(1))
5456
let initialValue: Result<LookupResult, any Error>
@@ -83,9 +85,11 @@ extension ConfigProvider {
8385
/// - Parameter updatesHandler: The closure that processes the async sequence of snapshot updates.
8486
/// - Returns: The value returned by the handler closure.
8587
/// - Throws: Provider-specific errors or errors thrown by the handler.
86-
public func watchSnapshotFromSnapshot<Return>(
87-
updatesHandler: (ConfigUpdatesAsyncSequence<any ConfigSnapshotProtocol, Never>) async throws -> Return
88-
) async throws -> Return {
88+
nonisolated(nonsending)
89+
public func watchSnapshotFromSnapshot<Return>(
90+
updatesHandler: (ConfigUpdatesAsyncSequence<any ConfigSnapshotProtocol, Never>) async throws -> Return
91+
) async throws -> Return
92+
{
8993
let (stream, continuation) = AsyncStream<any ConfigSnapshotProtocol>
9094
.makeStream(bufferingPolicy: .bufferingNewest(1))
9195
let initialValue = snapshot()

Sources/Configuration/Documentation.docc/Reference/ConfigReader-Watch.md

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,47 +4,47 @@
44

55
### Watching string values
66
- ``ConfigReader/watchString(forKey:isSecret:fileID:line:updatesHandler:)``
7-
- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-4q1c0``
8-
- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-7lki4``
7+
- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-7mxw1``
8+
- ``ConfigReader/watchString(forKey:as:isSecret:fileID:line:updatesHandler:)-818sy``
99
- ``ConfigReader/watchString(forKey:isSecret:default:fileID:line:updatesHandler:)``
10-
- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-4x6zt``
11-
- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-1ncw1``
10+
- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-6m0yu``
11+
- ``ConfigReader/watchString(forKey:as:isSecret:default:fileID:line:updatesHandler:)-6dpc3``
1212
- ``ConfigReader/watchString(forKey:context:isSecret:fileID:line:updatesHandler:)``
13-
- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-1vua5``
14-
- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-1s8wu``
13+
- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-34wbx``
14+
- ``ConfigReader/watchString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-549xr``
1515
- ``ConfigReader/watchString(forKey:context:isSecret:default:fileID:line:updatesHandler:)``
16-
- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-3ppdh``
17-
- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-80t2z``
16+
- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-9u7vf``
17+
- ``ConfigReader/watchString(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-1ofiv``
1818

1919
### Watching required string values
2020
- ``ConfigReader/watchRequiredString(forKey:isSecret:fileID:line:updatesHandler:)``
21-
- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-29xb0``
22-
- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-3dox3``
21+
- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-86ot1``
22+
- ``ConfigReader/watchRequiredString(forKey:as:isSecret:fileID:line:updatesHandler:)-3lrs7``
2323
- ``ConfigReader/watchRequiredString(forKey:context:isSecret:fileID:line:updatesHandler:)``
24-
- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-6v7w5``
25-
- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-76kbb``
24+
- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-77978``
25+
- ``ConfigReader/watchRequiredString(forKey:context:as:isSecret:fileID:line:updatesHandler:)-138o2``
2626

2727
### Watching lists of string values
2828
- ``ConfigReader/watchStringArray(forKey:isSecret:fileID:line:updatesHandler:)``
29-
- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-5igvu``
30-
- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-38ruy``
29+
- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-8t4nb``
30+
- ``ConfigReader/watchStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-9cmju``
3131
- ``ConfigReader/watchStringArray(forKey:isSecret:default:fileID:line:updatesHandler:)``
32-
- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-7oi5b``
33-
- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-4rhx2``
32+
- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-59de``
33+
- ``ConfigReader/watchStringArray(forKey:as:isSecret:default:fileID:line:updatesHandler:)-8nsil``
3434
- ``ConfigReader/watchStringArray(forKey:context:isSecret:fileID:line:updatesHandler:)``
35-
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-6gaip``
36-
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-5dyyx``
35+
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-5occx``
36+
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-30hf0``
3737
- ``ConfigReader/watchStringArray(forKey:context:isSecret:default:fileID:line:updatesHandler:)``
38-
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-7tbs9``
39-
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-5yo2r``
38+
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-4txm0``
39+
- ``ConfigReader/watchStringArray(forKey:context:as:isSecret:default:fileID:line:updatesHandler:)-3eipe``
4040

4141
### Watching required lists of string values
4242
- ``ConfigReader/watchRequiredStringArray(forKey:isSecret:fileID:line:updatesHandler:)``
43-
- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-1t82o``
44-
- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-7lk1k``
43+
- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-3whiy``
44+
- ``ConfigReader/watchRequiredStringArray(forKey:as:isSecret:fileID:line:updatesHandler:)-4zyyq``
4545
- ``ConfigReader/watchRequiredStringArray(forKey:context:isSecret:fileID:line:updatesHandler:)``
46-
- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-5zo1e``
47-
- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-6kvcj``
46+
- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-97r4l``
47+
- ``ConfigReader/watchRequiredStringArray(forKey:context:as:isSecret:fileID:line:updatesHandler:)-4jcy3``
4848

4949
### Watching Boolean values
5050
- ``ConfigReader/watchBool(forKey:isSecret:fileID:line:updatesHandler:)``

Sources/Configuration/MultiProvider.swift

Lines changed: 110 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -195,28 +195,33 @@ extension MultiProvider {
195195
/// - Parameter body: A closure that receives an async sequence of ``MultiSnapshot`` updates.
196196
/// - Returns: The value returned by the body closure.
197197
/// - Throws: Any error thrown by the nested providers or the body closure.
198-
func watchSnapshot<Return>(
199-
_ body: (ConfigUpdatesAsyncSequence<MultiSnapshot, Never>) async throws -> Return
200-
) async throws -> Return {
198+
nonisolated(nonsending)
199+
func watchSnapshot<Return>(
200+
_ body: (ConfigUpdatesAsyncSequence<MultiSnapshot, Never>) async throws -> Return
201+
) async throws -> Return
202+
{
201203
let providers = storage.providers
202-
let sources:
203-
[@Sendable (
204-
(ConfigUpdatesAsyncSequence<any ConfigSnapshotProtocol, Never>) async throws -> Void
205-
) async throws -> Void] = providers.map { $0.watchSnapshot }
206-
return try await combineLatestOneOrMore(
207-
elementType: (any ConfigSnapshotProtocol).self,
208-
sources: sources,
209-
updatesHandler: { updateArrays in
210-
try await body(
211-
ConfigUpdatesAsyncSequence(
212-
updateArrays
213-
.map { array in
214-
MultiSnapshot(snapshots: array)
215-
}
216-
)
204+
typealias UpdatesSequence = any (AsyncSequence<any ConfigSnapshotProtocol, Never> & Sendable)
205+
var updateSequences: [UpdatesSequence] = []
206+
updateSequences.reserveCapacity(providers.count)
207+
return try await withProvidersWatchingSnapshot(
208+
providers: ArraySlice(providers),
209+
updateSequences: &updateSequences,
210+
) { providerUpdateSequences in
211+
let updateArrays = combineLatestMany(
212+
elementType: (any ConfigSnapshotProtocol).self,
213+
failureType: Never.self,
214+
providerUpdateSequences
215+
)
216+
return try await body(
217+
ConfigUpdatesAsyncSequence(
218+
updateArrays
219+
.map { array in
220+
MultiSnapshot(snapshots: array)
221+
}
217222
)
218-
}
219-
)
223+
)
224+
}
220225
}
221226

222227
/// Asynchronously resolves a configuration value from nested providers.
@@ -281,52 +286,97 @@ extension MultiProvider {
281286
/// - updatesHandler: A closure that receives an async sequence of combined updates from all providers.
282287
/// - Throws: Any error thrown by the nested providers or the handler closure.
283288
/// - Returns: The value returned by the handler.
284-
func watchValue<Return>(
285-
forKey key: AbsoluteConfigKey,
286-
type: ConfigType,
287-
updatesHandler: (
288-
ConfigUpdatesAsyncSequence<([AccessEvent.ProviderResult], Result<ConfigValue?, any Error>), Never>
289+
nonisolated(nonsending)
290+
func watchValue<Return>(
291+
forKey key: AbsoluteConfigKey,
292+
type: ConfigType,
293+
updatesHandler: (
294+
ConfigUpdatesAsyncSequence<([AccessEvent.ProviderResult], Result<ConfigValue?, any Error>), Never>
295+
) async throws -> Return
289296
) async throws -> Return
290-
) async throws -> Return {
297+
{
291298
let providers = storage.providers
292299
let providerNames = providers.map(\.providerName)
293-
let sources:
294-
[@Sendable (
295-
(
296-
ConfigUpdatesAsyncSequence<Result<LookupResult, any Error>, Never>
297-
) async throws -> Void
298-
) async throws -> Void] = providers.map { provider in
299-
{ handler in
300-
_ = try await provider.watchValue(forKey: key, type: type, updatesHandler: handler)
301-
}
302-
}
303-
return try await combineLatestOneOrMore(
304-
elementType: Result<LookupResult, any Error>.self,
305-
sources: sources,
306-
updatesHandler: { updateArrays in
307-
try await updatesHandler(
308-
ConfigUpdatesAsyncSequence(
309-
updateArrays
310-
.map { array in
311-
var results: [AccessEvent.ProviderResult] = []
312-
for (providerIndex, lookupResult) in array.enumerated() {
313-
let providerName = providerNames[providerIndex]
314-
results.append(.init(providerName: providerName, result: lookupResult))
315-
switch lookupResult {
316-
case .success(let value) where value.value == nil:
317-
// Got a success + nil from a nested provider, keep iterating.
318-
continue
319-
default:
320-
// Got a success + non-nil or an error from a nested provider, propagate that up.
321-
return (results, lookupResult.map { $0.value })
322-
}
300+
typealias UpdatesSequence = any (AsyncSequence<Result<LookupResult, any Error>, Never> & Sendable)
301+
var updateSequences: [UpdatesSequence] = []
302+
updateSequences.reserveCapacity(providers.count)
303+
return try await withProvidersWatchingValue(
304+
providers: ArraySlice(providers),
305+
updateSequences: &updateSequences,
306+
key: key,
307+
configType: type,
308+
) { providerUpdateSequences in
309+
let updateArrays = combineLatestMany(
310+
elementType: Result<LookupResult, any Error>.self,
311+
failureType: Never.self,
312+
providerUpdateSequences
313+
)
314+
return try await updatesHandler(
315+
ConfigUpdatesAsyncSequence(
316+
updateArrays
317+
.map { array in
318+
var results: [AccessEvent.ProviderResult] = []
319+
for (providerIndex, lookupResult) in array.enumerated() {
320+
let providerName = providerNames[providerIndex]
321+
results.append(.init(providerName: providerName, result: lookupResult))
322+
switch lookupResult {
323+
case .success(let value) where value.value == nil:
324+
// Got a success + nil from a nested provider, keep iterating.
325+
continue
326+
default:
327+
// Got a success + non-nil or an error from a nested provider, propagate that up.
328+
return (results, lookupResult.map { $0.value })
323329
}
324-
// If all nested results were success + nil, return the same.
325-
return (results, .success(nil))
326330
}
327-
)
331+
// If all nested results were success + nil, return the same.
332+
return (results, .success(nil))
333+
}
328334
)
329-
}
335+
)
336+
}
337+
}
338+
}
339+
340+
@available(Configuration 1.0, *)
341+
nonisolated(nonsending) private func withProvidersWatchingValue<ReturnInner>(
342+
providers: ArraySlice<any ConfigProvider>,
343+
updateSequences: inout [any (AsyncSequence<Result<LookupResult, any Error>, Never> & Sendable)],
344+
key: AbsoluteConfigKey,
345+
configType: ConfigType,
346+
body: ([any (AsyncSequence<Result<LookupResult, any Error>, Never> & Sendable)]) async throws -> ReturnInner
347+
) async throws -> ReturnInner {
348+
guard let provider = providers.first else {
349+
// Recursion termination, once we've collected all update sequences, execute the body.
350+
return try await body(updateSequences)
351+
}
352+
return try await provider.watchValue(forKey: key, type: configType) { updates in
353+
updateSequences.append(updates)
354+
return try await withProvidersWatchingValue(
355+
providers: providers.dropFirst(),
356+
updateSequences: &updateSequences,
357+
key: key,
358+
configType: configType,
359+
body: body
360+
)
361+
}
362+
}
363+
364+
@available(Configuration 1.0, *)
365+
nonisolated(nonsending) private func withProvidersWatchingSnapshot<ReturnInner>(
366+
providers: ArraySlice<any ConfigProvider>,
367+
updateSequences: inout [any (AsyncSequence<any ConfigSnapshotProtocol, Never> & Sendable)],
368+
body: ([any (AsyncSequence<any ConfigSnapshotProtocol, Never> & Sendable)]) async throws -> ReturnInner
369+
) async throws -> ReturnInner {
370+
guard let provider = providers.first else {
371+
// Recursion termination, once we've collected all update sequences, execute the body.
372+
return try await body(updateSequences)
373+
}
374+
return try await provider.watchSnapshot { updates in
375+
updateSequences.append(updates)
376+
return try await withProvidersWatchingSnapshot(
377+
providers: providers.dropFirst(),
378+
updateSequences: &updateSequences,
379+
body: body
330380
)
331381
}
332382
}

0 commit comments

Comments
 (0)