-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAsyncSemaphoreTest.java
More file actions
303 lines (242 loc) · 9.47 KB
/
Copy pathAsyncSemaphoreTest.java
File metadata and controls
303 lines (242 loc) · 9.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
package com.marketdata.sdk;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CyclicBarrier;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;
class AsyncSemaphoreTest {
// ---------- fast path ----------
@Test
void acquireReturnsCompletedFutureWhenPermitsAvailable() {
AsyncSemaphore sem = new AsyncSemaphore(3);
CompletableFuture<Void> a = sem.acquire();
CompletableFuture<Void> b = sem.acquire();
CompletableFuture<Void> c = sem.acquire();
assertThat(a).isCompleted();
assertThat(b).isCompleted();
assertThat(c).isCompleted();
assertThat(sem.availablePermits()).isZero();
assertThat(sem.queueLength()).isZero();
}
// ---------- slow path ----------
@Test
void acquireReturnsPendingFutureWhenPoolExhausted() {
AsyncSemaphore sem = new AsyncSemaphore(2);
sem.acquire();
sem.acquire();
CompletableFuture<Void> waiter = sem.acquire();
assertThat(waiter).isNotCompleted();
assertThat(sem.availablePermits()).isZero();
assertThat(sem.queueLength()).isOne();
}
@Test
void releaseTransfersPermitDirectlyToFirstWaiter() {
AsyncSemaphore sem = new AsyncSemaphore(1);
sem.acquire(); // pool empty
CompletableFuture<Void> w1 = sem.acquire();
CompletableFuture<Void> w2 = sem.acquire();
sem.release();
// The permit goes from the in-flight caller straight to w1 — never re-counted.
assertThat(w1).isCompleted();
assertThat(w2).isNotCompleted();
assertThat(sem.availablePermits()).isZero();
assertThat(sem.queueLength()).isOne();
sem.release();
assertThat(w2).isCompleted();
assertThat(sem.availablePermits()).isZero();
assertThat(sem.queueLength()).isZero();
}
@Test
void releaseWithNoWaitersIncrementsCounter() {
AsyncSemaphore sem = new AsyncSemaphore(2);
sem.acquire();
sem.acquire();
sem.release();
assertThat(sem.availablePermits()).isOne();
sem.release();
assertThat(sem.availablePermits()).isEqualTo(2);
}
// ---------- cancellation ----------
@Test
void cancelledWaiterIsSkippedOnRelease() {
AsyncSemaphore sem = new AsyncSemaphore(1);
sem.acquire(); // pool empty
CompletableFuture<Void> cancelled = sem.acquire();
CompletableFuture<Void> alive = sem.acquire();
cancelled.cancel(false);
sem.release();
// The cancelled waiter is skipped; the next live one gets the permit.
assertThat(alive).isCompleted();
assertThat(sem.queueLength()).isZero();
assertThat(sem.availablePermits()).isZero();
}
@Test
void releaseWhenAllWaitersCancelledFallsBackToCounter() {
AsyncSemaphore sem = new AsyncSemaphore(1);
sem.acquire();
sem.acquire().cancel(false);
sem.acquire().cancel(false);
sem.release();
// No live waiter — the permit goes back to the pool.
assertThat(sem.availablePermits()).isOne();
assertThat(sem.queueLength()).isZero();
}
// ---------- ordering ----------
@Test
void waitersAreServedFifo() {
AsyncSemaphore sem = new AsyncSemaphore(0);
List<Integer> completionOrder = new ArrayList<>();
for (int i = 0; i < 10; i++) {
int id = i;
sem.acquire().thenRun(() -> completionOrder.add(id));
}
for (int i = 0; i < 10; i++) {
sem.release();
}
assertThat(completionOrder).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
}
// ---------- race between release() and waiter cancellation ----------
/**
* Regression for the TOCTOU race in {@link AsyncSemaphore#release()} between {@code pollFirst()}
* (inside the lock) and {@code complete(null)} (outside the lock). If the polled waiter is
* cancelled in that window, {@code complete(null)} returns false and — without the retry loop —
* the permit would be silently lost: it was already removed from the counter when release()
* "transferred" it, and the cancelled waiter never delivers it anywhere.
*
* <p>The race is timing-sensitive; we coordinate two threads through a {@link CyclicBarrier} and
* repeat the scenario many times so at least some iterations hit the bad window. The invariant we
* assert is permit-conservation:
*
* <ul>
* <li>If the canceller won the race, the waiter is cancelled and {@code release()} must have
* found an alternative home for the permit — either the next live waiter, or the
* available-permits counter.
* <li>If the releaser won the race, the waiter completes normally and the counter stays at 0.
* </ul>
*
* Either way, the permit is never lost.
*/
@RepeatedTest(200)
void releaseDoesNotLosePermitWhenWaiterIsCancelledMidRelease() throws Exception {
AsyncSemaphore sem = new AsyncSemaphore(1);
sem.acquire(); // pool now empty
CompletableFuture<Void> waiter = sem.acquire(); // queued
CyclicBarrier barrier = new CyclicBarrier(2);
Thread releaser =
new Thread(
() -> {
awaitBarrier(barrier);
sem.release();
});
Thread canceller =
new Thread(
() -> {
awaitBarrier(barrier);
waiter.cancel(false);
});
releaser.start();
canceller.start();
releaser.join();
canceller.join();
assertThat(sem.queueLength()).as("queue must be drained").isZero();
if (waiter.isCancelled()) {
// Canceller observed (or won) the race. Whatever release() did, the permit must have
// landed somewhere — and with no other waiter present, that "somewhere" is the counter.
assertThat(sem.availablePermits())
.as("permit must return to the pool when the only waiter is cancelled")
.isEqualTo(1);
} else {
// Releaser completed the waiter before cancel arrived. waiter must be done-normally,
// and the permit is considered "held" by the (notional) downstream consumer of the waiter.
assertThat(waiter)
.as("if not cancelled, waiter must be completed normally")
.isCompletedWithValue(null);
assertThat(sem.availablePermits()).isZero();
}
}
private static void awaitBarrier(CyclicBarrier barrier) {
try {
barrier.await();
} catch (Exception e) {
throw new AssertionError("barrier interrupted", e);
}
}
// ---------- close ----------
@Test
void closeCompletesAllQueuedWaitersWithCancellation() {
AsyncSemaphore sem = new AsyncSemaphore(1);
sem.acquire(); // pool empty
CompletableFuture<Void> w1 = sem.acquire();
CompletableFuture<Void> w2 = sem.acquire();
CompletableFuture<Void> w3 = sem.acquire();
sem.close();
// join() surfaces a CancellationException, but its shape is JDK-dependent: JDK 17 rethrows the
// original directly (message "AsyncSemaphore is closed"), while JDK 21+ wraps it in a fresh
// CancellationException (message "join") carrying the original as its cause. Accept either.
for (CompletableFuture<Void> w : List.of(w1, w2, w3)) {
assertThat(w).isCompletedExceptionally();
assertThatThrownBy(w::join)
.isInstanceOf(CancellationException.class)
.satisfiesAnyOf(
t -> assertThat(t).hasMessageContaining("closed"),
t -> assertThat(t).hasRootCauseMessage("AsyncSemaphore is closed"));
}
assertThat(sem.queueLength()).isZero();
}
@Test
void acquireAfterCloseReturnsFailedFutureImmediately() {
AsyncSemaphore sem = new AsyncSemaphore(5);
sem.close();
CompletableFuture<Void> failed = sem.acquire();
assertThat(failed).isCompletedExceptionally();
assertThatThrownBy(failed::join)
.isInstanceOf(CancellationException.class)
.satisfiesAnyOf(
t -> assertThat(t).hasMessageContaining("closed"),
t -> assertThat(t).hasRootCauseMessage("AsyncSemaphore is closed"));
assertThat(sem.queueLength()).isZero();
}
@Test
void closeIsIdempotent() {
AsyncSemaphore sem = new AsyncSemaphore(1);
CompletableFuture<Void> waiter = sem.acquire(); // takes the only permit
CompletableFuture<Void> queued = sem.acquire();
sem.close();
sem.close(); // must be safe
// First close completed the queued waiter; the second close has nothing to do.
assertThat(queued).isCompletedExceptionally();
// And the in-flight holder of the permit can still release without exploding.
assertThat(waiter).isCompleted();
sem.release();
}
@Test
void releaseAfterCloseDoesNotExplode() {
// After close the queue is empty, so release() falls through to the counter. Critical for
// the cancel-permit-after-close path: HttpDispatcher cancels the permit when its dispatched
// future is cancelled, and that cancellation may race close().
AsyncSemaphore sem = new AsyncSemaphore(1);
sem.acquire();
sem.close();
sem.release();
assertThat(sem.availablePermits()).isOne();
}
// ---------- argument validation ----------
@Test
void rejectsNegativeInitialPermits() {
assertThatThrownBy(() -> new AsyncSemaphore(-1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("permits");
}
@Test
void zeroInitialPermitsIsValidAndForcesSlowPath() {
AsyncSemaphore sem = new AsyncSemaphore(0);
CompletableFuture<Void> w = sem.acquire();
assertThat(w).isNotCompleted();
sem.release();
assertThat(w).isCompleted();
}
}