|
5 | 5 | package com.agentclientprotocol.sdk.spec; |
6 | 6 |
|
7 | 7 | import java.util.HashSet; |
| 8 | +import java.util.Objects; |
8 | 9 | import java.util.Set; |
| 10 | +import java.util.concurrent.CountDownLatch; |
| 11 | +import java.util.concurrent.ExecutorService; |
| 12 | +import java.util.concurrent.Executors; |
| 13 | +import java.util.concurrent.Future; |
| 14 | +import java.util.concurrent.TimeUnit; |
| 15 | +import java.util.concurrent.atomic.AtomicBoolean; |
9 | 16 | import java.util.concurrent.atomic.AtomicInteger; |
10 | 17 | import java.util.concurrent.atomic.AtomicIntegerArray; |
11 | 18 | import java.util.concurrent.atomic.AtomicReference; |
|
23 | 30 | import org.junit.jupiter.api.AfterAll; |
24 | 31 | import org.junit.jupiter.api.BeforeAll; |
25 | 32 | import org.junit.jupiter.api.Test; |
| 33 | +import org.reactivestreams.Subscription; |
26 | 34 | import reactor.core.Disposable; |
| 35 | +import reactor.core.publisher.BaseSubscriber; |
| 36 | +import reactor.core.publisher.Mono; |
27 | 37 | import reactor.core.publisher.MonoSink; |
28 | 38 | import reactor.util.context.Context; |
29 | 39 |
|
| 40 | +import static org.assertj.core.api.Assertions.assertThat; |
| 41 | + |
30 | 42 | /** |
31 | 43 | * Model checks {@link PendingResponses}, the table of requests waiting for a response, with |
32 | 44 | * Lincheck. Requests are registered, answered, cancelled (their sink is disposed, as a |
@@ -84,6 +96,62 @@ void noRequestIsLostOrCompletedTwice() { |
84 | 96 | .check(Requests.class); |
85 | 97 | } |
86 | 98 |
|
| 99 | + /** |
| 100 | + * The Reactor contract the model's sink relies on: a cancel that arrives while another is |
| 101 | + * still in the cancel callback does nothing, so the dispose callback (which removes the |
| 102 | + * entry) cannot overtake the cancel callback (which reports the cancel only if it removes |
| 103 | + * the entry itself). |
| 104 | + */ |
| 105 | + @Test |
| 106 | + void reactorRunsTheCallbacksOfOnlyTheFirstCancel() throws Exception { |
| 107 | + CountDownLatch inCancelCallback = new CountDownLatch(1); |
| 108 | + CountDownLatch release = new CountDownLatch(1); |
| 109 | + AtomicInteger cancelCallbacks = new AtomicInteger(); |
| 110 | + AtomicBoolean disposed = new AtomicBoolean(); |
| 111 | + AtomicReference<@Nullable Subscription> subscription = new AtomicReference<>(); |
| 112 | + Mono.create(sink -> { |
| 113 | + sink.onCancel(() -> { |
| 114 | + cancelCallbacks.incrementAndGet(); |
| 115 | + inCancelCallback.countDown(); |
| 116 | + awaitQuietly(release); |
| 117 | + }); |
| 118 | + sink.onDispose(() -> disposed.set(true)); |
| 119 | + }).subscribe(new BaseSubscriber<Object>() { |
| 120 | + @Override |
| 121 | + protected void hookOnSubscribe(Subscription s) { |
| 122 | + subscription.set(s); |
| 123 | + } |
| 124 | + }); |
| 125 | + Subscription sink = Objects.requireNonNull(subscription.get()); |
| 126 | + ExecutorService first = Executors.newSingleThreadExecutor(); |
| 127 | + try { |
| 128 | + Future<?> firstCancel = first.submit(sink::cancel); |
| 129 | + assertThat(inCancelCallback.await(5, TimeUnit.SECONDS)).isTrue(); |
| 130 | + |
| 131 | + sink.cancel(); |
| 132 | + |
| 133 | + assertThat(disposed).as("the second cancel ran the dispose callback").isFalse(); |
| 134 | + release.countDown(); |
| 135 | + firstCancel.get(5, TimeUnit.SECONDS); |
| 136 | + assertThat(cancelCallbacks).hasValue(1); |
| 137 | + assertThat(disposed).isTrue(); |
| 138 | + } |
| 139 | + finally { |
| 140 | + release.countDown(); |
| 141 | + first.shutdownNow(); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + private static void awaitQuietly(CountDownLatch latch) { |
| 146 | + try { |
| 147 | + if (!latch.await(5, TimeUnit.SECONDS)) { |
| 148 | + throw new IllegalStateException("not released"); |
| 149 | + } |
| 150 | + } |
| 151 | + catch (InterruptedException e) { |
| 152 | + Thread.currentThread().interrupt(); |
| 153 | + } |
| 154 | + } |
87 | 155 |
|
88 | 156 | @Param(name = "id", gen = IntGen.class, conf = "1:2") |
89 | 157 | public static class Requests { |
@@ -218,13 +286,28 @@ static final class RecordingSink implements MonoSink<AcpSchema.JSONRPCResponse> |
218 | 286 |
|
219 | 287 | volatile boolean cancelled; |
220 | 288 |
|
| 289 | + /** Whether a cancel already ran: Reactor's sink acts on the first cancel only. */ |
| 290 | + private final AtomicBoolean cancelRan = new AtomicBoolean(); |
| 291 | + |
221 | 292 | private final AtomicReference<@Nullable Disposable> onDispose = new AtomicReference<>(); |
222 | 293 |
|
223 | 294 | private final AtomicReference<@Nullable Disposable> onCancel = new AtomicReference<>(); |
224 | 295 |
|
225 | | - /** Like Reactor's: the cancel callback, then the dispose callback, unless already signalled. */ |
| 296 | + /** |
| 297 | + * Like Reactor's: the cancel callback, then the dispose callback, unless already |
| 298 | + * signalled. Only the first cancel does anything, as in Reactor's sink, whose |
| 299 | + * {@code cancel()} swaps its state once and runs both callbacks on the thread that |
| 300 | + * won: a second cancel (the caller disposes as its timeout fires, say) cannot run the |
| 301 | + * dispose callback while the first is still in the cancel callback. A model that let |
| 302 | + * it would report a lost {@code $/cancel_request} that Reactor never produces |
| 303 | + * ({@link PendingResponsesLincheckTest#reactorRunsTheCallbacksOfOnlyTheFirstCancel()} pins |
| 304 | + * that contract). |
| 305 | + */ |
226 | 306 | void cancel() { |
227 | 307 | cancelled = true; |
| 308 | + if (!cancelRan.compareAndSet(false, true)) { |
| 309 | + return; |
| 310 | + } |
228 | 311 | Disposable callback = onCancel.getAndSet(null); |
229 | 312 | if (callback != null && signals.get() == 0) { |
230 | 313 | callback.dispose(); |
|
0 commit comments