Skip to content

Commit 2170719

Browse files
Fix race condition in FileStorageKey (#3479)
* Failing test for multiple mutations to file storage. * wip * wip * fixes * wip --------- Co-authored-by: Stephen Celis <[email protected]>
1 parent a952dde commit 2170719

File tree

2 files changed

+102
-52
lines changed

2 files changed

+102
-52
lines changed

Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift

Lines changed: 34 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ extension PersistenceReaderKey {
4545
/// Use ``PersistenceReaderKey/fileStorage(_:decoder:encoder:)`` to create values of this type.
4646
public final class FileStorageKey<Value: Sendable>: PersistenceKey, Sendable {
4747
private let storage: FileStorage
48-
private let isSetting = LockIsolated(false)
4948
private let url: URL
5049
private let decode: @Sendable (Data) throws -> Value
5150
private let encode: @Sendable (Value) throws -> Data
@@ -83,7 +82,6 @@ public final class FileStorageKey<Value: Sendable>: PersistenceKey, Sendable {
8382
public func save(_ value: Value) {
8483
self.state.withValue { state in
8584
if state.workItem == nil {
86-
self.isSetting.setValue(true)
8785
try? self.storage.save(encode(value), self.url)
8886
let workItem = DispatchWorkItem { [weak self] in
8987
guard let self else { return }
@@ -94,7 +92,6 @@ public final class FileStorageKey<Value: Sendable>: PersistenceKey, Sendable {
9492
}
9593
guard let value = state.value
9694
else { return }
97-
self.isSetting.setValue(true)
9895
try? self.storage.save(self.encode(value), self.url)
9996
}
10097
}
@@ -125,14 +122,12 @@ public final class FileStorageKey<Value: Sendable>: PersistenceKey, Sendable {
125122
try? self.storage.save(Data(), self.url)
126123
}
127124
let writeCancellable = self.storage.fileSystemSource(self.url, [.write]) {
125+
// TODO: Improve this by fingerprinting (by adding extra bytes?) the file we write to the
126+
// file system so that we can early out of this closure.
128127
self.state.withValue { state in
129-
if self.isSetting.value == true {
130-
self.isSetting.setValue(false)
131-
} else {
132-
state.workItem?.cancel()
133-
state.workItem = nil
134-
didSet(self.load(initialValue: initialValue))
135-
}
128+
guard state.workItem == nil
129+
else { return }
130+
didSet(self.load(initialValue: initialValue))
136131
}
137132
}
138133
let deleteCancellable = self.storage.fileSystemSource(self.url, [.delete, .rename]) {
@@ -265,15 +260,6 @@ public struct FileStorage: Hashable, Sendable {
265260
let load: @Sendable (URL) throws -> Data
266261
@_spi(Internals) public let save: @Sendable (Data, URL) throws -> Void
267262

268-
/// File storage that interacts directly with the file system for saving, loading and listening
269-
/// for file changes.
270-
///
271-
/// This is the version of the ``Dependencies/DependencyValues/defaultFileStorage`` dependency
272-
/// that is used by default when running your app in the simulator or on device.
273-
public static let fileSystem = fileSystem(
274-
queue: DispatchQueue(label: "co.pointfree.ComposableArchitecture.FileStorage")
275-
)
276-
277263
/// File storage that emulates a file system without actually writing anything to disk.
278264
///
279265
/// This is the version of the ``Dependencies/DependencyValues/defaultFileStorage`` dependency
@@ -282,32 +268,35 @@ public struct FileStorage: Hashable, Sendable {
282268
inMemory(fileSystem: LockIsolated([:]))
283269
}
284270

285-
@_spi(Internals) public static func fileSystem(queue: DispatchQueue) -> Self {
286-
Self(
287-
id: AnyHashableSendable(queue),
288-
async: { queue.async(execute: $0) },
289-
asyncAfter: { queue.asyncAfter(deadline: .now() + $0, execute: $1) },
290-
createDirectory: {
291-
try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: $1)
292-
},
293-
fileExists: { FileManager.default.fileExists(atPath: $0.path) },
294-
fileSystemSource: {
295-
let source = DispatchSource.makeFileSystemObjectSource(
296-
fileDescriptor: open($0.path, O_EVTONLY),
297-
eventMask: $1,
298-
queue: queue
299-
)
300-
source.setEventHandler(handler: $2)
301-
source.resume()
302-
return AnyCancellable {
303-
source.cancel()
304-
close(source.handle)
305-
}
306-
},
307-
load: { try Data(contentsOf: $0) },
308-
save: { try $0.write(to: $1) }
309-
)
310-
}
271+
/// File storage that interacts directly with the file system for saving, loading and listening
272+
/// for file changes.
273+
///
274+
/// This is the version of the ``Dependencies/DependencyValues/defaultFileStorage`` dependency
275+
/// that is used by default when running your app in the simulator or on device.
276+
public static let fileSystem = Self(
277+
id: AnyHashableSendable(DispatchQueue.main),
278+
async: { DispatchQueue.main.async(execute: $0) },
279+
asyncAfter: { DispatchQueue.main.asyncAfter(deadline: .now() + $0, execute: $1) },
280+
createDirectory: {
281+
try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: $1)
282+
},
283+
fileExists: { FileManager.default.fileExists(atPath: $0.path) },
284+
fileSystemSource: {
285+
let source = DispatchSource.makeFileSystemObjectSource(
286+
fileDescriptor: open($0.path, O_EVTONLY),
287+
eventMask: $1,
288+
queue: .main
289+
)
290+
source.setEventHandler(handler: $2)
291+
source.resume()
292+
return AnyCancellable {
293+
source.cancel()
294+
close(source.handle)
295+
}
296+
},
297+
load: { try Data(contentsOf: $0) },
298+
save: { try $0.write(to: $1) }
299+
)
311300

312301
@_spi(Internals) public static func inMemory(
313302
fileSystem: LockIsolated<[URL: Data]>,

Tests/ComposableArchitectureTests/FileStorageTests.swift

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ final class FileStorageTests: XCTestCase {
163163
try? FileManager.default.removeItem(at: .fileURL)
164164

165165
try await withDependencies {
166-
$0.defaultFileStorage = .fileSystem(queue: .main)
166+
$0.defaultFileStorage = .fileSystem
167167
} operation: {
168168
@Shared(.fileStorage(.fileURL)) var users = [User]()
169169

@@ -201,7 +201,7 @@ final class FileStorageTests: XCTestCase {
201201
try JSONEncoder().encode([User.blob]).write(to: .fileURL)
202202

203203
try await withDependencies {
204-
$0.defaultFileStorage = .fileSystem(queue: .main)
204+
$0.defaultFileStorage = .fileSystem
205205
} operation: {
206206
@Shared(.fileStorage(.fileURL)) var users = [User]()
207207
_ = users
@@ -220,7 +220,7 @@ final class FileStorageTests: XCTestCase {
220220
try JSONEncoder().encode([User.blob]).write(to: .fileURL)
221221

222222
try await withDependencies {
223-
$0.defaultFileStorage = .fileSystem(queue: .main)
223+
$0.defaultFileStorage = .fileSystem
224224
} operation: {
225225
@Shared(.fileStorage(.fileURL)) var users = [User]()
226226
await Task.yield()
@@ -251,7 +251,7 @@ final class FileStorageTests: XCTestCase {
251251

252252
try fileStorage.save(Data(), .fileURL)
253253
scheduler.run()
254-
expectNoDifference(users, [])
254+
expectNoDifference(users, [.blob])
255255
try expectNoDifference(fileSystem.value.users(for: .fileURL), nil)
256256
}
257257
}
@@ -262,7 +262,7 @@ final class FileStorageTests: XCTestCase {
262262
try JSONEncoder().encode([User.blob]).write(to: .fileURL)
263263

264264
try await withDependencies {
265-
$0.defaultFileStorage = .fileSystem(queue: .main)
265+
$0.defaultFileStorage = .fileSystem
266266
} operation: {
267267
@Shared(.fileStorage(.fileURL)) var users = [User]()
268268
await Task.yield()
@@ -282,7 +282,7 @@ final class FileStorageTests: XCTestCase {
282282
try JSONEncoder().encode([User.blob]).write(to: .fileURL)
283283

284284
try await withDependencies {
285-
$0.defaultFileStorage = .fileSystem(queue: .main)
285+
$0.defaultFileStorage = .fileSystem
286286
} operation: {
287287
@Shared(.fileStorage(.fileURL)) var users = [User]()
288288
await Task.yield()
@@ -306,7 +306,7 @@ final class FileStorageTests: XCTestCase {
306306
try JSONEncoder().encode([User.blob]).write(to: .fileURL)
307307

308308
try await withDependencies {
309-
$0.defaultFileStorage = .fileSystem(queue: .main)
309+
$0.defaultFileStorage = .fileSystem
310310
} operation: {
311311
@Shared(.fileStorage(.fileURL)) var users = [User]()
312312
await Task.yield()
@@ -471,6 +471,56 @@ final class FileStorageTests: XCTestCase {
471471
await fulfillment(of: [publisherExpectation], timeout: 1)
472472
}
473473
}
474+
475+
@MainActor
476+
func testMultipleMutations() async throws {
477+
try? FileManager.default.removeItem(
478+
at: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("counts.json")
479+
)
480+
481+
try await withDependencies {
482+
$0.defaultFileStorage = .fileSystem
483+
} operation: {
484+
@Shared(.counts) var counts
485+
for m in 1...1000 {
486+
for n in 1...10 {
487+
$counts.withLock {
488+
$0[n, default: 0] += 1
489+
}
490+
}
491+
expectNoDifference(
492+
Dictionary((1...10).map { n in (n, m) }, uniquingKeysWith: { $1 }),
493+
counts
494+
)
495+
try await Task.sleep(for: .seconds(0.001))
496+
}
497+
}
498+
}
499+
500+
func testMultipleMutationsFromMultipleThreads() async throws {
501+
try? FileManager.default.removeItem(
502+
at: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("counts.json")
503+
)
504+
505+
await withDependencies {
506+
$0.defaultFileStorage = .fileSystem
507+
} operation: {
508+
@Shared(.counts) var counts
509+
510+
await withTaskGroup(of: Void.self) { group in
511+
for _ in 1...1000 {
512+
group.addTask { [$counts] in
513+
for _ in 1...10 {
514+
await $counts.withLock { $0[0, default: 0] += 1 }
515+
try? await Task.sleep(for: .seconds(0.2))
516+
}
517+
}
518+
}
519+
}
520+
521+
XCTAssertEqual(counts[0], 10_000)
522+
}
523+
}
474524
}
475525

476526
extension PersistenceReaderKey
@@ -513,3 +563,14 @@ extension [URL: Data] {
513563
return try JSONDecoder().decode([User].self, from: data)
514564
}
515565
}
566+
567+
extension PersistenceKey where Self == PersistenceKeyDefault<FileStorageKey<[Int: Int]>> {
568+
fileprivate static var counts: Self {
569+
Self(
570+
.fileStorage(
571+
URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("counts.json")
572+
),
573+
[:]
574+
)
575+
}
576+
}

0 commit comments

Comments
 (0)