Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/ConcurrencyTaskManager/SwiftUI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ public struct TaskManagerActorWrapper: Sendable {
}
}

public func cancelTask(key: TaskKey) {
Task {
await taskManager.cancel(key: key)
}
}

public func cancelAllTasks() {
Task {
await taskManager.cancelAll()
Expand Down
12 changes: 12 additions & 0 deletions Sources/ConcurrencyTaskManager/TaskManagerActor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,18 @@ public actor TaskManagerActor {
await closure(self)
}

/**
Cancels tasks for the specified key.
*/
public func cancel(key: TaskKey) async {
if let head = queues[key] {
for node in sequence(first: head, next: \.next) {
node.invalidate()
}
queues.removeValue(forKey: key)
}
}

/**
Cancells all tasks managed in this manager.
*/
Expand Down
97 changes: 97 additions & 0 deletions Tests/ConcurrencyTaskManagerTests/TaskManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,101 @@ final class TaskManagerTests: XCTestCase {

XCTAssertEqual(callCount, 2)
}

@MainActor
func test_cancel_specific_key() async {
let manager = TaskManagerActor()

let events: UnfairLockAtomic<[String]> = .init([])

// Start tasks with different keys
await manager.batch {
$0.task(key: .init("key1"), mode: .dropCurrent) {
await dummyTask("", nanoseconds: 1_000_000_000)
guard Task.isCancelled == false else { return }
events.modify { $0.append("key1") }
}

$0.task(key: .init("key2"), mode: .dropCurrent) {
await dummyTask("", nanoseconds: 1_000_000_000)
guard Task.isCancelled == false else { return }
events.modify { $0.append("key2") }
}

$0.task(key: .init("key3"), mode: .dropCurrent) {
await dummyTask("", nanoseconds: 1_000_000_000)
guard Task.isCancelled == false else { return }
events.modify { $0.append("key3") }
}
}

// Give tasks time to start
try? await Task.sleep(nanoseconds: 100_000_000)

// Cancel only key2
await manager.cancel(key: .init("key2"))

// Wait for remaining tasks to complete
try? await Task.sleep(nanoseconds: 2_000_000_000)

// key1 and key3 should complete, key2 should be cancelled
XCTAssertEqual(Set(events.value), Set(["key1", "key3"]))
}

@MainActor
func test_cancel_key_with_multiple_queued_tasks() async {
let manager = TaskManagerActor()

let events: UnfairLockAtomic<[String]> = .init([])

// Queue multiple tasks on the same key
await manager.batch {
$0.task(key: .init("queue"), mode: .waitInCurrent) {
await dummyTask("", nanoseconds: 500_000_000)
guard Task.isCancelled == false else { return }
events.modify { $0.append("task1") }
}

$0.task(key: .init("queue"), mode: .waitInCurrent) {
await dummyTask("", nanoseconds: 500_000_000)
guard Task.isCancelled == false else { return }
events.modify { $0.append("task2") }
}

$0.task(key: .init("queue"), mode: .waitInCurrent) {
await dummyTask("", nanoseconds: 500_000_000)
guard Task.isCancelled == false else { return }
events.modify { $0.append("task3") }
}
}

// Give first task time to start
try? await Task.sleep(nanoseconds: 100_000_000)

// Cancel all tasks for this key
await manager.cancel(key: .init("queue"))

// Wait to ensure no tasks complete
try? await Task.sleep(nanoseconds: 2_000_000_000)

// No tasks should have completed
XCTAssertEqual(events.value, [])
}

@MainActor
func test_cancel_nonexistent_key() async {
let manager = TaskManagerActor()

// This should not crash
await manager.cancel(key: .init("nonexistent"))

// Verify manager still works after cancelling nonexistent key
let expectation = XCTestExpectation(description: "Task completes")

await manager.task(key: .init("test"), mode: .dropCurrent) {
expectation.fulfill()
}

await fulfillment(of: [expectation], timeout: 1.0)
}
}