Skip to content

Commit b5f34e8

Browse files
feat: Port tests from libDispatch to DispatchAsync. Add additional tests for functionality. (#2)
* test: Add tests for AsyncSemaphore and DispatchTimeInterval. [Human-Directed AI Assistance] * test: Add ping pong test adapted re-written in Swift from libDispatch/tests/dispatch_pingpong.c. [Human-Directed AI Assistance] * test: Add port of libdispatch/tests/dispatch_group.c test * test: Fix a few different issues that caused some tests to be flaky. And other minor improvements to tests. * docs: Make note of some debug printouts. * fix: ACL for DispatchGroup.wait should have been public. * chore: Update availability and other compilation issues after latest rebase. * refactor: Consolidate semaphore tests to single file. * chore: Run swift-format. * docs: Add link to original port of test. * fix: Fix compilation error for consolidated AsyncSemaphoreTests. * chore: Peer review suggested changes to clean up typos and naming issues.
1 parent 41f71ae commit b5f34e8

File tree

7 files changed

+395
-154
lines changed

7 files changed

+395
-154
lines changed

Sources/DispatchAsync/DispatchGroup.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ extension DispatchAsync {
6969
}
7070
}
7171

72-
func wait() async {
72+
public func wait() async {
7373
await withCheckedContinuation { continuation in
7474
queue.enqueue { [weak self] in
7575
guard let self else { return }
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2025 PassiveLogic, Inc.
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Testing
16+
17+
@testable import DispatchAsync
18+
19+
nonisolated(unsafe) private var sharedPoolCompletionCount = 0
20+
21+
@Suite("AsyncSemaphore Tests")
22+
class AsyncSemaphoreTests {
23+
@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
24+
@Test(.timeLimit(.minutes(1)))
25+
func asyncSemaphoreWaitSignal() async throws {
26+
let semaphore = AsyncSemaphore(value: 1)
27+
28+
// First wait should succeed immediately and bring the count to 0
29+
await semaphore.wait()
30+
31+
// Launch a task that tries to wait – it should be suspended until we signal
32+
nonisolated(unsafe) var didEnterCriticalSection = false
33+
await withCheckedContinuation { continuation in
34+
Task { @Sendable in
35+
// Ensure the rest of this test doesn't
36+
// proceed until the Task block has started executing
37+
continuation.resume()
38+
39+
await semaphore.wait()
40+
didEnterCriticalSection = true
41+
await semaphore.signal()
42+
}
43+
}
44+
45+
// Allow the task a few cycles to reach the initial semaphore.wait()
46+
try? await Task.sleep(nanoseconds: 1_000)
47+
48+
#expect(!didEnterCriticalSection) // should still be waiting
49+
50+
// Now release the semaphore – the waiter should proceed
51+
await semaphore.signal()
52+
53+
// Wait for second signal to fire from inside the task above
54+
// There is a timeout on this test, so if there is a problem
55+
// we'll either hit the timeout and fail, or didEnterCriticalSection
56+
// will be false below
57+
await semaphore.wait()
58+
59+
#expect(didEnterCriticalSection) // waiter must have run
60+
}
61+
62+
@Test func basicAsyncSemaphoreTest() async throws {
63+
sharedPoolCompletionCount = 0 // Reset to 0 for each test run
64+
let totalConcurrentPools = 10
65+
66+
let semaphore = AsyncSemaphore(value: 1)
67+
68+
await withTaskGroup(of: Void.self) { group in
69+
for _ in 0 ..< totalConcurrentPools {
70+
group.addTask {
71+
// Wait for any other pools currently holding the semaphore
72+
await semaphore.wait()
73+
74+
// Only one task should mutate counter at a time
75+
//
76+
// If there are issues with the semaphore, then
77+
// we would expect to grab incorrect values here occasionally,
78+
// which would result in an incorrect final completion count.
79+
//
80+
let existingPoolCompletionCount = sharedPoolCompletionCount
81+
82+
// Add artificial delay to amplify race conditions
83+
// Pools started shortly after this "semaphore-locked"
84+
// pool starts will run before this line, unless
85+
// this pool contains a valid lock.
86+
try? await Task.sleep(nanoseconds: 100)
87+
88+
sharedPoolCompletionCount = existingPoolCompletionCount + 1
89+
90+
// When we exit this flow, release our hold on the semaphore
91+
await semaphore.signal()
92+
}
93+
}
94+
}
95+
96+
// After all tasks are done, counter should be 10
97+
#expect(sharedPoolCompletionCount == totalConcurrentPools)
98+
}
99+
}

Tests/DispatchAsyncTests/DispatchGroupTests.swift

Lines changed: 168 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -15,109 +15,188 @@
1515
@_spi(DispatchAsync) import DispatchAsync
1616
import Testing
1717

18+
import func Foundation.sin
19+
20+
#if !os(WASI)
21+
import class Foundation.Thread
22+
#endif
23+
1824
private typealias DispatchGroup = DispatchAsync.DispatchGroup
25+
private typealias DispatchQueue = DispatchAsync.DispatchQueue
1926

20-
@Test(arguments: [100])
21-
func dispatchGroupOrderCleanliness(repetitions: Int) async throws {
22-
// Repeating this `repetitions` number of times to help rule out
23-
// edge cases that only show up some of the time
24-
for index in 0 ..< repetitions {
25-
Task {
26-
actor Result {
27-
private(set) var value = ""
28-
29-
func append(value: String) {
30-
self.value.append(value)
27+
@Suite("DispatchGroup Tests")
28+
struct DispatchGroupTests {
29+
@Test(arguments: [1000])
30+
@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
31+
func dispatchGroupOrderCleanliness(repetitions: Int) async throws {
32+
// Repeating this `repetitions` number of times to help rule out
33+
// edge cases that only show up some of the time
34+
for index in 0 ..< repetitions {
35+
Task {
36+
actor Result {
37+
private(set) var value = ""
38+
39+
func append(value: String) {
40+
self.value.append(value)
41+
}
3142
}
32-
}
3343

34-
let result = Result()
44+
let result = Result()
3545

36-
let group = DispatchGroup()
37-
await result.append(value: "|🔵\(index)")
46+
let group = DispatchGroup()
47+
await result.append(value: "|🔵\(iteration)")
3848

39-
group.enter()
40-
Task {
41-
await result.append(value: "🟣/")
42-
group.leave()
43-
}
49+
group.enter()
50+
Task {
51+
await result.append(value: "🟣/")
52+
group.leave()
53+
}
4454

45-
group.enter()
46-
Task {
47-
await result.append(value: "🟣^")
48-
group.leave()
49-
}
55+
group.enter()
56+
Task {
57+
await result.append(value: "🟣^")
58+
group.leave()
59+
}
5060

51-
group.enter()
52-
Task {
53-
await result.append(value: "🟣\\")
54-
group.leave()
61+
group.enter()
62+
Task {
63+
await result.append(value: "🟣\\")
64+
group.leave()
65+
}
66+
67+
await withCheckedContinuation { continuation in
68+
group.notify(queue: .main) {
69+
Task {
70+
await result.append(value: "🟢\(iteration)=")
71+
continuation.resume()
72+
}
73+
}
74+
}
75+
76+
let finalValue = await result.value
77+
78+
/// NOTE: If you need to visually debug issues, you can uncomment
79+
/// the following to watch a visual representation of the group ordering.
80+
///
81+
/// In general, you'll see something like the following printed over and over
82+
/// to the console:
83+
///
84+
/// ```
85+
/// |🔵42🟣/🟣^🟣\🟢42=
86+
/// ```
87+
///
88+
/// What you should observe:
89+
///
90+
/// - The index number be the same at the beginning and end of each line, and it
91+
/// should always increment by one.
92+
/// - The 🔵 should always be first, and the 🟢 should always be last for each line.
93+
/// - There should always be 3 🟣's in between the 🔵 and 🟢.
94+
/// - The ordering of the 🟣 can be random, and that is fine.
95+
///
96+
/// For example, for of the following are valid outputs:
97+
///
98+
/// ```
99+
/// // GOOD
100+
/// |🔵42🟣/🟣^🟣\🟢42=
101+
/// ```
102+
///
103+
/// ```
104+
/// // GOOD
105+
/// |🔵42🟣/🟣\🟣^🟢42=
106+
/// ```
107+
///
108+
/// But the following would not be valid:
109+
///
110+
/// ```
111+
/// // BAD! (43 comes before 42)
112+
/// |🔵43🟣/🟣^🟣\🟢43=
113+
/// |🔵42🟣/🟣^🟣\🟢42=
114+
/// |🔵44🟣/🟣^🟣\🟢44=
115+
/// ```
116+
///
117+
/// ```
118+
/// // BAD! (green globe comes before a purle one)
119+
/// |🔵42🟣/🟣^🟢42🟣\=
120+
/// ```
121+
///
122+
123+
// NOTE: Uncomment to use troubleshooting method above:
124+
// print(finalValue)
125+
126+
#expect(finalValue.prefix(1) == "|")
127+
#expect(finalValue.count { $0 == "🟣" } == 3)
128+
#expect(finalValue.count { $0 == "🟢" } == 1)
129+
#expect(finalValue.lastIndex(of: "🟣")! < finalValue.firstIndex(of: "🟢")!)
130+
#expect(finalValue.suffix(1) == "=")
55131
}
132+
}
133+
}
134+
135+
/// Swift port of libdispatch/tests/dispatch_group.c
136+
///
137+
/// See https://github.com/swiftlang/swift-corelibs-libdispatch/blob/686475721aca13d98d2eab3a0c439403d33b6e2d/tests/dispatch_group.c
138+
///
139+
/// The original C test stresses `dispatch_group_wait` by enqueuing a bunch of
140+
/// math-heavy blocks on a global queue, then waiting for them to finish with a
141+
/// timeout. It also verifies that `notify` is invoked exactly once.
142+
@Test(.timeLimit(.minutes(1)))
143+
@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
144+
func dispatchGroupStress() async throws {
145+
let iterations = 1000
146+
// We use a separate concurrent queue rather than the global queue to avoid interference issues
147+
// with other tests running in parallel
148+
let workQueue = DispatchQueue(attributes: .concurrent)
149+
let group = DispatchGroup()
56150

57-
await withCheckedContinuation { continuation in
58-
group.notify(queue: .main) {
59-
Task {
60-
await result.append(value: "🟢\(index)=")
61-
continuation.resume()
151+
let isolationQueue = DispatchQueue(label: "isolationQueue")
152+
nonisolated(unsafe) var counter = 0
153+
154+
for _ in 0 ..< iterations {
155+
group.enter()
156+
workQueue.async {
157+
// We alternate between two options for workload. One is a simple
158+
// math function, the other is a thread sleep.
159+
//
160+
// Alternating between those two approaches provides variance to
161+
// increases failure chances if there are race conditions subject to timing
162+
// and load.
163+
if Bool.random() {
164+
#if !os(WASI)
165+
Thread.sleep(forTimeInterval: 0.00001) // 10_000 nanoseconds
166+
#endif
167+
} else {
168+
// A small math workload similar to the original C test which used
169+
// sin(random()). We iterate a couple thousand times to keep the CPU
170+
// busy long enough for the group scheduling to matter.
171+
var x = Double.random(in: 0.0 ... Double.pi)
172+
for _ in 0 ..< 2_000 {
173+
x = sin(x)
62174
}
63175
}
176+
177+
isolationQueue.async {
178+
counter += 1
179+
group.leave()
180+
}
64181
}
182+
}
183+
184+
// NOTE: The test has a 1 minute time limit that will time out. In
185+
// the original code, this timeout was 5 seconds, but currently
186+
// the shortest timeout Swift Testing provides is 1 minute.
187+
await group.wait()
65188

66-
let finalValue = await result.value
67-
68-
/// NOTE: If you need to visually debug issues, you can uncomment
69-
/// the following to watch a visual representation of the group ordering.
70-
///
71-
/// In general, you'll see something like the following printed over and over
72-
/// to the console:
73-
///
74-
/// ```
75-
/// |🔵42🟣/🟣^🟣\🟢42=
76-
/// ```
77-
///
78-
/// What you should observe:
79-
///
80-
/// - The index number be the same at the beginning and end of each line, and it
81-
/// should always increment by one.
82-
/// - The 🔵 should always be first, and the 🟢 should always be last for each line.
83-
/// - There should always be 3 🟣's in between the 🔵 and 🟢.
84-
/// - The ordering of the 🟣 can be random, and that is fine.
85-
///
86-
/// For example, for of the following are valid outputs:
87-
///
88-
/// ```
89-
/// // GOOD
90-
/// |🔵42🟣/🟣^🟣\🟢42=
91-
/// ```
92-
///
93-
/// ```
94-
/// // GOOD
95-
/// |🔵42🟣/🟣\🟣^🟢42=
96-
/// ```
97-
///
98-
/// But the following would not be valid:
99-
///
100-
/// ```
101-
/// // BAD!
102-
/// |🔵43🟣/🟣^🟣\🟢43=
103-
/// |🔵42🟣/🟣^🟣\🟢42=
104-
/// |🔵44🟣/🟣^🟣\🟢44=
105-
/// ```
106-
///
107-
/// ```
108-
/// // BAD!
109-
/// |🔵42🟣/🟣^🟢42🟣\=
110-
/// ```
111-
///
112-
113-
// Uncomment to use troubleshooting method above:
114-
// print(finalValue)
115-
116-
#expect(finalValue.prefix(1) == "|")
117-
#expect(finalValue.count { $0 == "🟣" } == 3)
118-
#expect(finalValue.count { $0 == "🟢" } == 1)
119-
#expect(finalValue.lastIndex(of: "🟣")! < finalValue.firstIndex(of: "🟢")!)
120-
#expect(finalValue.suffix(1) == "=")
189+
// Verify notify fires exactly once.
190+
nonisolated(unsafe) var notifyHits = 0
191+
await withCheckedContinuation { k in
192+
group.notify(queue: .main) {
193+
notifyHits += 1
194+
k.resume()
195+
}
121196
}
197+
#expect(notifyHits == 1)
198+
199+
let finalCount = counter
200+
#expect(finalCount == iterations)
122201
}
123202
}

0 commit comments

Comments
 (0)