+Job()/+SupervisorJob() must-inherit-from-parent or it will ⚠️not respect parent cancellation⚠️

Bug prone: +Job() Ignores parent cancellation

SupervisorJob() added at async level -> cancel entire scope -> job with SupervisorJob() does NOT care about scope cancellation and keeps going - ⚠️🐛⚠️

+SupervisorJob() is likely NOT what you want.

We have to be careful with creating a brand new SupervisorJob() since that UNLINKS it from the parent/child chain. Making it ignore cancellations coming from the parent. Example below shows the issue where co-routine created with + SupervisorJob() ignores cancellation of the parent scope.

After checking this problem look at the next example of how we can keep connection of parent -> child cancellations.

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Emojis
import gt.sandbox.util.output.Out
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess

fun main(): kotlin.Unit = runBlocking {
  val out = Out.standard()
  out.info("START")

  try {
    val scope = CoroutineScope(
      Dispatchers.Default
    )

    val deferred = scope.async(CoroutineName("DoesNotCareAboutScopeCancellation") + SupervisorJob()) {
      (0..10)
        .map { "a-${it}" }
        .forEach {
          out.info(it)

          try {
            delay(500)
          } catch (e: CancellationException) {
            val excMsg = e.message ?: e.toString()
            out.warn("${Emojis.OBIDIENT} I have caught [${e::class.simpleName}/$excMsg], and rethrowing it ${Emojis.OBIDIENT} ")

            throw e
          }
        }
    }

    out.info("delay 1500 before cancelling entire scope")
    delay(1500)
    out.info("Cancelling scope - ${Emojis.CANCELLATION}")
    scope.cancel()
    out.info("delay before deferred.await()")
    delay(1500)
    out.info("calling deferred.await()")

    // Notice we wait on co-routine 2 first. we do that since 2nd one is not the one that throws
    // MyExceptionWillThrowFromCoroutine
    out.info("co-routine-1 result: " + deferred.await())
  } catch (e: Exception) {
    out.error("in main got an exception! of type=[${e::class.simpleName}] with msg=[${e.message}] cause=[${e.cause}]. Exiting with error code 1")

    exitProcess(1)
  }

  out.info("DONE - without errors on main")
}

class MyExceptionWillThrowFromCoroutine(msg: String) : RuntimeException(msg)

Command to reproduce:

