Skip to content

Commit bc349b2

Browse files
dfedbachand
andauthored
Add tests for Semaphore type (#5)
* Add tests for Semaphore type * Utilize a single actor rather than two actors to increase determinism * Ensure 100% coverage of all code, including test code * Be explicit about wanting 100% coverage * Review feedback --------- Co-authored-by: Michael Bachand <bachand.michael@gmail.com>
1 parent 71212a7 commit bc349b2

File tree

2 files changed

+144
-5
lines changed

2 files changed

+144
-5
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2022 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 XCTest
24+
25+
final class SemaphoreTests: XCTestCase {
26+
27+
// MARK: XCTestCase
28+
29+
override func setUp() async throws {
30+
try await super.setUp()
31+
32+
systemUnderTest = Semaphore()
33+
}
34+
35+
override func tearDown() async throws {
36+
let isWaiting = await systemUnderTest.isWaiting
37+
XCTAssertFalse(isWaiting)
38+
39+
try await super.tearDown()
40+
}
41+
42+
// MARK: Behavior Tests
43+
44+
func test_wait_suspendsUntilEqualNumberOfSignalCalls() async {
45+
/*
46+
This test is tricky to pull off!
47+
Our requirements:
48+
1. We need to call `wait()` before `signal()`
49+
2. We need to ensure that the `wait()` call suspends _before_ we call `signal()`
50+
3. We can't `await` the `wait()` call on the test's queue before calling `signal()` since that would deadlock the test.
51+
4. We must utilize a single actor's isolated context to avoid accidental interleaving when suspending to communicate across actor contexts.
52+
53+
In order to ensure that we are executing the `wait()` calls before we call `signal()` _without awaiting a `wait()` call_,
54+
we utilize the Sempahore's ordered execution context to enqueue ordered `Task`s similar to how an ActorQueue works.
55+
*/
56+
57+
let iterationCount = 1_000
58+
/// A counter that will only be accessed from within the `systemUnderTest`'s context
59+
let unsafeCounter = UnsafeCounter()
60+
61+
for _ in 1...iterationCount {
62+
await systemUnderTest.enqueueAndCount(using: unsafeCounter) { systemUnderTest in
63+
let didSuspend = await systemUnderTest.wait()
64+
XCTAssertTrue(didSuspend)
65+
66+
return { systemUnderTest in
67+
// Signal that the suspended wait call above has resumed.
68+
// This signal allows us to `wait()` for all of these enqueued `wait()` tasks to have completed later in this test.
69+
systemUnderTest.signal()
70+
}
71+
}
72+
}
73+
74+
// Loop one fewer than iterationCount.
75+
for _ in 1..<iterationCount {
76+
await systemUnderTest.execute { systemUnderTest in
77+
systemUnderTest.signal()
78+
}
79+
}
80+
81+
await systemUnderTest.execute { systemUnderTest in
82+
// Give each suspended `wait` task an opportunity to resume (if they were to resume, which they won't) before we check the count.
83+
for _ in 1...iterationCount {
84+
await Task.yield()
85+
}
86+
87+
// The count will still be zero each time because we have executed one more `wait` than `signal` calls.
88+
let completedCountedTasks = unsafeCounter.countedTasksCompleted
89+
XCTAssertEqual(completedCountedTasks, 0)
90+
91+
// Signal one last time, enabling all of the original `wait` calls to resume.
92+
systemUnderTest.signal()
93+
94+
for _ in 1...iterationCount {
95+
// Wait for all enqueued `wait`s to have completed and signaled their completion.
96+
await systemUnderTest.wait()
97+
}
98+
99+
let tasksCompleted = unsafeCounter.countedTasksCompleted
100+
XCTAssertEqual(iterationCount, tasksCompleted)
101+
}
102+
}
103+
104+
func test_wait_doesNotSuspendIfSignalCalledFirst() async {
105+
await systemUnderTest.signal()
106+
let didSuspend = await systemUnderTest.wait()
107+
XCTAssertFalse(didSuspend)
108+
}
109+
110+
// MARK: Private
111+
112+
private var systemUnderTest = Semaphore()
113+
}
114+
115+
// MARK: - Semaphore Extension
116+
117+
private extension Semaphore {
118+
/// Enqueues an asynchronous task. This method suspends the caller until the asynchronous task has begun, ensuring ordered execution of enqueued tasks.
119+
/// - Parameter task: A unit of work that returns work to execute after the task completes and the count is incremented.
120+
func enqueueAndCount(using counter: UnsafeCounter, _ task: @escaping @Sendable (isolated Semaphore) async -> ((isolated Semaphore) -> Void)?) async {
121+
// Await the start of the soon-to-be-enqueued `Task` with a continuation.
122+
await withCheckedContinuation { continuation in
123+
// Re-enter the actor context but don't wait for the result.
124+
Task {
125+
// Now that we're back in the actor context, resume the calling code.
126+
continuation.resume()
127+
let executeAfterIncrement = await task(self)
128+
counter.countedTasksCompleted += 1
129+
executeAfterIncrement?(self)
130+
}
131+
}
132+
}
133+
134+
func execute(_ task: @Sendable (isolated Semaphore) async throws -> Void) async rethrows {
135+
try await task(self)
136+
}
137+
}
138+
139+
// MARK: - UnsafeCounter
140+
141+
private final class UnsafeCounter: @unchecked Sendable {
142+
var countedTasksCompleted = 0
143+
}

codecov.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,5 @@ coverage:
1010
status:
1111
project:
1212
default:
13-
threshold: 0.25%
13+
target: 100%
1414
patch: off
15-
16-
ignore:
17-
# This package is not shipped.
18-
- "Tests"

0 commit comments

Comments
 (0)