With UnconfinedTestDispatcher, what exactly does 'runs eagerly to the first suspension point' mean, and where does execution stop in a coroutine that does some work then calls delay()?
answer
- Eager prefix runs synchronously on the launching thread
- Stops at the first call that actually suspends (e.g. delay)
- Non-suspending suspend calls don't stop the eager run
- After first suspension, virtual clock takes over
- Standard would queue the whole body instead
basics
~20 sWhen you launch a coroutine on an UnconfinedTestDispatcher, its code starts running right away on the same thread and keeps going until it hits the first point where it actually suspends — like a delay. Then it pauses there.
solid answer
~50 sUnconfinedTestDispatcher starts the launched coroutine synchronously on the launching thread and runs the body until the first *real* suspension — the first suspend call that actually yields control rather than returning immediately. For a body that runs `println("a"); delay(100); println("b")`, the eager run executes up to and including reaching `delay(100)`, prints "a", then suspends; "b" runs only after virtual time advances past 100ms. Note: a suspend call that completes without suspending (e.g. an already-completed value, or a fast path) does not stop eager execution — only an actual suspension does. Resumption after that first suspension is scheduled on the shared TestCoroutineScheduler, so subsequent steps still obey the virtual clock and require advancing time. This is why Unconfined is convenient for state-machine-style code where you want the initial synchronous portion to have run before your first assertion.
code
kotlin · 12 lines@Test
fun stopsAtFirstSuspension() = runTest {
val log = mutableListOf<String>()
launch(UnconfinedTestDispatcher(testScheduler)) {
log += "start" // eager
yield() // a real suspension on a test dispatcher
log += "after"
}
assertEquals(listOf("start"), log) // only the eager prefix ran
runCurrent()
assertEquals(listOf("start", "after"), log)
}go deeper
Knows the body starts immediately and stops at delay.
Can trace exactly which statements run before vs after advancing, and identifies delay as the suspension point.
Distinguishes syntactic suspend calls from actual suspensions, and explains the non-suspending fast-path case.
Reasons about how eager-prefix semantics interact with shared scheduler resumption and test determinism guarantees.
## 'Suspension point' defined A **suspension point** is any place a `suspend` function can give up the thread and return control to the caller, to be *resumed* later. The Kotlin compiler turns `suspend` functions into state machines; a suspension point is a state boundary. Crucially, a suspend call only *actually suspends* if it cannot complete immediately. `delay(100)` always suspends (it schedules a resume in the future). But a suspend function that returns a value synchronously (a fast path / already-available result) returns `COROUTINE_SUSPENDED`'s opposite — it does **not** suspend, so eager execution keeps going. ## Eager run on UnconfinedTestDispatcher When you `launch {}` on an `UnconfinedTestDispatcher`, dispatch is *unconfined*: the body begins executing **immediately and synchronously on the current thread**, not after a round-trip through the scheduler queue. It runs forward until the **first real suspension**, then control returns to your test code. ```kotlin @Test fun eager() = runTest { val log = mutableListOf<String>() launch(UnconfinedTestDispatcher(testScheduler)) { log += "a" // runs eagerly delay(100) // FIRST real suspension -> stop here log += "b" // only after time advances past 100ms } // At this line: assertEquals(listOf("a"), log) // "a" already there, "b" not yet advanceUntilIdle() assertEquals(listOf("a", "b"), log) } ``` ## Where it stops, step by step 1. `log += "a"` — no suspension, runs. 2. `delay(100)` — genuinely suspends; schedules a resume at virtual time 100. Eager run **stops** here. 3. Control returns to the test; `log` holds only `"a"`. 4. `advanceUntilIdle()` (or `advanceTimeBy(100)`) moves the virtual clock; the continuation resumes and `log += "b"` runs. ## Contrast with StandardTestDispatcher With Standard, step 1 wouldn't even happen until you advance time — the whole body is queued. With Unconfined, the *prefix* before the first suspension is already done synchronously. ## Gotcha: non-suspending suspend calls If the first suspend call in the body happens to **not** suspend (returns immediately), eager execution continues past it to the next real suspension. So 'first suspension point' is dynamic, not just syntactic. ## After the first suspension Resumption is driven by the **shared TestCoroutineScheduler** and the virtual clock — Unconfined does not magically run everything; only the initial synchronous prefix is eager.
- Does a suspend function that returns immediately stop the eager run?No. Only an actual suspension stops it; a synchronous fast-path suspend call is passed through and execution continues.
- After the first suspension, does Unconfined keep running eagerly?No. Resumption is scheduled on the shared scheduler and obeys virtual time; you must advance the clock.
Like a runner who sprints off the line until the first red light (real suspension), then waits for you to turn the light green by advancing the clock.
saying these in an interview costs you the question
- Saying Unconfined runs the entire coroutine to completion eagerly
- Claiming every suspend keyword is a suspension point regardless of behavior
- Thinking delay() returns immediately under Unconfined
- Ignoring that resumption still needs the virtual clock
- Assuming the eager prefix runs on a background thread