Structured Concurrency & Cancellation Quiz
KOTLIN › Coroutines
Child A throws an IllegalStateException. What happens to child B?
- B keeps running to completion, unaffected by A's exception
- B is cancelled because the scope fails and cancels siblings
- B is merely paused until A is restarted and finishes cleanly
- The exception is swallowed, so B and the scope keep going
Answer: B is cancelled because the scope fails and cancels siblings
coroutineScope uses a regular Job, so an uncaught child failure cancels the scope and all sibling coroutines, then rethrows from the scope.
You want each child coroutine to fail independently without cancelling its siblings. Which construct do you use?
- withContext(Dispatchers.Default) for switching dispatchers
- coroutineScope for structured child completion and cleanup
- supervisorScope or a SupervisorJob-backed scope
- runBlocking when you need to bridge suspend code to a caller
Answer: supervisorScope or a SupervisorJob-backed scope
supervisorScope / SupervisorJob make cancellation one-directional, so a child's failure does not cancel its parent or siblings.
A coroutine runs a tight CPU loop with no suspension points. You call job.cancel(). What happens?
- The loop keeps running, as cancellation is cooperative and unchecked
- The loop stops instantly at the next line of code after cancel()
- A CancellationException is thrown immediately in the middle of work
- The runtime force-kills the thread, so the loop cannot continue running
Answer: The loop keeps running, as cancellation is cooperative and unchecked
Cancellation only sets a flag; code that never suspends or checks isActive/ensureActive()/yield() keeps running despite cancel().
Which statement about ensureActive() is correct?
- It returns a Boolean indicating whether the job is currently active
- It suspends the current coroutine briefly to let other coroutines run
- It throws CancellationException at once if the job is cancelled
- It permanently disables cancellation for the enclosing block of code
Answer: It throws CancellationException at once if the job is cancelled
ensureActive() is fail-fast: it throws CancellationException right away when cancelled, unlike isActive (a boolean) or yield (which also suspends).
In a launch block you write catch (e: Exception) { log(e) } around suspending work and do not rethrow. What is the main bug?
- It means exceptions can no longer be logged anywhere in the block
- It catches CancellationException, so cancellation is ignored
- It forces the coroutine to run on the main thread by default
- It turns the coroutine into an async task that returns Deferred
Answer: It catches CancellationException, so cancellation is ignored
Catching the broad Exception also catches CancellationException; without rethrowing it, the coroutine ignores cancellation. Catch specific types like IOException instead.
Where does a CoroutineExceptionHandler have NO effect?
- On a root launch coroutine whose scope context directly contains the handler
- As a generic handler for any uncaught failure in a launched root coroutine
- On a child launch inside supervisorScope that installs the handler itself
- On an async coroutine, where the exception is kept in the Deferred
Answer: On an async coroutine, where the exception is kept in the Deferred
async never routes to a CoroutineExceptionHandler; its exception is held in the Deferred and rethrown when you call await(). The handler works for uncaught launch failures at the root.
You must run resource-cleanup code in a finally block, but it contains a suspend call that would be skipped because the coroutine is already cancelled. What do you use?
- withContext(NonCancellable) { ... } around the cleanup code
- GlobalScope.launch { ... } to run the cleanup in a new coroutine
- yield() before the cleanup to give cancellation a chance to clear
- withTimeout(0) { ... } to force the cleanup to finish immediately
Answer: withContext(NonCancellable) { ... } around the cleanup code
withContext(NonCancellable) lets suspending cleanup run to completion even in an already-cancelled coroutine; it is intended for finally blocks, not for launching new work.
Inside a coroutineScope you write async { repo.load() } but never call await() on the resulting Deferred, and load() throws. When does the failure surface?
- It is silently swallowed forever, because await() is simply never called here
- It is routed straight to a CoroutineExceptionHandler that the scope installed
- It propagates to the parent at once and cancels the scope; non-root async reports up
- It is held in the Deferred and thrown only if some later code eventually calls await()
Answer: It propagates to the parent at once and cancels the scope; non-root async reports up
async only defers its exception until await() when it is a root coroutine; as a child of coroutineScope an uncaught failure cancels the parent immediately regardless of await().
A ViewModel launches network work in viewModelScope. The user navigates away and the ViewModel is cleared. What happens to that in-flight coroutine?
- It is cancelled automatically when viewModelScope is cleared.
- It keeps running and must be cancelled manually in onCleared().
- It is moved to GlobalScope so it survives the ViewModel cleanup.
- It is paused and later resumed in a new ViewModel of the same type.
Answer: It is cancelled automatically when viewModelScope is cleared.
viewModelScope is tied to the ViewModel lifecycle and is cancelled in onCleared(), so its child coroutines are cancelled automatically without manual cleanup.
The block has not finished after 2 seconds. What happens?
- It returns null and lets the block keep running in the background.
- It throws TimeoutCancellationException and cancels the coroutine.
- It blocks the thread until completion and ignores the timeout limit.
- It returns any partial result the block may have computed so far.
Answer: It throws TimeoutCancellationException and cancels the coroutine.
withTimeout throws TimeoutCancellationException (a CancellationException subclass) and cancels the block; use withTimeoutOrNull if you want a null result instead of an exception.
Why is launching app work in GlobalScope discouraged in Android code?
- It always runs its coroutines on the main thread, blocking the entire UI
- It cannot call suspend functions from within its coroutine body
- It automatically cancels its work whenever any Activity gets destroyed
- It has no lifecycle, so its coroutines aren't cancelled by any parent
Answer: It has no lifecycle, so its coroutines aren't cancelled by any parent
GlobalScope has no parent Job or lifecycle, so it breaks structured concurrency: work is not cancelled when the caller goes away, risking leaks and orphaned coroutines.
When does the coroutineScope call return?
- Only after the launched child finishes; the scope awaits its children.
- Immediately, because launch is fire-and-forget and the scope ignores it.
- After a fixed 100ms grace period, no matter when the child actually ends.
- Never, because launch detaches the child and the scope cannot finish.
Answer: Only after the launched child finishes; the scope awaits its children.
Structured concurrency guarantees a scope suspends until every child (including fire-and-forget launch) completes, so coroutineScope returns only after the child finishes.
What does supervisorScope's failure isolation actually apply to?
- Every coroutine anywhere in its tree, even many levels below the scope
- Only async children, while launch children are excluded from the failure rules
- Only its direct children; a nested non-supervisor scope still fails normally
- Nothing; it works exactly like coroutineScope with no failure isolation
Answer: Only its direct children; a nested non-supervisor scope still fails normally
supervisorScope only changes propagation for its immediate children; a regular coroutineScope nested inside a child still uses a normal Job, so failures there propagate and fail that child as usual.
Which of these fetches A and B sequentially instead of in parallel?
- val a = async { loadA() }; val b = async { loadB() }; a.await() + b.await()
- coroutineScope { val a = async { loadA() }; val b = async { loadB() }; a.await() + b.await() }
- listOf(async { loadA() }, async { loadB() }).awaitAll()
- val a = async { loadA() }.await(); val b = async { loadB() }.await()
Answer: val a = async { loadA() }.await(); val b = async { loadB() }.await()
Calling await() on the same line that starts each async forces loadA() to finish before loadB() even begins; true parallelism requires starting both async coroutines first, then awaiting.
What is wrong with a repository that creates its own CoroutineScope(Dispatchers.IO) internally?
- Nothing, this is the standard recommended pattern for repositories in Android architecture guidelines
- The scope outlives the ViewModel, leaks coroutines after the screen is destroyed, and cannot be controlled in tests
- It forces all callers to also use Dispatchers.IO, which blocks the main thread and causes ANR errors
- It prevents the repository from ever being used with Dispatchers.Default, limiting it strictly to network operations alone
Answer: The scope outlives the ViewModel, leaks coroutines after the screen is destroyed, and cannot be controlled in tests
A privately-owned scope has no lifecycle tie -- it outlives the ViewModel and leaks work. It also can't be swapped for a TestDispatcher. The fix: accept a dispatcher parameter, use withContext, and let the ViewModel own the scope.
In structured concurrency, what happens to child coroutines when their CoroutineScope is cancelled?
- All child coroutines in that scope are cancelled too
- Children keep running until they finish on their own
- Only coroutines started with async are cancelled
- The children are detached and reparented to GlobalScope
Answer: All child coroutines in that scope are cancelled too
Cancellation propagates down the coroutine hierarchy: cancelling a scope (or its Job) recursively cancels all of its child coroutines.
What distinguishes supervisorScope from coroutineScope?
- In supervisorScope, one child's failure won't cancel its siblings
- supervisorScope forces every child to run only on Dispatchers.IO
- supervisorScope cannot call suspend functions inside it at all
- supervisorScope blocks the caller thread until every child completes
Answer: In supervisorScope, one child's failure won't cancel its siblings
supervisorScope uses a SupervisorJob, so a child's failure is isolated and does not cancel its siblings, unlike coroutineScope where one failure cancels the whole scope.
A coroutine runs a long CPU loop with no suspension points and does not stop when its scope is cancelled. Why?
- Cancellation never actually works for any coroutine running on Dispatchers.Default
- Coroutines simply cannot be cancelled at all once they have already started
- Cancellation is cooperative, so an unchecked loop never sees the cancelled state
- The coroutine must be launched inside GlobalScope to ever become cancellable
Answer: Cancellation is cooperative, so an unchecked loop never sees the cancelled state
Cancellation only sets a flag; cooperative code must hit a suspension point or check isActive/ensureActive/yield to actually react, otherwise a tight loop keeps running.
A coroutine is cancelled while inside a blocking call with no suspension points. What happens?
- Nothing until the call returns: it stays Cancelling and holds its thread
- A CancellationException is thrown into the blocking call more or less immediately
- The dispatcher terminates the thread and replaces it from the pool
- The call is transparently retried on a different thread
Answer: Nothing until the call returns: it stays Cancelling and holds its thread
Cancellation needs a suspension point or a flag check to take effect, and a blocking call offers neither. That is what runInterruptible and invokeOnCancellation exist to bridge.
Which tool converts coroutine cancellation into a thread interrupt for legacy blocking IO?
- runInterruptible { }
- withContext(NonCancellable) { }
- ensureActive()
- yield()
Answer: runInterruptible { }
runInterruptible interrupts the executing thread on cancellation, which blocking Java IO surfaces as an InterruptedException. NonCancellable does the opposite, and the other two only work where the code already checks or suspends.
Why use cancelAndJoin() rather than cancel() before starting a replacement job?
- cancel() returns before the old coroutine has stopped, so both run briefly at once
- cancel() does not affect coroutines that have already suspended
- cancelAndJoin() is the only variant that propagates down to child coroutines as well
- cancel() throws if the job has already completed
Answer: cancel() returns before the old coroutine has stopped, so both run briefly at once
The overlap is what produces duplicated requests in a search box. Joining first guarantees the previous coroutine has finished unwinding before the replacement starts.
What does yield() provide beyond a cancellation check?
- It hands the dispatcher to other coroutines waiting to run
- It guarantees resumption on the same thread
- It converts the cancellation into a catchable non-cancellation exception
- It resets the coroutine job back to the Active state
Answer: It hands the dispatcher to other coroutines waiting to run
yield suspends and reschedules, so it is both a cancellation point and a fairness point. Checking isActive alone keeps you cancellable but can starve other coroutines on a single-threaded dispatcher.