From f2fb96bc3fd8b1a8c03d1317b0cdf856f7ff7d36 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Thu, 3 Jul 2025 13:14:34 -0600 Subject: [PATCH 01/12] test: Add tests for AsyncSemaphore and DispatchTimeInterval. [Human-Directed AI Assistance] --- .../AsyncSemaphoreTests.swift | 34 +++++++++++++++++++ .../DispatchTimeIntervalTests.swift | 23 +++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift create mode 100644 Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift diff --git a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift new file mode 100644 index 0000000..41a2839 --- /dev/null +++ b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift @@ -0,0 +1,34 @@ +import Testing + +@testable import DispatchAsync + +@Test +func asyncSemaphoreWaitSignal() async throws { + let semaphore = AsyncSemaphore(value: 1) + + // First wait should succeed immediately and bring the count to 0 + await semaphore.wait() + + // Launch a task that tries to wait – it should be suspended until we signal + var didEnterCriticalSection = false + let waiter = Task { + await semaphore.wait() + didEnterCriticalSection = true + await semaphore.signal() + } + + // Allow the task a brief moment to start and (hopefully) suspend + try? await Task.sleep(nanoseconds: 1_000) + + #expect(!didEnterCriticalSection) // should still be waiting + + // Now release the semaphore – the waiter should proceed + await semaphore.signal() + + // Give the waiter a chance to run + try? await Task.sleep(nanoseconds: 1_000) + + #expect(didEnterCriticalSection) // waiter must have run + + _ = await waiter.value +} diff --git a/Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift b/Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift new file mode 100644 index 0000000..4e2fe81 --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift @@ -0,0 +1,23 @@ +import Testing + +@testable import DispatchAsync + +@Test +func dispatchTimeIntervalEquality() throws { + // 1 second == 1_000 milliseconds + #expect(DispatchTimeInterval.seconds(1) == .milliseconds(1_000)) + + // 1 second != 2 seconds + #expect(DispatchTimeInterval.seconds(1) != .seconds(2)) + + // 2_000 micro-seconds == 1 milliseconds + #expect(DispatchTimeInterval.microseconds(2_000) == .milliseconds(2)) + + // 1 micro-seconds == 1_000 nanoseconds + #expect(DispatchTimeInterval.microseconds(1) == .nanoseconds(1_000)) + + // `.never` is only equal to `.never` + #expect(DispatchTimeInterval.never == .never) + #expect(DispatchTimeInterval.never != .seconds(0)) + #expect(DispatchTimeInterval.never != .seconds(Int.max)) +} From 1d590f29574543238a81f2b985df2fbc9c4649d8 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Thu, 3 Jul 2025 14:44:49 -0600 Subject: [PATCH 02/12] test: Add ping pong test adapted re-written in Swift from libDispatch/tests/dispatch_pingpong.c. [Human-Directed AI Assistance] --- .../DispatchPingPongTests.swift | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Tests/DispatchAsyncTests/DispatchPingPongTests.swift diff --git a/Tests/DispatchAsyncTests/DispatchPingPongTests.swift b/Tests/DispatchAsyncTests/DispatchPingPongTests.swift new file mode 100644 index 0000000..e670ab8 --- /dev/null +++ b/Tests/DispatchAsyncTests/DispatchPingPongTests.swift @@ -0,0 +1,60 @@ +import Testing + +@testable import DispatchAsync + +/// Ping-Pong queue test is adapted from the test +/// [dispatch_pingpong.c in libdispatch](https://github.com/swiftlang/swift-corelibs-libdispatch/blob/main/tests/dispatch_pingpong.c). +/// +/// Two queues recursively schedule work on each other N times. The test +/// succeeds when the hand-off count matches expectations and no deadlock +/// occurs. +@Test +func dispatchPingPongQueues() async throws { + // NOTE: Original test uses 10_000_000, but that makes for a rather slow + // unit test. Using 100_000 here as a "close-enough" tradeoff. + let totalIterations = 100_000 // Total number of hand-offs between ping and pong functions. + + let queuePing = DispatchQueue(label: "ping") + let queuePong = DispatchQueue(label: "pong") + + // NOTE: We intentionally use a nonisolated + // variable here rather than an actor-protected or + // semaphore-protected variable to force reliance on + // the separate and serial queues waiting to execute + // until another value is enqueued. + // + // This matches the implementation of the dispatch_pinpong.c test. + nonisolated(unsafe) var counter = 0 + + // Ping + @Sendable + func schedulePing(_ iteration: Int, _ continuation: CheckedContinuation) { + queuePing.async { + counter += 1 + if iteration < totalIterations { + schedulePong(iteration + 1, continuation) + } else { + continuation.resume() + } + } + } + + // Pong + @Sendable + func schedulePong(_ iteration: Int, _ continuation: CheckedContinuation) { + queuePong.async { + counter += 1 + schedulePing(iteration, continuation) + } + } + + await withCheckedContinuation { continuation in + // Start the chain. Chain will resume continuation when totalIterations + // have been reached. + schedulePing(0, continuation) + } + + let finalCount = counter + // Each iteration performs two increments (ping + pong) + #expect(finalCount == totalIterations * 2 + 1) // + 1 is for the final ping increment on the final iteration where i==totalIterations +} From a691ca8807b9de54144101bf1be1d04fa29d6f7e Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 8 Jul 2025 13:15:10 -0600 Subject: [PATCH 03/12] test: Add port of libdispatch/tests/dispatch_group.c test # Conflicts: # Tests/DispatchAsyncTests/DispatchGroupTests.swift # Conflicts: # Tests/DispatchAsyncTests/DispatchGroupTests.swift --- .../DispatchGroupTests.swift | 251 +++++++++++------- 1 file changed, 162 insertions(+), 89 deletions(-) diff --git a/Tests/DispatchAsyncTests/DispatchGroupTests.swift b/Tests/DispatchAsyncTests/DispatchGroupTests.swift index ec5109d..ac7c3ff 100644 --- a/Tests/DispatchAsyncTests/DispatchGroupTests.swift +++ b/Tests/DispatchAsyncTests/DispatchGroupTests.swift @@ -13,111 +13,184 @@ //===----------------------------------------------------------------------===// @_spi(DispatchAsync) import DispatchAsync +import func Foundation.sin +#if !os(WASI) +import class Foundation.Thread +#endif import Testing private typealias DispatchGroup = DispatchAsync.DispatchGroup -@Test(arguments: [100]) -func dispatchGroupOrderCleanliness(repetitions: Int) async throws { - // Repeating this `repetitions` number of times to help rule out - // edge cases that only show up some of the time - for index in 0 ..< repetitions { - Task { - actor Result { - private(set) var value = "" - - func append(value: String) { - self.value.append(value) +@Suite("DispatchGroup Tests") +struct DispatchGroupTests { + @Test(arguments: [1000]) + func dispatchGroupOrderCleanliness(repetitions: Int) async throws { + // Repeating this `repetitions` number of times to help rule out + // edge cases that only show up some of the time + for index in 0 ..< repetitions { + Task { + actor Result { + private(set) var value = "" + + func append(value: String) { + self.value.append(value) + } } - } - let result = Result() + let result = Result() - let group = DispatchGroup() - await result.append(value: "|🔵\(index)") + let group = DispatchGroup() + await result.append(value: "|🔵\(iteration)") - group.enter() - Task { - await result.append(value: "🟣/") - group.leave() - } + group.enter() + Task { + await result.append(value: "🟣/") + group.leave() + } - group.enter() - Task { - await result.append(value: "🟣^") - group.leave() - } + group.enter() + Task { + await result.append(value: "🟣^") + group.leave() + } - group.enter() - Task { - await result.append(value: "🟣\\") - group.leave() + group.enter() + Task { + await result.append(value: "🟣\\") + group.leave() + } + + await withCheckedContinuation { continuation in + group.notify(queue: .main) { + Task { + await result.append(value: "🟢\(iteration)=") + continuation.resume() + } + } + } + + let finalValue = await result.value + + /// NOTE: If you need to visually debug issues, you can uncomment + /// the following to watch a visual representation of the group ordering. + /// + /// In general, you'll see something like the following printed over and over + /// to the console: + /// + /// ``` + /// |🔵42🟣/🟣^🟣\🟢42= + /// ``` + /// + /// What you should observe: + /// + /// - The index number be the same at the beginning and end of each line, and it + /// should always increment by one. + /// - The 🔵 should always be first, and the 🟢 should always be last for each line. + /// - There should always be 3 🟣's in between the 🔵 and 🟢. + /// - The ordering of the 🟣 can be random, and that is fine. + /// + /// For example, for of the following are valid outputs: + /// + /// ``` + /// // GOOD + /// |🔵42🟣/🟣^🟣\🟢42= + /// ``` + /// + /// ``` + /// // GOOD + /// |🔵42🟣/🟣\🟣^🟢42= + /// ``` + /// + /// But the following would not be valid: + /// + /// ``` + /// // BAD! + /// |🔵43🟣/🟣^🟣\🟢43= + /// |🔵42🟣/🟣^🟣\🟢42= + /// |🔵44🟣/🟣^🟣\🟢44= + /// ``` + /// + /// ``` + /// // BAD! + /// |🔵42🟣/🟣^🟢42🟣\= + /// ``` + /// + + // NOTE: Uncomment to use troubleshooting method above: + // print(finalValue) + + #expect(finalValue.prefix(1) == "|") + #expect(finalValue.count { $0 == "🟣" } == 3) + #expect(finalValue.count { $0 == "🟢" } == 1) + #expect(finalValue.lastIndex(of: "🟣")! < finalValue.firstIndex(of: "🟢")!) + #expect(finalValue.suffix(1) == "=") } + } + } + + /// Swift port of libdispatch/tests/dispatch_group.c + /// The original C test stresses `dispatch_group_wait` by enqueuing a bunch of + /// math-heavy blocks on a global queue, then waiting for them to finish with a + /// timeout. It also verifies that `notify` is invoked exactly once. + @Test(.timeLimit(.minutes(1))) + func dispatchGroupStress() async throws { + let iterations = 1000 + // We use a separate concurrent queue rather than the global queue to avoid interference issues + // with other tests running in parallel + let workQueue = DispatchQueue(attributes: .concurrent) + let group = DispatchGroup() - await withCheckedContinuation { continuation in - group.notify(queue: .main) { - Task { - await result.append(value: "🟢\(index)=") - continuation.resume() + let isolationQueue = DispatchQueue(label: "isolationQueue") + nonisolated(unsafe) var counter = 0 + + for _ in 0 ..< iterations { + group.enter() + workQueue.async { + // We alternate between two options for workload. One is a simple + // math function, the other is a thread sleep. + // + // Alternating between those two approaches provides variance to + // increases failure chances if there are race conditions subject to timing + // and load. + if Bool.random() { + #if !os(WASI) + Thread.sleep(forTimeInterval: 0.00001) // 10_000 nanoseconds + #endif + } else { + // A small math workload similar to the original C test which used + // sin(random()). We iterate a couple thousand times to keep the CPU + // busy long enough for the group scheduling to matter. + var x = Double.random(in: 0.0 ... Double.pi) + for _ in 0 ..< 2_000 { + x = sin(x) } } + + isolationQueue.async { + counter += 1 + group.leave() + } } + } + + // NOTE: The test has a 1 minute time limit that will time out. In + // the original code, this timeout was 5 seconds, but currently + // the shortest timeout Swift Testing provides is 1 minute. + await group.wait() - let finalValue = await result.value - - /// NOTE: If you need to visually debug issues, you can uncomment - /// the following to watch a visual representation of the group ordering. - /// - /// In general, you'll see something like the following printed over and over - /// to the console: - /// - /// ``` - /// |🔵42🟣/🟣^🟣\🟢42= - /// ``` - /// - /// What you should observe: - /// - /// - The index number be the same at the beginning and end of each line, and it - /// should always increment by one. - /// - The 🔵 should always be first, and the 🟢 should always be last for each line. - /// - There should always be 3 🟣's in between the 🔵 and 🟢. - /// - The ordering of the 🟣 can be random, and that is fine. - /// - /// For example, for of the following are valid outputs: - /// - /// ``` - /// // GOOD - /// |🔵42🟣/🟣^🟣\🟢42= - /// ``` - /// - /// ``` - /// // GOOD - /// |🔵42🟣/🟣\🟣^🟢42= - /// ``` - /// - /// But the following would not be valid: - /// - /// ``` - /// // BAD! - /// |🔵43🟣/🟣^🟣\🟢43= - /// |🔵42🟣/🟣^🟣\🟢42= - /// |🔵44🟣/🟣^🟣\🟢44= - /// ``` - /// - /// ``` - /// // BAD! - /// |🔵42🟣/🟣^🟢42🟣\= - /// ``` - /// - - // Uncomment to use troubleshooting method above: - // print(finalValue) - - #expect(finalValue.prefix(1) == "|") - #expect(finalValue.count { $0 == "🟣" } == 3) - #expect(finalValue.count { $0 == "🟢" } == 1) - #expect(finalValue.lastIndex(of: "🟣")! < finalValue.firstIndex(of: "🟢")!) - #expect(finalValue.suffix(1) == "=") + // Verify notify fires exactly once. + nonisolated(unsafe) var notifyHits = 0 + await withCheckedContinuation { k in + group.notify(queue: .main) { + notifyHits += 1 + k.resume() + } } + #expect(notifyHits == 1) + + let finalCount = counter + #expect(finalCount == iterations) } } + + From 73b9e2c89af0915f98e136fb9fd1298ab981a2e9 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 8 Jul 2025 13:15:51 -0600 Subject: [PATCH 04/12] test: Fix a few different issues that caused some tests to be flaky. And other minor improvements to tests. --- .../AsyncSemaphoreTests.swift | 29 ++++++++++++------- .../DispatchSemaphoreTests.swift | 1 + .../DispatchTimeTests.swift | 14 ++++++++- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift index 41a2839..f46ed78 100644 --- a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift +++ b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift @@ -2,7 +2,7 @@ import Testing @testable import DispatchAsync -@Test +@Test(.timeLimit(.minutes(1))) func asyncSemaphoreWaitSignal() async throws { let semaphore = AsyncSemaphore(value: 1) @@ -10,14 +10,20 @@ func asyncSemaphoreWaitSignal() async throws { await semaphore.wait() // Launch a task that tries to wait – it should be suspended until we signal - var didEnterCriticalSection = false - let waiter = Task { - await semaphore.wait() - didEnterCriticalSection = true - await semaphore.signal() + nonisolated(unsafe) var didEnterCriticalSection = false + await withCheckedContinuation { continuation in + Task { @Sendable in + // Ensure the rest of this test doesn't + // proceed until the Task block has started executing + continuation.resume() + + await semaphore.wait() + didEnterCriticalSection = true + await semaphore.signal() + } } - // Allow the task a brief moment to start and (hopefully) suspend + // Allow the task a few cycles to reach the initial semaphore.wait() try? await Task.sleep(nanoseconds: 1_000) #expect(!didEnterCriticalSection) // should still be waiting @@ -25,10 +31,11 @@ func asyncSemaphoreWaitSignal() async throws { // Now release the semaphore – the waiter should proceed await semaphore.signal() - // Give the waiter a chance to run - try? await Task.sleep(nanoseconds: 1_000) + // Wait for second signal to fire from inside the task above + // There is a timeout on this test, so if there is a problem + // we'll either hit the timeout and fail, or didEnterCriticalSection + // will be false below + await semaphore.wait() #expect(didEnterCriticalSection) // waiter must have run - - _ = await waiter.value } diff --git a/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift b/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift index 4c35698..7148a74 100644 --- a/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift +++ b/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift @@ -26,6 +26,7 @@ private typealias DispatchSemaphore = AsyncSemaphore nonisolated(unsafe) private var sharedPoolCompletionCount = 0 @Test func basicAsyncSemaphoreTest() async throws { + sharedPoolCompletionCount = 0 // Reset to 0 for each test run let totalConcurrentPools = 10 let semaphore = DispatchSemaphore(value: 1) diff --git a/Tests/DispatchAsyncTests/DispatchTimeTests.swift b/Tests/DispatchAsyncTests/DispatchTimeTests.swift index 517b73f..be693c8 100644 --- a/Tests/DispatchAsyncTests/DispatchTimeTests.swift +++ b/Tests/DispatchAsyncTests/DispatchTimeTests.swift @@ -23,5 +23,17 @@ private typealias DispatchTime = DispatchAsync.DispatchTime func testDispatchTimeContinousClockBasics() async throws { let a = DispatchTime.now().uptimeNanoseconds let b = DispatchTime.now().uptimeNanoseconds - #expect(a <= b) + try await Task.sleep(for: .nanoseconds(1)) + let c = DispatchTime.now().uptimeNanoseconds + #expect(a < b) + #expect(b < c) +} + +@Test +func testUptimeNanosecondsEqualityForConsecutiveCalls() async throws { + let original = DispatchTime.now() + let a = original.uptimeNanoseconds + try await Task.sleep(for: .nanoseconds(100)) + let b = original.uptimeNanoseconds + #expect(a == b) } From 8f6e2be42cbbcb0b978d105abcbfd69d21424e8e Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 8 Jul 2025 13:29:41 -0600 Subject: [PATCH 05/12] docs: Make note of some debug printouts. --- Tests/DispatchAsyncTests/DispatchGroupTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/DispatchAsyncTests/DispatchGroupTests.swift b/Tests/DispatchAsyncTests/DispatchGroupTests.swift index ac7c3ff..ae6287d 100644 --- a/Tests/DispatchAsyncTests/DispatchGroupTests.swift +++ b/Tests/DispatchAsyncTests/DispatchGroupTests.swift @@ -104,14 +104,14 @@ struct DispatchGroupTests { /// But the following would not be valid: /// /// ``` - /// // BAD! + /// // BAD! (43 comes before 42) /// |🔵43🟣/🟣^🟣\🟢43= /// |🔵42🟣/🟣^🟣\🟢42= /// |🔵44🟣/🟣^🟣\🟢44= /// ``` /// /// ``` - /// // BAD! + /// // BAD! (green globe comes before a purle one) /// |🔵42🟣/🟣^🟢42🟣\= /// ``` /// From 46ff27b64726bdf29c6ed0145391cb2d8dc6ba40 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Wed, 9 Jul 2025 16:58:32 -0600 Subject: [PATCH 06/12] fix: ACL for DispatchGroup.wait should have been public. --- Sources/DispatchAsync/DispatchGroup.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DispatchAsync/DispatchGroup.swift b/Sources/DispatchAsync/DispatchGroup.swift index 6dafbee..d0876fe 100644 --- a/Sources/DispatchAsync/DispatchGroup.swift +++ b/Sources/DispatchAsync/DispatchGroup.swift @@ -69,7 +69,7 @@ extension DispatchAsync { } } - func wait() async { + public func wait() async { await withCheckedContinuation { continuation in queue.enqueue { [weak self] in guard let self else { return } From f9cf1e00d9496b4504a920e9f6cb26617eec6d9d Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Wed, 9 Jul 2025 16:59:42 -0600 Subject: [PATCH 07/12] chore: Update availability and other compilation issues after latest rebase. --- .../AsyncSemaphoreTests.swift | 1 + .../DispatchAsyncTests/DispatchGroupTests.swift | 3 +++ .../DispatchPingPongTests.swift | 17 ++++++++++++++++- .../DispatchTimeIntervalTests.swift | 17 ++++++++++++++++- .../DispatchAsyncTests/DispatchTimeTests.swift | 1 + 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift index f46ed78..7bd9a03 100644 --- a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift +++ b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift @@ -2,6 +2,7 @@ import Testing @testable import DispatchAsync +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) @Test(.timeLimit(.minutes(1))) func asyncSemaphoreWaitSignal() async throws { let semaphore = AsyncSemaphore(value: 1) diff --git a/Tests/DispatchAsyncTests/DispatchGroupTests.swift b/Tests/DispatchAsyncTests/DispatchGroupTests.swift index ae6287d..e5b920d 100644 --- a/Tests/DispatchAsyncTests/DispatchGroupTests.swift +++ b/Tests/DispatchAsyncTests/DispatchGroupTests.swift @@ -20,10 +20,12 @@ import class Foundation.Thread import Testing private typealias DispatchGroup = DispatchAsync.DispatchGroup +private typealias DispatchQueue = DispatchAsync.DispatchQueue @Suite("DispatchGroup Tests") struct DispatchGroupTests { @Test(arguments: [1000]) + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func dispatchGroupOrderCleanliness(repetitions: Int) async throws { // Repeating this `repetitions` number of times to help rule out // edge cases that only show up some of the time @@ -133,6 +135,7 @@ struct DispatchGroupTests { /// math-heavy blocks on a global queue, then waiting for them to finish with a /// timeout. It also verifies that `notify` is invoked exactly once. @Test(.timeLimit(.minutes(1))) + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) func dispatchGroupStress() async throws { let iterations = 1000 // We use a separate concurrent queue rather than the global queue to avoid interference issues diff --git a/Tests/DispatchAsyncTests/DispatchPingPongTests.swift b/Tests/DispatchAsyncTests/DispatchPingPongTests.swift index e670ab8..f772000 100644 --- a/Tests/DispatchAsyncTests/DispatchPingPongTests.swift +++ b/Tests/DispatchAsyncTests/DispatchPingPongTests.swift @@ -1,6 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(DispatchAsync) import DispatchAsync import Testing -@testable import DispatchAsync +private typealias DispatchQueue = DispatchAsync.DispatchQueue /// Ping-Pong queue test is adapted from the test /// [dispatch_pingpong.c in libdispatch](https://github.com/swiftlang/swift-corelibs-libdispatch/blob/main/tests/dispatch_pingpong.c). diff --git a/Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift b/Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift index 4e2fe81..917a569 100644 --- a/Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift +++ b/Tests/DispatchAsyncTests/DispatchTimeIntervalTests.swift @@ -1,6 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(DispatchAsync) import DispatchAsync import Testing -@testable import DispatchAsync +private typealias DispatchTimeInterval = DispatchAsync.DispatchTimeInterval @Test func dispatchTimeIntervalEquality() throws { diff --git a/Tests/DispatchAsyncTests/DispatchTimeTests.swift b/Tests/DispatchAsyncTests/DispatchTimeTests.swift index be693c8..1728a82 100644 --- a/Tests/DispatchAsyncTests/DispatchTimeTests.swift +++ b/Tests/DispatchAsyncTests/DispatchTimeTests.swift @@ -29,6 +29,7 @@ func testDispatchTimeContinousClockBasics() async throws { #expect(b < c) } +@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) @Test func testUptimeNanosecondsEqualityForConsecutiveCalls() async throws { let original = DispatchTime.now() From a5c25531b9ecacab6fe548830200a27f2c9ee063 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Wed, 9 Jul 2025 17:15:41 -0600 Subject: [PATCH 08/12] refactor: Consolidate semaphore tests to single file. --- .../AsyncSemaphoreTests.swift | 53 +++++++++++++++ .../DispatchSemaphoreTests.swift | 64 ------------------- 2 files changed, 53 insertions(+), 64 deletions(-) delete mode 100644 Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift diff --git a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift index 7bd9a03..09b8868 100644 --- a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift +++ b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift @@ -1,3 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 PassiveLogic, Inc. +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + import Testing @testable import DispatchAsync @@ -40,3 +54,42 @@ func asyncSemaphoreWaitSignal() async throws { #expect(didEnterCriticalSection) // waiter must have run } + +@Test func basicAsyncSemaphoreTest() async throws { + nonisolated(unsafe) var sharedPoolCompletionCount = 0 + sharedPoolCompletionCount = 0 // Reset to 0 for each test run + let totalConcurrentPools = 10 + + let semaphore = AsyncSemaphore(value: 1) + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< totalConcurrentPools { + group.addTask { + // Wait for any other pools currently holding the semaphore + await semaphore.wait() + + // Only one task should mutate counter at a time + // + // If there are issues with the semaphore, then + // we would expect to grab incorrect values here occasionally, + // which would result in an incorrect final completion count. + // + let existingPoolCompletionCount = sharedPoolCompletionCount + + // Add artificial delay to amplify race conditions + // Pools started shortly after this "semaphore-locked" + // pool starts will run before this line, unless + // this pool contains a valid lock. + try? await Task.sleep(nanoseconds: 100) + + sharedPoolCompletionCount = existingPoolCompletionCount + 1 + + // When we exit this flow, release our hold on the semaphore + await semaphore.signal() + } + } + } + + // After all tasks are done, counter should be 10 + #expect(sharedPoolCompletionCount == totalConcurrentPools) +} diff --git a/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift b/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift deleted file mode 100644 index 7148a74..0000000 --- a/Tests/DispatchAsyncTests/DispatchSemaphoreTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2025 PassiveLogic, Inc. -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of Swift.org project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -// TODO: SM: Rename this file to AsyncSemaphoreTests (coming in next PR that adds tests) - -import Testing - -@testable import DispatchAsync - -// NOTE: AsyncSempahore is nearly API-compatible with DispatchSemaphore, -// This typealias helps demonstrate that fact. -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -private typealias DispatchSemaphore = AsyncSemaphore - -nonisolated(unsafe) private var sharedPoolCompletionCount = 0 - -@Test func basicAsyncSemaphoreTest() async throws { - sharedPoolCompletionCount = 0 // Reset to 0 for each test run - let totalConcurrentPools = 10 - - let semaphore = DispatchSemaphore(value: 1) - - await withTaskGroup(of: Void.self) { group in - for _ in 0 ..< totalConcurrentPools { - group.addTask { - // Wait for any other pools currently holding the semaphore - await semaphore.wait() - - // Only one task should mutate counter at a time - // - // If there are issues with the semaphore, then - // we would expect to grab incorrect values here occasionally, - // which would result in an incorrect final completion count. - // - let existingPoolCompletionCount = sharedPoolCompletionCount - - // Add artificial delay to amplify race conditions - // Pools started shortly after this "semaphore-locked" - // pool starts will run before this line, unless - // this pool contains a valid lock. - try? await Task.sleep(nanoseconds: 100) - - sharedPoolCompletionCount = existingPoolCompletionCount + 1 - - // When we exit this flow, release our hold on the semaphore - await semaphore.signal() - } - } - } - - // After all tasks are done, counter should be 10 - #expect(sharedPoolCompletionCount == totalConcurrentPools) -} From 9153d5b72c04047bcd573e250ed6fcc5402bc521 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Wed, 9 Jul 2025 17:16:57 -0600 Subject: [PATCH 09/12] chore: Run swift-format. --- Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift | 4 ++-- Tests/DispatchAsyncTests/DispatchGroupTests.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift index 09b8868..23a7d2f 100644 --- a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift +++ b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift @@ -41,7 +41,7 @@ func asyncSemaphoreWaitSignal() async throws { // Allow the task a few cycles to reach the initial semaphore.wait() try? await Task.sleep(nanoseconds: 1_000) - #expect(!didEnterCriticalSection) // should still be waiting + #expect(!didEnterCriticalSection) // should still be waiting // Now release the semaphore – the waiter should proceed await semaphore.signal() @@ -52,7 +52,7 @@ func asyncSemaphoreWaitSignal() async throws { // will be false below await semaphore.wait() - #expect(didEnterCriticalSection) // waiter must have run + #expect(didEnterCriticalSection) // waiter must have run } @Test func basicAsyncSemaphoreTest() async throws { diff --git a/Tests/DispatchAsyncTests/DispatchGroupTests.swift b/Tests/DispatchAsyncTests/DispatchGroupTests.swift index e5b920d..367bf74 100644 --- a/Tests/DispatchAsyncTests/DispatchGroupTests.swift +++ b/Tests/DispatchAsyncTests/DispatchGroupTests.swift @@ -13,11 +13,13 @@ //===----------------------------------------------------------------------===// @_spi(DispatchAsync) import DispatchAsync +import Testing + import func Foundation.sin + #if !os(WASI) import class Foundation.Thread #endif -import Testing private typealias DispatchGroup = DispatchAsync.DispatchGroup private typealias DispatchQueue = DispatchAsync.DispatchQueue @@ -143,7 +145,7 @@ struct DispatchGroupTests { let workQueue = DispatchQueue(attributes: .concurrent) let group = DispatchGroup() - let isolationQueue = DispatchQueue(label: "isolationQueue") + let isolationQueue = DispatchQueue(label: "isolationQueue") nonisolated(unsafe) var counter = 0 for _ in 0 ..< iterations { @@ -195,5 +197,3 @@ struct DispatchGroupTests { #expect(finalCount == iterations) } } - - From 0e0c7c34f89ba9cfbbe0ef8c21fb34cd86713cd8 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Wed, 9 Jul 2025 17:23:17 -0600 Subject: [PATCH 10/12] docs: Add link to original port of test. --- Tests/DispatchAsyncTests/DispatchGroupTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/DispatchAsyncTests/DispatchGroupTests.swift b/Tests/DispatchAsyncTests/DispatchGroupTests.swift index 367bf74..dd3f3ef 100644 --- a/Tests/DispatchAsyncTests/DispatchGroupTests.swift +++ b/Tests/DispatchAsyncTests/DispatchGroupTests.swift @@ -133,6 +133,9 @@ struct DispatchGroupTests { } /// Swift port of libdispatch/tests/dispatch_group.c + /// + /// See https://github.com/swiftlang/swift-corelibs-libdispatch/blob/686475721aca13d98d2eab3a0c439403d33b6e2d/tests/dispatch_group.c + /// /// The original C test stresses `dispatch_group_wait` by enqueuing a bunch of /// math-heavy blocks on a global queue, then waiting for them to finish with a /// timeout. It also verifies that `notify` is invoked exactly once. From e681e821c2d4366d8fbd954f7bae20ee140dbfdf Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Wed, 9 Jul 2025 17:32:47 -0600 Subject: [PATCH 11/12] fix: Fix compilation error for consolidated AsyncSemaphoreTests. --- .../AsyncSemaphoreTests.swift | 136 +++++++++--------- 1 file changed, 70 insertions(+), 66 deletions(-) diff --git a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift index 23a7d2f..33d1f4d 100644 --- a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift +++ b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift @@ -16,80 +16,84 @@ import Testing @testable import DispatchAsync -@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) -@Test(.timeLimit(.minutes(1))) -func asyncSemaphoreWaitSignal() async throws { - let semaphore = AsyncSemaphore(value: 1) - - // First wait should succeed immediately and bring the count to 0 - await semaphore.wait() - - // Launch a task that tries to wait – it should be suspended until we signal - nonisolated(unsafe) var didEnterCriticalSection = false - await withCheckedContinuation { continuation in - Task { @Sendable in - // Ensure the rest of this test doesn't - // proceed until the Task block has started executing - continuation.resume() - - await semaphore.wait() - didEnterCriticalSection = true - await semaphore.signal() - } - } - - // Allow the task a few cycles to reach the initial semaphore.wait() - try? await Task.sleep(nanoseconds: 1_000) - - #expect(!didEnterCriticalSection) // should still be waiting - - // Now release the semaphore – the waiter should proceed - await semaphore.signal() +nonisolated(unsafe) private var sharedPoolCompletionCount = 0 + +@Suite("DispatchGroup Tests") +class AsyncSemaphoreTests { + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) + @Test(.timeLimit(.minutes(1))) + func asyncSemaphoreWaitSignal() async throws { + let semaphore = AsyncSemaphore(value: 1) + + // First wait should succeed immediately and bring the count to 0 + await semaphore.wait() + + // Launch a task that tries to wait – it should be suspended until we signal + nonisolated(unsafe) var didEnterCriticalSection = false + await withCheckedContinuation { continuation in + Task { @Sendable in + // Ensure the rest of this test doesn't + // proceed until the Task block has started executing + continuation.resume() - // Wait for second signal to fire from inside the task above - // There is a timeout on this test, so if there is a problem - // we'll either hit the timeout and fail, or didEnterCriticalSection - // will be false below - await semaphore.wait() - - #expect(didEnterCriticalSection) // waiter must have run -} - -@Test func basicAsyncSemaphoreTest() async throws { - nonisolated(unsafe) var sharedPoolCompletionCount = 0 - sharedPoolCompletionCount = 0 // Reset to 0 for each test run - let totalConcurrentPools = 10 + await semaphore.wait() + didEnterCriticalSection = true + await semaphore.signal() + } + } - let semaphore = AsyncSemaphore(value: 1) + // Allow the task a few cycles to reach the initial semaphore.wait() + try? await Task.sleep(nanoseconds: 1_000) - await withTaskGroup(of: Void.self) { group in - for _ in 0 ..< totalConcurrentPools { - group.addTask { - // Wait for any other pools currently holding the semaphore - await semaphore.wait() + #expect(!didEnterCriticalSection) // should still be waiting - // Only one task should mutate counter at a time - // - // If there are issues with the semaphore, then - // we would expect to grab incorrect values here occasionally, - // which would result in an incorrect final completion count. - // - let existingPoolCompletionCount = sharedPoolCompletionCount + // Now release the semaphore – the waiter should proceed + await semaphore.signal() - // Add artificial delay to amplify race conditions - // Pools started shortly after this "semaphore-locked" - // pool starts will run before this line, unless - // this pool contains a valid lock. - try? await Task.sleep(nanoseconds: 100) + // Wait for second signal to fire from inside the task above + // There is a timeout on this test, so if there is a problem + // we'll either hit the timeout and fail, or didEnterCriticalSection + // will be false below + await semaphore.wait() - sharedPoolCompletionCount = existingPoolCompletionCount + 1 + #expect(didEnterCriticalSection) // waiter must have run + } - // When we exit this flow, release our hold on the semaphore - await semaphore.signal() + @Test func basicAsyncSemaphoreTest() async throws { + sharedPoolCompletionCount = 0 // Reset to 0 for each test run + let totalConcurrentPools = 10 + + let semaphore = AsyncSemaphore(value: 1) + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< totalConcurrentPools { + group.addTask { + // Wait for any other pools currently holding the semaphore + await semaphore.wait() + + // Only one task should mutate counter at a time + // + // If there are issues with the semaphore, then + // we would expect to grab incorrect values here occasionally, + // which would result in an incorrect final completion count. + // + let existingPoolCompletionCount = sharedPoolCompletionCount + + // Add artificial delay to amplify race conditions + // Pools started shortly after this "semaphore-locked" + // pool starts will run before this line, unless + // this pool contains a valid lock. + try? await Task.sleep(nanoseconds: 100) + + sharedPoolCompletionCount = existingPoolCompletionCount + 1 + + // When we exit this flow, release our hold on the semaphore + await semaphore.signal() + } } } - } - // After all tasks are done, counter should be 10 - #expect(sharedPoolCompletionCount == totalConcurrentPools) + // After all tasks are done, counter should be 10 + #expect(sharedPoolCompletionCount == totalConcurrentPools) + } } From 7331fb678642ad727a4df2114b12b7455e6eb210 Mon Sep 17 00:00:00 2001 From: Scott Marchant Date: Tue, 30 Sep 2025 08:45:00 -0600 Subject: [PATCH 12/12] chore: Peer review suggested changes to clean up typos and naming issues. --- Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift | 2 +- Tests/DispatchAsyncTests/DispatchPingPongTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift index 33d1f4d..c0d4c44 100644 --- a/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift +++ b/Tests/DispatchAsyncTests/AsyncSemaphoreTests.swift @@ -18,7 +18,7 @@ import Testing nonisolated(unsafe) private var sharedPoolCompletionCount = 0 -@Suite("DispatchGroup Tests") +@Suite("AsyncSemaphore Tests") class AsyncSemaphoreTests { @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) @Test(.timeLimit(.minutes(1))) diff --git a/Tests/DispatchAsyncTests/DispatchPingPongTests.swift b/Tests/DispatchAsyncTests/DispatchPingPongTests.swift index f772000..d9b9532 100644 --- a/Tests/DispatchAsyncTests/DispatchPingPongTests.swift +++ b/Tests/DispatchAsyncTests/DispatchPingPongTests.swift @@ -17,7 +17,7 @@ import Testing private typealias DispatchQueue = DispatchAsync.DispatchQueue -/// Ping-Pong queue test is adapted from the test +/// Ping-Pong queue test is adapted from the test /// [dispatch_pingpong.c in libdispatch](https://github.com/swiftlang/swift-corelibs-libdispatch/blob/main/tests/dispatch_pingpong.c). /// /// Two queues recursively schedule work on each other N times. The test