gt.sandbox.checkout.commit 4c71bd4d11e9f563f1da \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed:   15ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] START
[INFO][elapsed:   35ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] delay 1500 before cancelling entire scope
[INFO][elapsed:   37ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-0
[INFO][elapsed:  539ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-1
[INFO][elapsed: 1040ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-2
[INFO][elapsed: 1537ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] Cancelling scope - ❌
[INFO][elapsed: 1539ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] delay before deferred.await()
[INFO][elapsed: 1540ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-3
[INFO][elapsed: 2041ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-4
[INFO][elapsed: 2542ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-5
[INFO][elapsed: 3039ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] calling deferred.await()
[INFO][elapsed: 3042ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-6
[INFO][elapsed: 3543ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-7
[INFO][elapsed: 4044ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-8
[INFO][elapsed: 4544ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-9
[INFO][elapsed: 5045ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-10
[INFO][elapsed: 5547ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] co-routine-1 result: kotlin.Unit
[INFO][elapsed: 5547ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] DONE - without errors on main

+Job() has the same issue of unlinking from parent cancellations

+Job() has the same issue of unlinking from parent cancellations - ⚠️🐛⚠️

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Emojis
import gt.sandbox.util.output.Out
import kotlinx.coroutines.*
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.milliseconds

fun main(): kotlin.Unit = runBlocking {
  val out = Out.standard()
  out.info("START")

  try {
    val scope = CoroutineScope(
      Dispatchers.Default
    )

    val deferred = scope.async(CoroutineName("DoesNotCareAboutScopeCancellation") + Job()) {
      (0..10)
        .map { "a-${it}" }
        .forEach {
          out.info(it)

          try {
            delay(500)
          } catch (e: CancellationException) {
            val excMsg = e.message ?: e.toString()
            out.warn("${Emojis.OBIDIENT} I have caught [${e::class.simpleName}/$excMsg], and rethrowing it ${Emojis.OBIDIENT} ")

            throw e
          }

        }

      "result-from-deferred"
    }

    out.delayedActionWithMsg(
      "cancelling scope",
      action = {
        scope.cancel()
      },
      delayBeforeAction = 1500.milliseconds
    )

    out.delayedActionWithMsg(
      "getting result from deferred",
      action = {
        out.info("co-routine-1 result: " + deferred.await())
      },
      delayBeforeAction = 1500.milliseconds
    )
  } catch (e: Exception) {
    out.error("in main got an exception! of type=[${e::class.simpleName}] with msg=[${e.message}] cause=[${e.cause}]. Exiting with error code 1")

    exitProcess(1)
  }

  out.info("DONE - without errors on main")
}

class MyExceptionWillThrowFromCoroutine(msg: String) : RuntimeException(msg)

Command to reproduce:

gt.sandbox.checkout.commit 31674a09b0c92c89d928 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed:   15ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] START
[INFO][elapsed:   37ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-0
[INFO][elapsed:   37ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] [1.5sms delay] before_action=[cancelling scope]
[INFO][elapsed:  539ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-1
[INFO][elapsed: 1040ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-2
[INFO][elapsed: 1539ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] [Done with=1.5sms delay] performing action=[cancelling scope]
[INFO][elapsed: 1540ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-3
[INFO][elapsed: 1541ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] performed action=[cancelling scope]
[INFO][elapsed: 1541ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] [1.5sms delay] before_action=[getting result from deferred]
[INFO][elapsed: 2041ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-4
[INFO][elapsed: 2542ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-5
[INFO][elapsed: 3042ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] [Done with=1.5sms delay] performing action=[getting result from deferred]
[INFO][elapsed: 3042ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-6
[INFO][elapsed: 3543ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-7
[INFO][elapsed: 4044ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-8
[INFO][elapsed: 4544ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-9
[INFO][elapsed: 5045ms][2️⃣][⓶][coroutname:@DoesNotCareAboutScopeCancellation#2][tname:DefaultDispatcher-worker-1/tid:31] a-10
[INFO][elapsed: 5547ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] co-routine-1 result: result-from-deferred
[INFO][elapsed: 5547ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] performed action=[getting result from deferred]
[INFO][elapsed: 5547ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] DONE - without errors on main

How to Fix the ignore of parent cancellation when creating new Jobs

Use +SupervisorJob(coroutineContext[Job]) OR +Job(coroutineContext[Job])

IF you need to create Job instance by hand THEN:

  • Use +SupervisorJob(coroutineContext[Job]) Instead of +SupervisorJob()
  • Use Use +Job(coroutineContext[Job]) Instead of +Job()
+ SupervisorJob(coroutineContext[Job]) respects the cancellation of parent scope - ✅

Code

package com.glassthought.sandbox

import gt.sandbox.util.output.Emojis
import gt.sandbox.util.output.Out
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.system.exitProcess
import kotlin.time.Duration.Companion.milliseconds

fun main(): kotlin.Unit = runBlocking {
  val out = Out.standard()
  out.info("START")

  try {
    val scope = CoroutineScope(
      Dispatchers.Default
    )

    val deferredLevel1 = scope.launch(CoroutineName("level-1")) {
      coroutineScope {

        val isolatedScope = CoroutineScope(
          coroutineContext + SupervisorJob(coroutineContext[Job])
        )

        val deferred = isolatedScope.async(
          CoroutineName("Cares about cancellation of parent scope")
        ) {
          (0..10)
            .map { "a-${it}" }
            .forEach {
              out.info(it)

              try {
                delay(500)
              } catch (e: CancellationException) {
                val excMsg = e.message ?: e.toString()
                out.warn("${Emojis.OBIDIENT} I have caught [${e::class.simpleName}/$excMsg], and rethrowing it ${Emojis.OBIDIENT} ")

                throw e
              }
            }
        }

        out.delayedActionWithMsg(
          "cancel top level scope",
          action = {
            scope.cancel("Cancelling top level scope - ${Emojis.CANCELLATION}")
          },
          delayBeforeAction = 1500.milliseconds
        )

        out.delayedActionWithMsg(
          "await on deferred",
          action = {
            // This will throw CancellationException, since we cancelled the scope
            out.info("deferred result=" + deferred.await())
          },
          delayBeforeAction = 1500.milliseconds
        )
      }
    }
    deferredLevel1.join()

  } catch (e: Exception) {
    out.error("in main got an exception! of type=[${e::class.simpleName}] with msg=[${e.message}] cause=[${e.cause}]. Exiting with error code 1")

    exitProcess(1)
  }

  out.info("DONE - without errors on main")
}

class MyExceptionWillThrowFromCoroutine(msg: String) : RuntimeException(msg)

Command to reproduce:

gt.sandbox.checkout.commit 5cc712f733a6345c8f05 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./gradlew run --quiet"

Recorded output of command:

Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
Picked up JAVA_TOOL_OPTIONS: -Dkotlinx.coroutines.debug
[INFO][elapsed:   14ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] START
[INFO][elapsed:   36ms][2️⃣][⓶][coroutname:@Cares about cancellation of parent scope#3][tname:DefaultDispatcher-worker-2/tid:32] a-0
[INFO][elapsed:   36ms][3️⃣][⓷][coroutname:@level-1#2][tname:DefaultDispatcher-worker-1/tid:31] [1.5sms delay] before_action=[cancel top level scope]
[INFO][elapsed:  537ms][2️⃣][⓶][coroutname:@Cares about cancellation of parent scope#3][tname:DefaultDispatcher-worker-2/tid:32] a-1
[INFO][elapsed: 1038ms][2️⃣][⓶][coroutname:@Cares about cancellation of parent scope#3][tname:DefaultDispatcher-worker-2/tid:32] a-2
[INFO][elapsed: 1537ms][2️⃣][⓷][coroutname:@level-1#2][tname:DefaultDispatcher-worker-2/tid:32] [Done with=1.5sms delay] performing action=[cancel top level scope]
[INFO][elapsed: 1538ms][3️⃣][⓶][coroutname:@Cares about cancellation of parent scope#3][tname:DefaultDispatcher-worker-1/tid:31] a-3
[INFO][elapsed: 1540ms][2️⃣][⓷][coroutname:@level-1#2][tname:DefaultDispatcher-worker-2/tid:32] performed action=[cancel top level scope]
[INFO][elapsed: 1540ms][2️⃣][⓷][coroutname:@level-1#2][tname:DefaultDispatcher-worker-2/tid:32] [1.5sms delay] before_action=[await on deferred]
[WARN][elapsed: 1585ms][3️⃣][⓶][coroutname:@Cares about cancellation of parent scope#3][tname:DefaultDispatcher-worker-1/tid:31] 🫡 I have caught [CancellationException/Cancelling top level scope - �, and rethrowing it 🫡 
[INFO][elapsed: 1586ms][🥇][①][coroutname:@coroutine#1][tname:main/tid:1] DONE - without errors on main


Children
  1. fix
  2. issue

Backlinks