Skip to content

Commit 0601195

Browse files
dfedclaude
andauthored
Create CancellableQueue Tests (#64)
* Ignore agent configuration * Add CancellableQueueTests for 100% test coverage Test cancelTasks() behavior across all queue types: - FIFOQueue (with isolated and throwing variants) - ActorQueue - MainActor queue Each queue type tests four scenarios: - doesNotCancelCompletedTask - cancelsCurrentlyExecutingTask - cancelsCurrentlyExecutingAndPendingTasks - doesNotCancelFutureTasks * Use semaphores instead of sleep for test synchronization Replace brittle Task.sleep timing with proper semaphore-based synchronization to ensure tasks have started before cancellation. * Simplify tests by returning Task.isCancelled directly Remove Expectation usage in favor of checking task.value directly. This eliminates timeouts and makes tests cleaner. * Use task.isCancelled property instead of returning from closure Check cancellation status directly on the Task instance rather than returning Task.isCancelled from within the closure. * Remove unnecessary task awaits Check task.isCancelled immediately after cancelTasks() without waiting for task completion. Only await task.result where needed to ensure task completes before calling cancelTasks(). * Fix some of the 'didn't wait long enough' problem * Add taskAllowedToEnd semaphore to ActorQueue and MainActor tests Ensure tasks are still executing when cancelTasks() is called by having them wait on a semaphore that is signaled after cancellation. * Fix misleading comments about ActorQueue task ordering ActorQueues are re-entrant, not FIFO, so tasks don't wait for earlier tasks to complete. * Fix flaky ActorQueue and MainActor cancellation tests ActorQueues are re-entrant, so task2 and task3 would complete immediately when task1 suspends, potentially before cancelTasks() is called. Now all three tasks signal when started and wait on semaphores, ensuring they're all still executing when cancelled. * Rename ActorQueue/MainActor tests to reflect concurrent execution ActorQueues are re-entrant, so tasks execute concurrently rather than pending. Renamed tests from 'cancelsCurrentlyExecutingAndPendingTasks' to 'cancelsAllConcurrentlyExecutingTasks' to accurately describe behavior. * Revert "Rename ActorQueue/MainActor tests to reflect concurrent execution" This reverts commit 88f7fd0. * mark * formatting * Simplify ActorQueue and MainActor multi-task cancellation tests We only need to wait for task1 to start. Whether task2 and task3 are pending or already executing when cancelTasks() is called, they will be cancelled either way. * Ensure task2 and task3 wait before cancelTasks() is called Tasks need to wait on the semaphore to ensure they haven't completed and been removed from the cancel map before cancelTasks() is called. * Ensure task3 is pending in ActorQueue/MainActor cancellation tests Task2 now spins with try Task.checkCancellation() without suspension points, preventing task3 from starting on the re-entrant queue. This guarantees task3 is truly pending when cancelTasks() is called. --------- Co-authored-by: Claude <[email protected]>
1 parent 3ec737f commit 0601195

File tree

2 files changed

+327
-0
lines changed

2 files changed

+327
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ xcuserdata/
33
.swiftpm
44
.build/
55
generated/
6+
.claude/
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2025 Dan Federman
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
import Testing
24+
25+
@testable import AsyncQueue
26+
27+
struct CancellableQueueTests {
28+
// MARK: Behavior Tests
29+
30+
@Test
31+
func cancelTasks_fifoQueue_doesNotCancelCompletedTask() async {
32+
let systemUnderTest = CancellableQueue(underlyingQueue: FIFOQueue())
33+
34+
// Create a task that completes immediately.
35+
let task = Task(on: systemUnderTest) {
36+
try doWork()
37+
}
38+
39+
// Wait for the task to complete.
40+
_ = await task.result
41+
42+
// Now cancel tasks - should have no effect since task already completed.
43+
systemUnderTest.cancelTasks()
44+
45+
#expect(!task.isCancelled)
46+
}
47+
48+
@Test
49+
func cancelTasks_fifoQueue_cancelsCurrentlyExecutingTask() async {
50+
let systemUnderTest = CancellableQueue(underlyingQueue: FIFOQueue())
51+
let taskStarted = Semaphore()
52+
let taskAllowedToEnd = Semaphore()
53+
54+
// Create a task that signals when it starts, then waits.
55+
let task = Task(on: systemUnderTest) {
56+
await taskStarted.signal()
57+
await taskAllowedToEnd.wait()
58+
}
59+
60+
// Wait for the task to start executing.
61+
await taskStarted.wait()
62+
63+
// Cancel all tasks.
64+
systemUnderTest.cancelTasks()
65+
66+
// Allow the task to end now that we've cancelled it.
67+
await taskAllowedToEnd.signal()
68+
69+
#expect(task.isCancelled)
70+
}
71+
72+
@Test
73+
func cancelTasks_fifoQueue_cancelsCurrentlyExecutingAndPendingTasks() async {
74+
let systemUnderTest = CancellableQueue(underlyingQueue: FIFOQueue())
75+
let taskStarted = Semaphore()
76+
let taskAllowedToEnd = Semaphore()
77+
let counter = Counter()
78+
79+
// Create a task that signals when it starts.
80+
let task1 = Task(on: systemUnderTest, isolatedTo: counter) { _ in
81+
await taskStarted.signal()
82+
await taskAllowedToEnd.wait()
83+
}
84+
85+
// Create pending tasks that won't start until the first task completes.
86+
let task2 = Task(on: systemUnderTest, isolatedTo: counter) { _ in }
87+
88+
let task3 = Task(on: systemUnderTest, isolatedTo: counter) { _ in }
89+
90+
// Wait for the first task to start executing.
91+
await taskStarted.wait()
92+
93+
// Cancel all tasks.
94+
systemUnderTest.cancelTasks()
95+
96+
// Allow the task to end now that we've cancelled it.
97+
await taskAllowedToEnd.signal()
98+
99+
#expect(task1.isCancelled)
100+
#expect(task2.isCancelled)
101+
#expect(task3.isCancelled)
102+
}
103+
104+
@Test
105+
func cancelTasks_fifoQueue_doesNotCancelFutureTasks() {
106+
let systemUnderTest = CancellableQueue(underlyingQueue: FIFOQueue())
107+
let counter = Counter()
108+
109+
// Cancel tasks before creating any.
110+
systemUnderTest.cancelTasks()
111+
112+
// Create a task after cancellation - it should NOT be cancelled.
113+
let task = Task(on: systemUnderTest, isolatedTo: counter) { _ in
114+
try doWork()
115+
}
116+
117+
#expect(!task.isCancelled)
118+
}
119+
120+
@Test
121+
func cancelTasks_actorQueue_doesNotCancelCompletedTask() async {
122+
let actorQueue = ActorQueue<Counter>()
123+
let counter = Counter()
124+
actorQueue.adoptExecutionContext(of: counter)
125+
let systemUnderTest = CancellableQueue(underlyingQueue: actorQueue)
126+
127+
// Create a task that completes immediately.
128+
let task = Task(on: systemUnderTest) { _ in
129+
try doWork()
130+
}
131+
132+
// Wait for the task to complete.
133+
_ = await task.result
134+
135+
// Now cancel tasks - should have no effect since task already completed.
136+
systemUnderTest.cancelTasks()
137+
138+
#expect(!task.isCancelled)
139+
}
140+
141+
@Test
142+
func cancelTasks_actorQueue_cancelsCurrentlyExecutingTask() async {
143+
let actorQueue = ActorQueue<Counter>()
144+
let counter = Counter()
145+
actorQueue.adoptExecutionContext(of: counter)
146+
let systemUnderTest = CancellableQueue(underlyingQueue: actorQueue)
147+
let taskStarted = Semaphore()
148+
let taskAllowedToEnd = Semaphore()
149+
150+
// Create a task that signals when it starts, then waits.
151+
let task = Task(on: systemUnderTest) { _ in
152+
await taskStarted.signal()
153+
await taskAllowedToEnd.wait()
154+
}
155+
156+
// Wait for the task to start executing.
157+
await taskStarted.wait()
158+
159+
// Cancel all tasks.
160+
systemUnderTest.cancelTasks()
161+
162+
// Allow the task to end now that we've cancelled it.
163+
await taskAllowedToEnd.signal()
164+
165+
#expect(task.isCancelled)
166+
}
167+
168+
@Test
169+
func cancelTasks_actorQueue_cancelsCurrentlyExecutingAndPendingTasks() async {
170+
let actorQueue = ActorQueue<Counter>()
171+
let counter = Counter()
172+
actorQueue.adoptExecutionContext(of: counter)
173+
let systemUnderTest = CancellableQueue(underlyingQueue: actorQueue)
174+
let taskStarted = Semaphore()
175+
let taskAllowedToEnd = Semaphore()
176+
177+
// Create a task that signals when it starts, then waits.
178+
let task1 = Task(on: systemUnderTest) { _ in
179+
await taskStarted.signal()
180+
await taskAllowedToEnd.wait()
181+
}
182+
183+
// Create a task that spins until cancelled, ensuring task3 remains pending.
184+
let task2 = Task(on: systemUnderTest) { _ in
185+
while true {
186+
try Task.checkCancellation()
187+
}
188+
}
189+
190+
// Create a pending task.
191+
let task3 = Task(on: systemUnderTest) { _ in
192+
await taskAllowedToEnd.wait()
193+
}
194+
195+
// Wait for the first task to start executing.
196+
await taskStarted.wait()
197+
198+
// Cancel all tasks.
199+
systemUnderTest.cancelTasks()
200+
201+
// Allow tasks to end now that we've cancelled them.
202+
await taskAllowedToEnd.signal()
203+
204+
#expect(task1.isCancelled)
205+
#expect(task2.isCancelled)
206+
#expect(task3.isCancelled)
207+
}
208+
209+
@Test
210+
func cancelTasks_actorQueue_doesNotCancelFutureTasks() {
211+
let actorQueue = ActorQueue<Counter>()
212+
let counter = Counter()
213+
actorQueue.adoptExecutionContext(of: counter)
214+
let systemUnderTest = CancellableQueue(underlyingQueue: actorQueue)
215+
216+
// Cancel tasks before creating any.
217+
systemUnderTest.cancelTasks()
218+
219+
// Create a task after cancellation - it should NOT be cancelled.
220+
let task = Task(on: systemUnderTest) { _ in
221+
try doWork()
222+
}
223+
224+
#expect(!task.isCancelled)
225+
}
226+
227+
@Test
228+
func cancelTasks_mainActorQueue_doesNotCancelCompletedTask() async {
229+
let systemUnderTest = CancellableQueue(underlyingQueue: MainActor.queue)
230+
231+
// Create a task that completes immediately.
232+
let task = Task(on: systemUnderTest) {
233+
try doWork()
234+
}
235+
236+
// Wait for the task to complete.
237+
_ = await task.result
238+
239+
// Now cancel tasks - should have no effect since task already completed.
240+
systemUnderTest.cancelTasks()
241+
242+
#expect(!task.isCancelled)
243+
}
244+
245+
@Test
246+
func cancelTasks_mainActorQueue_cancelsCurrentlyExecutingTask() async {
247+
let systemUnderTest = CancellableQueue(underlyingQueue: MainActor.queue)
248+
let taskStarted = Semaphore()
249+
let taskAllowedToEnd = Semaphore()
250+
251+
// Create a task that signals when it starts, then waits.
252+
let task = Task(on: systemUnderTest) {
253+
await taskStarted.signal()
254+
await taskAllowedToEnd.wait()
255+
}
256+
257+
// Wait for the task to start executing.
258+
await taskStarted.wait()
259+
260+
// Cancel all tasks
261+
systemUnderTest.cancelTasks()
262+
263+
// Allow the task to end now that we've cancelled it.
264+
await taskAllowedToEnd.signal()
265+
266+
#expect(task.isCancelled)
267+
}
268+
269+
@Test
270+
func cancelTasks_mainActorQueue_cancelsCurrentlyExecutingAndPendingTasks() async {
271+
let systemUnderTest = CancellableQueue(underlyingQueue: MainActor.queue)
272+
let taskStarted = Semaphore()
273+
let taskAllowedToEnd = Semaphore()
274+
275+
// Create a task that signals when it starts, then waits.
276+
let task1 = Task(on: systemUnderTest) {
277+
await taskStarted.signal()
278+
await taskAllowedToEnd.wait()
279+
}
280+
281+
// Create a task that spins until cancelled, ensuring task3 remains pending.
282+
let task2 = Task(on: systemUnderTest) {
283+
while true {
284+
try Task.checkCancellation()
285+
}
286+
}
287+
288+
// Create a pending task.
289+
let task3 = Task(on: systemUnderTest) {
290+
await taskAllowedToEnd.wait()
291+
}
292+
293+
// Wait for the first task to start executing.
294+
await taskStarted.wait()
295+
296+
// Cancel all tasks.
297+
systemUnderTest.cancelTasks()
298+
299+
// Allow tasks to end now that we've cancelled them.
300+
await taskAllowedToEnd.signal()
301+
302+
#expect(task1.isCancelled)
303+
#expect(task2.isCancelled)
304+
#expect(task3.isCancelled)
305+
}
306+
307+
@Test
308+
func cancelTasks_mainActorQueue_doesNotCancelFutureTasks() {
309+
let systemUnderTest = CancellableQueue(underlyingQueue: MainActor.queue)
310+
311+
// Cancel tasks before creating any.
312+
systemUnderTest.cancelTasks()
313+
314+
// Create a task after cancellation - it should NOT be cancelled.
315+
let task = Task(on: systemUnderTest) {
316+
try doWork()
317+
}
318+
319+
#expect(!task.isCancelled)
320+
}
321+
322+
// MARK: Private
323+
324+
@Sendable
325+
private func doWork() throws {}
326+
}

0 commit comments

Comments
 (0)