Skip to content

Commit e559319

Browse files
mbrandonwpyrtsa
andauthored
Make file writes atomic, and don't decode empty file. (#129)
* Don't decode empty file. * wip * wip * 15.2 fix * 15.2 fix * bring back atomic * atomic working again * clean upo * new lines * Update Sources/Sharing/SharedKeys/FileStorageKey.swift Co-authored-by: Pyry Jahkola <[email protected]> * Increase test iterations. * Get rid of stub bytes. --------- Co-authored-by: Pyry Jahkola <[email protected]>
1 parent 2e983b5 commit e559319

File tree

2 files changed

+109
-35
lines changed

2 files changed

+109
-35
lines changed

Sources/Sharing/SharedKeys/FileStorageKey.swift

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
public func load(context _: LoadContext<Value>, continuation: LoadContinuation<Value>) {
108108
guard
109109
let data = try? storage.load(url),
110-
data != stubBytes
110+
!data.isEmpty
111111
else {
112112
continuation.resumeReturningInitialValue()
113113
return
@@ -127,40 +127,65 @@
127127
// NB: Make sure there is a file to create a source for.
128128
if !storage.fileExists(url) {
129129
try storage.createDirectory(url.deletingLastPathComponent(), true)
130-
try storage.save(stubBytes, url)
130+
try storage.save(Data(), url)
131131
}
132-
let writeCancellable = try storage.fileSystemSource(url, [.write]) { [weak self] in
132+
let externalCancellable = try storage.fileSystemSource(url, [.write, .rename]) {
133+
[weak self] in
133134
guard let self else { return }
134-
state.withValue { state in
135-
let modificationDate =
136-
(try? self.storage.attributesOfItemAtPath(self.url.path)[.modificationDate]
137-
as? Date)
138-
?? Date.distantPast
135+
let fileExists = self.storage.fileExists(self.url)
136+
defer {
137+
if !fileExists {
138+
setUpSources()
139+
}
140+
}
141+
if state.withValue({ $0.workItem == nil }) {
142+
if fileExists {
143+
subscriber.yield(with: Result { try self.decode(self.storage.load(self.url)) })
144+
} else {
145+
subscriber.yieldReturningInitialValue()
146+
}
147+
}
148+
}
149+
let internalCancellable = try storage.fileSystemSource(url, [.delete]) {
150+
[weak self] in
151+
guard let self else { return }
152+
let fileExists = self.storage.fileExists(self.url)
153+
defer {
154+
if !fileExists {
155+
setUpSources()
156+
}
157+
}
158+
let modificationDate =
159+
fileExists
160+
? (try? self.storage.attributesOfItemAtPath(self.url.path)[.modificationDate]
161+
as? Date)
162+
: nil
163+
let shouldYield = state.withValue { state in
164+
guard fileExists
165+
else {
166+
state.cancelWorkItem()
167+
return true
168+
}
169+
let modificationDate = modificationDate ?? .distantPast
139170
guard
140171
!state.modificationDates.contains(modificationDate)
141172
else {
142173
state.modificationDates.removeAll(where: { $0 <= modificationDate })
143-
return
174+
return false
144175
}
145-
146-
guard state.workItem == nil
147-
else { return }
148-
149-
subscriber.yield(with: Result { try self.decode(self.storage.load(self.url)) })
176+
return state.workItem == nil
150177
}
151-
}
152-
let deleteCancellable = try storage.fileSystemSource(url, [.delete, .rename]) {
153-
[weak self] in
154-
guard let self else { return }
155-
state.withValue { state in
156-
state.cancelWorkItem()
178+
if shouldYield {
179+
if fileExists {
180+
subscriber.yield(with: Result { try self.decode(self.storage.load(self.url)) })
181+
} else {
182+
subscriber.yieldReturningInitialValue()
183+
}
157184
}
158-
subscriber.yield(with: .success(try? decode(storage.load(url))))
159-
setUpSources()
160185
}
161186
$0 = SharedSubscription {
162-
writeCancellable.cancel()
163-
deleteCancellable.cancel()
187+
externalCancellable.cancel()
188+
internalCancellable.cancel()
164189
}
165190
} catch {
166191
subscriber.yield(throwing: error)
@@ -353,8 +378,12 @@
353378
close(source.handle)
354379
}
355380
},
356-
load: { try Data(contentsOf: $0) },
357-
save: { try $0.write(to: $1) }
381+
load: { url in
382+
try Data(contentsOf: url)
383+
},
384+
save: { data, url in
385+
try data.write(to: url, options: .atomic)
386+
}
358387
)
359388

360389
/// File storage that emulates a file system without actually writing anything to disk.
@@ -413,6 +442,4 @@
413442
#endif
414443
return encoder
415444
}()
416-
417-
private let stubBytes = Data("co.pointfree.Sharing.FileStorage.stub".utf8)
418445
#endif

Tests/SharingTests/FileStorageTests.swift

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
@Shared(.fileStorage(.fileURL)) var users = [User]()
1818
#expect($users.loadError == nil)
1919
expectNoDifference(
20-
fileSystem.value, [.fileURL: Data("co.pointfree.Sharing.FileStorage.stub".utf8)]
20+
fileSystem.value, [.fileURL: Data()]
2121
)
2222
$users.withLock { $0.append(.blob) }
2323
try expectNoDifference(fileSystem.value.users(for: .fileURL), [.blob])
@@ -31,7 +31,7 @@
3131
@Shared(.utf8String) var string = ""
3232
#expect($string.loadError == nil)
3333
expectNoDifference(
34-
fileSystem.value, [.utf8StringURL: Data("co.pointfree.Sharing.FileStorage.stub".utf8)]
34+
fileSystem.value, [.utf8StringURL: Data()]
3535
)
3636
$string.withLock { $0 = "hello" }
3737
expectNoDifference(
@@ -269,6 +269,24 @@
269269
}
270270
}
271271

