Skip to content

Commit 71c68f2

Browse files
committed
Refactor ThreadSafeBox to use non-optional type
Change the underlying storage type from `Value?` to storing `Value`, with optional-specific operations moved to constrained extensions. This improves type safety by eliminating unnecessary optionality for non-optional types. This stronger type constraint makes it more difficult for the struct to be misused. For instance in the current implementation the extension method `increment()` will fail to increment if the user forgot to initialize the `ThreadSafeBox` with a value. Now a value must be set initially before increment is called. Also this patch prevents a race condition in the `memoize` methods where the call to the memoized `body` was not guarded by a lock which allowed multiple threads to call memoize at once and produce inconsistent results. Finally, fix the subscript setter so it works properly with `structs`, as the `if var value` and then value set will set the value on a copy, not on the `self.underlying` struct. Now that the type is no longer optional this unwrapping isn't necessary and we can set directly on the value.
1 parent 444b5be commit 71c68f2

24 files changed

+824
-173
lines changed

Sources/Basics/Cancellator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ public final class Cancellator: Cancellable, Sendable {
9999

100100
@discardableResult
101101
public func register(name: String, handler: @escaping CancellationHandler) -> RegistrationKey? {
102-
if self.cancelling.get(default: false) {
102+
if self.cancelling.get() {
103103
self.observabilityScope?.emit(debug: "not registering '\(name)' with terminator, termination in progress")
104104
return .none
105105
}

Sources/Basics/Concurrency/AsyncProcess.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,7 @@ package final class AsyncProcess {
805805
package func waitUntilExit() throws -> AsyncProcessResult {
806806
let group = DispatchGroup()
807807
group.enter()
808-
let resultBox = ThreadSafeBox<Result<AsyncProcessResult, Swift.Error>>()
808+
let resultBox = ThreadSafeBox<Result<AsyncProcessResult, Swift.Error>?>()
809809
self.waitUntilExit { result in
810810
resultBox.put(result)
811811
group.leave()

Sources/Basics/Concurrency/ConcurrencyHelpers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public enum Concurrency {
2929
public func unsafe_await<T: Sendable>(_ body: @Sendable @escaping () async -> T) -> T {
3030
let semaphore = DispatchSemaphore(value: 0)
3131

32-
let box = ThreadSafeBox<T>()
32+
let box = ThreadSafeBox<T?>()
3333
Task {
3434
let localValue: T = await body()
3535
box.mutate { _ in localValue }

Sources/Basics/Concurrency/ThreadSafeBox.swift

Lines changed: 223 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -12,137 +12,302 @@
1212

1313
import class Foundation.NSLock
1414

15-
/// Thread-safe value boxing structure
15+
/// Thread-safe value boxing structure that provides synchronized access to a wrapped value.
1616
@dynamicMemberLookup
1717
public final class ThreadSafeBox<Value> {
18-
private var underlying: Value?
18+
private var underlying: Value
1919
private let lock = NSLock()
2020

21-
public init() {}
22-
21+
/// Creates a new thread-safe box with the given initial value.
22+
///
23+
/// - Parameter seed: The initial value to store in the box.
2324
public init(_ seed: Value) {
2425
self.underlying = seed
2526
}
2627

27-
public func mutate(body: (Value?) throws -> Value?) rethrows {
28+
/// Atomically mutates the stored value by applying a transformation function.
29+
///
30+
/// The transformation function receives the current value and returns a new value
31+
/// to replace it. The entire operation is performed under a lock to ensure atomicity.
32+
///
33+
/// - Parameter body: A closure that takes the current value and returns a new value.
34+
/// - Throws: Any error thrown by the transformation function.
35+
public func mutate(body: (Value) throws -> Value) rethrows {
2836
try self.lock.withLock {
2937
let value = try body(self.underlying)
3038
self.underlying = value
3139
}
3240
}
33-
34-
public func mutate(body: (inout Value?) throws -> ()) rethrows {
41+
42+
/// Atomically mutates the stored value by applying an in-place transformation.
43+
///
44+
/// The transformation function receives an inout reference to the current value,
45+
/// allowing direct modification. The entire operation is performed under a lock
46+
/// to ensure atomicity.
47+
///
48+
/// - Parameter body: A closure that receives an inout reference to the current value.
49+
/// - Throws: Any error thrown by the transformation function.
50+
public func mutate(body: (inout Value) throws -> Void) rethrows {
3551
try self.lock.withLock {
3652
try body(&self.underlying)
3753
}
3854
}
3955

40-
@discardableResult
41-
public func memoize(body: () throws -> Value) rethrows -> Value {
42-
if let value = self.get() {
43-
return value
44-
}
45-
let value = try body()
56+
/// Atomically retrieves the current value from the box.
57+
///
58+
/// - Returns: A copy of the current value stored in the box.
59+
public func get() -> Value {
4660
self.lock.withLock {
47-
self.underlying = value
61+
self.underlying
4862
}
49-
return value
5063
}
5164

52-
@discardableResult
53-
public func memoize(body: () async throws -> Value) async rethrows -> Value {
54-
if let value = self.get() {
55-
return value
56-
}
57-
let value = try await body()
65+
/// Atomically replaces the current value with a new value.
66+
///
67+
/// - Parameter newValue: The new value to store in the box.
68+
public func put(_ newValue: Value) {
5869
self.lock.withLock {
59-
self.underlying = value
70+
self.underlying = newValue
6071
}
61-
return value
6272
}
6373

64-
public func clear() {
74+
/// Provides thread-safe read-only access to properties of the wrapped value.
75+
///
76+
/// This subscript allows you to access properties of the wrapped value using
77+
/// dot notation while maintaining thread safety.
78+
///
79+
/// - Parameter keyPath: A key path to a property of the wrapped value.
80+
/// - Returns: The value of the specified property.
81+
public subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
6582
self.lock.withLock {
66-
self.underlying = nil
83+
self.underlying[keyPath: keyPath]
6784
}
6885
}
6986

70-
public func get() -> Value? {
71-
self.lock.withLock {
72-
self.underlying
87+
/// Provides thread-safe read-write access to properties of the wrapped value.
88+
///
89+
/// - Parameter keyPath: A writable key path to a property of the wrapped value.
90+
/// - Returns: The value of the specified property when getting.
91+
public subscript<T>(dynamicMember keyPath: WritableKeyPath<Value, T>) -> T {
92+
get {
93+
self.lock.withLock {
94+
self.underlying[keyPath: keyPath]
95+
}
96+
}
97+
set {
98+
self.lock.withLock {
99+
self.underlying[keyPath: keyPath] = newValue
100+
}
73101
}
74102
}
103+
}
75104

76-
public func get(default: Value) -> Value {
77-
self.lock.withLock {
78-
self.underlying ?? `default`
79-
}
105+
// Extension for optional values to support empty initialization
106+
extension ThreadSafeBox {
107+
/// Creates a new thread-safe box initialized with nil for optional value types.
108+
///
109+
/// This convenience initializer is only available when the wrapped value type is optional.
110+
public convenience init<Wrapped>() where Value == Wrapped? {
111+
self.init(nil)
80112
}
81113

82-
public func put(_ newValue: Value) {
114+
/// Takes the stored optional value, setting it to nil.
115+
/// - Returns: The previously stored value, or nil if none was present.
116+
public func takeValue<Wrapped>() -> Value where Value == Wrapped? {
83117
self.lock.withLock {
84-
self.underlying = newValue
118+
guard let value = self.underlying else { return nil }
119+
self.underlying = nil
120+
return value
85121
}
86122
}
87123

88-
public func takeValue<U>() -> Value where U? == Value {
124+
/// Atomically sets the stored optional value to nil.
125+
///
126+
/// This method is only available when the wrapped value type is optional.
127+
public func clear<Wrapped>() where Value == Wrapped? {
89128
self.lock.withLock {
90-
guard let value = self.underlying else { return nil }
91129
self.underlying = nil
92-
return value
93130
}
94131
}
95132

96-
public subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T? {
133+
/// Atomically retrieves the stored value, returning a default if nil.
134+
///
135+
/// This method is only available when the wrapped value type is optional.
136+
///
137+
/// - Parameter defaultValue: The value to return if the stored value is nil.
138+
/// - Returns: The stored value if not nil, otherwise the default value.
139+
public func get<Wrapped>(default defaultValue: Wrapped) -> Wrapped where Value == Wrapped? {
97140
self.lock.withLock {
98-
self.underlying?[keyPath: keyPath]
141+
self.underlying ?? defaultValue
99142
}
100143
}
101144

102-
public subscript<T>(dynamicMember keyPath: WritableKeyPath<Value, T?>) -> T? {
103-
get {
104-
self.lock.withLock {
105-
self.underlying?[keyPath: keyPath]
145+
/// Atomically computes and caches a value if not already present.
146+
///
147+
/// If the box already contains a non-nil value, that value is returned immediately.
148+
/// Otherwise, the provided closure is executed to compute the value, which is then
149+
/// stored and returned. This method is only available when the wrapped value type is optional.
150+
///
151+
/// - Parameter body: A closure that computes the value to store if none exists.
152+
/// - Returns: The cached value or the newly computed value.
153+
/// - Throws: Any error thrown by the computation closure.
154+
@discardableResult
155+
public func memoize<Wrapped>(body: () throws -> Wrapped) rethrows -> Wrapped where Value == Wrapped? {
156+
try self.lock.withLock {
157+
if let value = self.underlying {
158+
return value
106159
}
160+
let value = try body()
161+
self.underlying = value
162+
return value
107163
}
108-
set {
109-
self.lock.withLock {
110-
if var value = self.underlying {
111-
value[keyPath: keyPath] = newValue
112-
}
164+
}
165+
166+
/// Atomically computes and caches an optional value if not already present.
167+
///
168+
/// If the box already contains a non-nil value, that value is returned immediately.
169+
/// Otherwise, the provided closure is executed to compute the value, which is then
170+
/// stored and returned. This method is only available when the wrapped value type is optional.
171+
///
172+
/// If the returned value is `nil` subsequent calls to `memoize` or `memoizeOptional` will
173+
/// re-execute the closure.
174+
///
175+
/// - Parameter body: A closure that computes the optional value to store if none exists.
176+
/// - Returns: The cached value or the newly computed value (which may be nil).
177+
/// - Throws: Any error thrown by the computation closure.
178+
@discardableResult
179+
public func memoizeOptional<Wrapped>(body: () throws -> Wrapped?) rethrows -> Wrapped? where Value == Wrapped? {
180+
try self.lock.withLock {
181+
if let value = self.underlying {
182+
return value
113183
}
184+
let value = try body()
185+
self.underlying = value
186+
return value
114187
}
115188
}
116189
}
117190

118191
extension ThreadSafeBox where Value == Int {
192+
/// Atomically increments the stored integer value by 1.
193+
///
194+
/// This method is only available when the wrapped value type is Int.
119195
public func increment() {
120196
self.lock.withLock {
121-
if let value = self.underlying {
122-
self.underlying = value + 1
123-
}
197+
self.underlying = self.underlying + 1
124198
}
125199
}
126200

201+
/// Atomically decrements the stored integer value by 1.
202+
///
203+
/// This method is only available when the wrapped value type is Int.
127204
public func decrement() {
128205
self.lock.withLock {
129-
if let value = self.underlying {
130-
self.underlying = value - 1
131-
}
206+
self.underlying = self.underlying - 1
132207
}
133208
}
134209
}
135210

136211
extension ThreadSafeBox where Value == String {
212+
/// Atomically appends a string to the stored string value.
213+
///
214+
/// This method is only available when the wrapped value type is String.
215+
///
216+
/// - Parameter value: The string to append to the current stored value.
137217
public func append(_ value: String) {
138218
self.mutate { existingValue in
139-
if let existingValue {
140-
return existingValue + value
141-
} else {
219+
existingValue + value
220+
}
221+
}
222+
}
223+
224+
extension ThreadSafeBox: @unchecked Sendable where Value: Sendable {}
225+
226+
/// Thread-safe value boxing structure that provides synchronized asynchronous memoization of a wrapped value.
227+
public final class AsyncMemoizableThreadSafeBox<Value: Sendable>: @unchecked Sendable {
228+
private var underlying: Value?
229+
private let lock = NSLock()
230+
private let asyncCoordination = AsyncMemoizationCoordinator<Value>()
231+
232+
public init() {}
233+
234+
/// Atomically retrieves the current value from the box.
235+
///
236+
/// - Returns: The current value stored in the box, or nil if none is present.
237+
public func get() -> Value? {
238+
self.lock.withLock {
239+
self.underlying
240+
}
241+
}
242+
243+
/// Atomically computes and caches a value produced by an async function, if not already present.
244+
///
245+
/// If the box already contains a non-nil value that value is returned immediately.
246+
/// Otherwise, the provided async closure is executed to compute the value, which is then
247+
/// stored and returned.
248+
///
249+
/// Concurrent calls to memoize will wait for the first call to complete and receive its result.
250+
/// If the body throws an error, all pending calls receive that error and the state is reset.
251+
///
252+
/// - Parameter body: An async closure that computes the value to store if none exists.
253+
/// - Returns: The cached value or the newly computed value.
254+
/// - Throws: Any error thrown by the computation closure.
255+
@discardableResult
256+
public func memoize(body: @Sendable @escaping () async throws -> Value) async throws -> Value {
257+
if let value = self.get() {
258+
return value
259+
}
260+
261+
// Try to register as the executor, or get the existing task
262+
let task: Task<Value, Error> = await self.asyncCoordination.getOrCreateTask {
263+
// This closure is only called by the first caller
264+
Task<Value, Error> {
265+
// Double-check after acquiring coordination
266+
if let value = self.get() {
267+
return value
268+
}
269+
270+
let value = try await body()
271+
272+
// Store the value
273+
self.lock.withLock {
274+
self.underlying = value
275+
}
142276
return value
143277
}
144278
}
279+
280+
// Everyone (including the first caller) awaits the same task
281+
do {
282+
let result = try await task.value
283+
await self.asyncCoordination.clearTask()
284+
return result
285+
} catch {
286+
await self.asyncCoordination.clearTask()
287+
throw error
288+
}
289+
}
290+
291+
// Actor for coordinating async memoization within a thread safe box
292+
private actor AsyncMemoizationCoordinator<T: Sendable>: Sendable {
293+
private var inProgressTask: Task<T, Error>?
294+
295+
/// Returns an existing task if one is in progress, or creates and stores a new one
296+
func getOrCreateTask(_ createTask: @Sendable () -> Task<T, Error>) -> Task<T, Error> {
297+
if let existingTask = inProgressTask {
298+
return existingTask
299+
}
300+
301+
// We're the first - create and store the task
302+
let task = createTask()
303+
inProgressTask = task
304+
return task
305+
}
306+
307+
/// Clears the current task
308+
func clearTask() {
309+
inProgressTask = nil
310+
}
145311
}
146312
}
147313

148-
extension ThreadSafeBox: @unchecked Sendable where Value: Sendable {}

Sources/Basics/Observability.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ public final class ObservabilityScope: DiagnosticsEmitterProtocol, Sendable, Cus
151151
}
152152

153153
var errorsReported: Bool {
154-
self._errorsReported.get() ?? false
154+
self._errorsReported.get()
155155
}
156156
}
157157
}

0 commit comments

Comments
 (0)