5
5
package kotlinx.coroutines.reactor
6
6
7
7
import kotlinx.coroutines.*
8
+ import kotlinx.coroutines.CancellationException
8
9
import kotlinx.coroutines.flow.*
9
10
import kotlinx.coroutines.reactive.*
10
11
import org.junit.*
@@ -21,6 +22,7 @@ class MonoTest : TestBase() {
21
22
@Before
22
23
fun setup () {
23
24
ignoreLostThreads(" timer-" , " parallel-" )
25
+ Hooks .onErrorDropped { expectUnreached() }
24
26
}
25
27
26
28
@Test
@@ -285,4 +287,95 @@ class MonoTest : TestBase() {
285
287
.collect { }
286
288
}
287
289
}
290
+
291
+ /* * Test that cancelling a [mono] due to a timeout does throw an exception. */
292
+ @Test
293
+ fun testTimeout () {
294
+ val mono = mono {
295
+ withTimeout(1 ) { delay(100 ) }
296
+ }
297
+ try {
298
+ mono.doOnSubscribe { expect(1 ) }
299
+ .doOnNext { expectUnreached() }
300
+ .doOnSuccess { expectUnreached() }
301
+ .doOnError { expect(2 ) }
302
+ .doOnCancel { expectUnreached() }
303
+ .block()
304
+ } catch (e: CancellationException ) {
305
+ expect(3 )
306
+ }
307
+ finish(4 )
308
+ }
309
+
310
+ /* * Test that when the reason for cancellation of a [mono] is that the downstream doesn't want its results anymore,
311
+ * this is considered normal behavior and exceptions are not propagated. */
312
+ @Test
313
+ fun testDownstreamCancellationDoesNotThrow () = runTest {
314
+ /* * Attach a hook that handles exceptions from publishers that are known to be disposed of. We don't expect it
315
+ * to be fired in this case, as the reason for the publisher in this test to accept an exception is simply
316
+ * cancellation from the downstream. */
317
+ Hooks .onOperatorError(" testDownstreamCancellationDoesNotThrow" ) { t, a ->
318
+ expectUnreached()
319
+ t
320
+ }
321
+ /* * A Mono that doesn't emit a value and instead waits indefinitely. */
322
+ val mono = mono { expect(3 ); delay(Long .MAX_VALUE ) }
323
+ .doOnSubscribe { expect(2 ) }
324
+ .doOnNext { expectUnreached() }
325
+ .doOnSuccess { expectUnreached() }
326
+ .doOnError { expectUnreached() }
327
+ .doOnCancel { expect(4 ) }
328
+ expect(1 )
329
+ mono.awaitCancelAndJoin()
330
+ finish(5 )
331
+ Hooks .resetOnOperatorError(" testDownstreamCancellationDoesNotThrow" )
332
+ }
333
+
334
+ /* * Test that, when [Mono] is cancelled by the downstream and throws during handling the cancellation, the resulting
335
+ * error is propagated to [Hooks.onOperatorError]. */
336
+ @Test
337
+ fun testRethrowingDownstreamCancellation () = runTest {
338
+ /* * Attach a hook that handles exceptions from publishers that are known to be disposed of. We expect it
339
+ * to be fired in this case. */
340
+ Hooks .onOperatorError(" testDownstreamCancellationDoesNotThrow" ) { t, a ->
341
+ expect(5 )
342
+ t
343
+ }
344
+ /* * A Mono that doesn't emit a value and instead waits indefinitely, and, when cancelled, throws. */
345
+ val mono = mono {
346
+ expect(3 );
347
+ try {
348
+ delay(Long .MAX_VALUE )
349
+ } catch (e: CancellationException ) {
350
+ throw TestException ()
351
+ }
352
+ }
353
+ .doOnSubscribe { expect(2 ) }
354
+ .doOnNext { expectUnreached() }
355
+ .doOnSuccess { expectUnreached() }
356
+ .doOnError { expectUnreached() }
357
+ .doOnCancel { expect(4 ) }
358
+ expect(1 )
359
+ mono.awaitCancelAndJoin()
360
+ finish(6 ) /* * if this line fails, see the comment for [awaitCancelAndJoin] */
361
+ Hooks .resetOnOperatorError(" testDownstreamCancellationDoesNotThrow" )
362
+ }
363
+
364
+ /* * Run the given [Mono], cancel it, wait for the cancellation handler to finish, and *return only then*.
365
+ *
366
+ * There are no guarantees about the execution context in which the cancellation handler will run, but we have
367
+ * to wait for it to finish to check its behavior. The contraption below seems to ensure that everything works out.
368
+ * If it stops giving that guarantee, then [testRethrowingDownstreamCancellation] should fail more or less
369
+ * consistently because the hook won't have enough time to fire before a call to [finish].
370
+ */
371
+ private suspend fun <T > Mono<T>.awaitCancelAndJoin () = coroutineScope {
372
+ val job = async(start = CoroutineStart .UNDISPATCHED ) {
373
+ awaitFirstOrNull()
374
+ }
375
+ newSingleThreadContext(" monoCancellationCleanup" ).use { pool ->
376
+ launch(pool) {
377
+ job.cancelAndJoin()
378
+ }
379
+ }.join()
380
+ }
288
381
}
0 commit comments