272+
@Test func moveFileThenWrite() async throws {
273+
try await withMainSerialExecutor {
274+
try JSONEncoder().encode([User.blob]).write(to: .fileURL)
275+
276+
@Shared(.fileStorage(.fileURL)) var users = [User]()
277+
await Task.yield()
278+
expectNoDifference(users, [.blob])
279+
280+
try FileManager.default.moveItem(at: .fileURL, to: .anotherFileURL)
281+
try await Task.sleep(nanoseconds: 100_000_000)
282+
expectNoDifference(users, [])
283+
284+
try JSONEncoder().encode([User.blobEsq]).write(to: .fileURL)
285+
try await Task.sleep(nanoseconds: 1_000_000_000)
286+
expectNoDifference(users, [.blobEsq])
287+
}
288+
}
289+
272290
@Test func testDeleteFileThenWriteToFile() async throws {
273291
try await withMainSerialExecutor {
274292
try JSONEncoder().encode([User.blob]).write(to: .fileURL)
@@ -299,7 +317,7 @@
299317
try await Task.sleep(nanoseconds: 1_200_000_000)
300318
expectNoDifference(users, [.blob])
301319
#expect(
302-
try Data(contentsOf: .fileURL) == Data("co.pointfree.Sharing.FileStorage.stub".utf8)
320+
try Data(contentsOf: .fileURL) == Data()
303321
)
304322
}
305323
}
@@ -356,14 +374,16 @@
356374
@MainActor
357375
@Test func multipleMutations() async throws {
358376
@Shared(.counts) var counts
359-
for m in 1...1000 {
360-
for n in 1...10 {
377+
let iterations = 1_000
378+
let buckets = 10
379+
for m in 1...iterations {
380+
for n in 1...buckets {
361381
$counts.withLock {
362382
$0[n, default: 0] += 1
363383
}
364384
}
365385
expectNoDifference(
366-
Dictionary((1...10).map { n in (n, m) }, uniquingKeysWith: { $1 }),
386+
Dictionary((1...buckets).map { n in (n, m) }, uniquingKeysWith: { $1 }),
367387
counts
368388
)
369389
try await Task.sleep(nanoseconds: 1_000_000)
@@ -386,14 +406,41 @@
386406

387407
#expect(counts[0] == 10_000)
388408
}
409+
410+
@Test func emptyData() throws {
411+
try? FileManager.default.removeItem(at: .fileURL)
412+
try Data().write(to: .fileURL)
413+
@Shared(.fileStorage(.fileURL)) var count = 0
414+
#expect(count == 0)
415+
}
416+
417+
@Test func corruptData() async throws {
418+
try? FileManager.default.removeItem(at: .fileURL)
419+
try Data("corrupted".utf8).write(to: .fileURL)
420+
@Shared(value: 0) var count: Int
421+
withKnownIssue {
422+
$count = Shared(wrappedValue: 0, .fileStorage(.fileURL))
423+
} matching: {
424+
$0.description.hasPrefix("""
425+
Caught error: dataCorrupted(Swift.DecodingError.Context(codingPath: [], \
426+
debugDescription: "The given data was not valid JSON.", underlyingError: \
427+
Optional(Error Domain=NSCocoaErrorDomain Code=3840 "Unexpected character
428+
""")
429+
}
430+
#expect(count == 0)
431+
$count.withLock { $0 = 1 }
432+
try await Task.sleep(for: .seconds(0.01))
433+
#expect(count == 1)
434+
#expect(try String(decoding: Data(contentsOf: .fileURL), as: UTF8.self) == "1")
435+
}
389436
}
390437
}
391438

392439
extension [URL: Data] {
393440
fileprivate func users(for url: URL) throws -> [User]? {
394441
guard
395442
let data = self[url],
396-
data != Data("co.pointfree.Sharing.FileStorage.stub".utf8)
443+
!data.isEmpty
397444
else { return nil }
398445
return try JSONDecoder().decode([User].self, from: data)
399446
}

0 commit comments

Comments
 (0)