Skip to content

Commit 73f25c3

Browse files
committed
Add lots of concurrency tests for FileCache
1 parent 79eec0d commit 73f25c3

File tree

1 file changed

+200
-0
lines changed

1 file changed

+200
-0
lines changed

ios/MullvadVPNTests/MullvadTypes/FileCacheTests.swift

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)