@@ -41,4 +41,204 @@ class FileCacheTests: XCTestCase {
4141
4242 XCTAssertEqual ( try Data ( contentsOf: testFileURL) , serializedData)
4343 }
44+
45+ // MARK: - Cache behaviour
46+
47+ func testReadReturnsCachedContentOnSubsequentCalls( ) throws {
48+ let value = " cached-value "
49+ try JSONEncoder ( ) . encode ( value) . write ( to: testFileURL)
50+
51+ let fileCache = FileCache < String > ( fileURL: testFileURL)
52+ let first = try fileCache. read ( )
53+ let second = try fileCache. read ( )
54+
55+ XCTAssertEqual ( first, value)
56+ XCTAssertEqual ( second, value)
57+ }
58+
59+ func testWriteUpdatesCachedContent( ) throws {
60+ let fileCache = FileCache < String > ( fileURL: testFileURL)
61+
62+ try fileCache. write ( " first " )
63+ XCTAssertEqual ( try fileCache. read ( ) , " first " )
64+
65+ try fileCache. write ( " second " )
66+ XCTAssertEqual ( try fileCache. read ( ) , " second " )
67+ }
68+
69+ func testClearInvalidatesCache( ) throws {
70+ let fileCache = FileCache < String > ( fileURL: testFileURL)
71+
72+ try fileCache. write ( " value " )
73+ XCTAssertEqual ( try fileCache. read ( ) , " value " )
74+
75+ try fileCache. clear ( )
76+
77+ XCTAssertThrowsError ( try fileCache. read ( ) )
78+ }
79+
80+ // MARK: - Thundering herd
81+
82+ /// Spawn many concurrent readers on a single FileCache instance to verify there is no deadlock
83+ /// and all readers return the correct value.
84+ func testThunderingHerdReads( ) throws {
85+ let value = " herd-read-value "
86+ try JSONEncoder ( ) . encode ( value) . write ( to: testFileURL)
87+
88+ let fileCache = FileCache < String > ( fileURL: testFileURL)
89+ let iterations = 200
90+
91+ DispatchQueue . concurrentPerform ( iterations: iterations) { _ in
92+ do {
93+ let result = try fileCache. read ( )
94+ XCTAssertEqual ( result, value)
95+ } catch {
96+ XCTFail ( " Concurrent read failed: \( error) " )
97+ }
98+ }
99+ }
100+
101+ /// Spawn many concurrent writers that each write a unique value, then verify the file contains
102+ /// one of the written values and reading back returns the same value.
103+ func testThunderingHerdWrites( ) throws {
104+ let fileCache = FileCache < String > ( fileURL: testFileURL)
105+ let iterations = 200
106+
107+ DispatchQueue . concurrentPerform ( iterations: iterations) { i in
108+ do {
109+ try fileCache. write ( " value- \( i) " )
110+ } catch {
111+ XCTFail ( " Concurrent write failed: \( error) " )
112+ }
113+ }
114+
115+ // The last writer wins; just verify we can read back consistently.
116+ let result = try fileCache. read ( )
117+ XCTAssertTrue ( result. hasPrefix ( " value- " ) , " Expected one of the written values, got: \( result) " )
118+ }
119+
120+ /// Interleave reads and writes from many threads to check for deadlocks and data races.
121+ func testThunderingHerdMixedReadsAndWrites( ) throws {
122+ try JSONEncoder ( ) . encode ( " initial " ) . write ( to: testFileURL)
123+
124+ let fileCache = FileCache < String > ( fileURL: testFileURL)
125+ let iterations = 200
126+
127+ DispatchQueue . concurrentPerform ( iterations: iterations) { i in
128+ do {
129+ if i. isMultiple ( of: 3 ) {
130+ try fileCache. write ( " mixed- \( i) " )
131+ } else {
132+ _ = try fileCache. read ( )
133+ }
134+ } catch {
135+ XCTFail ( " Mixed concurrent operation failed at iteration \( i) : \( error) " )
136+ }
137+ }
138+
139+ let result = try fileCache. read ( )
140+ XCTAssertEqual ( result, " mixed-198 " )
141+ XCTAssertFalse ( result. isEmpty)
142+ }
143+
144+ // MARK: - Deadlock smoke tests
145+
146+ /// Rapidly alternate write-then-read on many threads to provoke coordinator / cache-queue ordering issues.
147+ func testWriteThenReadDoesNotDeadlock( ) throws {
148+ let fileCache = FileCache < String > ( fileURL: testFileURL)
149+ let iterations = 100
150+
151+ DispatchQueue . concurrentPerform ( iterations: iterations) { i in
152+ do {
153+ try fileCache. write ( " wtr- \( i) " )
154+ let result = try fileCache. read ( )
155+ XCTAssertTrue ( result. hasPrefix ( " wtr- " ) )
156+ } catch {
157+ XCTFail ( " Write-then-read failed at iteration \( i) : \( error) " )
158+ }
159+ }
160+ }
161+
162+ /// Rapidly alternate write-then-clear on many threads to exercise the forDeleting / forReplacing coordination paths.
163+ func testConcurrentWriteAndClear( ) throws {
164+ let fileCache = FileCache < String > ( fileURL: testFileURL)
165+ let iterations = 100
166+
167+ DispatchQueue . concurrentPerform ( iterations: iterations) { i in
168+ if i. isMultiple ( of: 2 ) {
169+ try ? fileCache. write ( " clear- \( i) " )
170+ } else {
171+ try ? fileCache. clear ( )
172+ }
173+ }
174+
175+ // After the storm, write a known value and verify consistency.
176+ try fileCache. write ( " final " )
177+ XCTAssertEqual ( try fileCache. read ( ) , " final " )
178+ }
179+
180+ /// Simulate the iOS 17 scenario where `presentedItemDidChange` fires while coordinated operations
181+ /// are in flight by calling it manually from many threads alongside reads and writes.
182+ func testPresenterCallbacksDuringConcurrentAccess( ) throws {
183+ try JSONEncoder ( ) . encode ( " presenter-initial " ) . write ( to: testFileURL)
184+
185+ let fileCache = FileCache < String > ( fileURL: testFileURL)
186+ let iterations = 200
187+
188+ DispatchQueue . concurrentPerform ( iterations: iterations) { i in
189+ switch i % 4 {
190+ case 0 :
191+ try ? fileCache. write ( " presenter- \( i) " )
192+ case 1 :
193+ _ = try ? fileCache. read ( )
194+ case 2 :
195+ // Simulate the iOS 17 spurious presenter callback.
196+ fileCache. presentedItemDidChange ( )
197+ default :
198+ fileCache. accommodatePresentedItemDeletion { _ in }
199+ }
200+ }
201+
202+ XCTAssertEqual ( try fileCache. read ( ) , " presenter-196 " )
203+ // Stabilize: write a known value and confirm round-trip.
204+ try fileCache. write ( " after-presenter-storm " )
205+ XCTAssertEqual ( try fileCache. read ( ) , " after-presenter-storm " )
206+ }
207+
208+ /// Deadlock smoke test with a timeout — if any coordinated operation deadlocks, the test will
209+ /// fail by exceeding the XCTest timeout rather than hanging the suite indefinitely.
210+ func testNoDeadlockUnderTimeout( ) throws {
211+ try JSONEncoder ( ) . encode ( " timeout-test " ) . write ( to: testFileURL)
212+
213+ let fileCache = FileCache < String > ( fileURL: testFileURL)
214+ let expectation = expectation ( description: " All concurrent operations complete " )
215+ let iterations = 300
216+ let group = DispatchGroup ( )
217+
218+ for i in 0 ..< iterations {
219+ group. enter ( )
220+ DispatchQueue . global ( ) . async {
221+ defer { group. leave ( ) }
222+ do {
223+ switch i % 5 {
224+ case 0 : try fileCache. write ( " timeout- \( i) " )
225+ case 1 : _ = try fileCache. read ( )
226+ case 2 :
227+ try fileCache. clear ( )
228+ try fileCache. write ( " recovered- \( i) " )
229+ case 3 : fileCache. presentedItemDidChange ( )
230+ default : fileCache. accommodatePresentedItemDeletion { _ in }
231+ }
232+ } catch {
233+ // Errors from clear/read races are expected; only deadlocks matter here.
234+ }
235+ }
236+ }
237+
238+ group. notify ( queue: . main) {
239+ expectation. fulfill ( )
240+ }
241+
242+ wait ( for: [ expectation] , timeout: 30 )
243+ }
44244}
0 commit comments