Debugging a Concurrency Bug With AI as a Rubber Duck That Reads Code
Race conditions are hard because you can't hold every interleaving in your head. A model can enumerate them without getting bored or attached to a theory — provided you ask for orderings rather than causes, and demand a reproducible trigger for every hypothesis.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — The difficulty of a race condition isn't intelligence, it's combinatorics: you can't
hold every interleaving in your head, and you get attached to the first theory that fits. A model is
good at enumerating orderings mechanically. Give it the threading context it can't infer, ask for
interleavings rather than causes, require an exact trigger sequence for every hypothesis, then
convert the surviving one into a runTest that fails deterministically.
Why this class of bug is different
Most bugs are found by reading code carefully. Race conditions resist that, because the code is correct on every path you read — it's only the combination of two paths, in one particular order, on two particular threads, that breaks.
Two human weaknesses make it worse. We're bad at exhaustively enumerating orderings, and we anchor on the first plausible explanation and then look for confirmation. A model has neither habit, which is the actual reason it helps here.
Step 1: give it the state, not the symptom
"It crashes sometimes" produces generic advice about Mutex. What a model needs is the shape of the
state and, critically, which thread or dispatcher each entry point runs on — something it cannot
infer from the source alone.
Here's a ViewModel.
refresh()is called from the main thread by a pull-to-refresh gesture.onItemToggled()is called from the main thread. The repository'sstream()emits onDispatchers.IO._stateis aMutableStateFlow. The bug: occasionally a toggled item reverts to its previous value about a second later.
That paragraph does more work than any amount of prompt engineering. The symptom, the entry points, the dispatchers, and the timing.
Step 2: ask for interleavings, not causes
The prompt that changes the output:
Don't tell me the cause yet. List the possible orderings of these three operations that could leave
_stateinconsistent. For each ordering, walk through it step by step showing the value of_stateafter each step.
This turns an open question into a mechanical search, and produces something you can review rather than believe. Most listed orderings will be impossible in your architecture — and dismissing them is fast, because you can see the reasoning.
Here's the shape of finding it usually surfaces:
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow(FeedUiState())
private var job: Job? = null
fun refresh() {
job = viewModelScope.launch { // old job not cancelled
repo.stream().collect { _state.value = FeedUiState(it) }
}
}
fun onItemToggled(id: String) {
_state.update { it.toggle(id) } // overwritten by a stale collect
}
}
The interleaving: refresh() twice leaves two live collectors; the user toggles an item; the older
collector emits a second later and overwrites the toggle with server data. It reproduces only when the
network is slow enough for the collectors to overlap — hence "once a week".
Step 3: make it prove the trigger
The rule that separates a lead from noise:
For each hypothesis, give the exact sequence of calls and timings that produces it. If you can't produce one, mark it speculative.
"There might be a race on _state" is unfalsifiable and unhelpful. "If refresh() is called twice
and the first collector emits after onItemToggled(), the toggle is lost" is a test you can write in
five minutes.
This also exposes the failure mode you must plan for: models confidently invent races that cannot happen in your code. Requiring a concrete sequence makes the invented ones fall over immediately, because the sequence references something that doesn't exist.
Step 4: make it deterministic
A race you can reproduce on demand isn't a race any more — it's an ordinary failing test:
@Test
fun `a toggle is not overwritten by a stale collector`() = runTest {
val vm = FeedViewModel(repo)
vm.refresh()
vm.refresh() // second collector
repo.emit(serverItems) // first collector still alive
vm.onItemToggled("item-1")
repo.emit(serverItems) // stale emission arrives
advanceUntilIdle()
assertTrue(vm.state.value.item("item-1").isToggled)
}
runTest and virtual time let you order events precisely rather than hoping a delay is long enough.
Getting to this test is the entire goal of the previous three steps — everything before it is a search
for the sequence to encode here.
What it's genuinely good and bad at
Good: enumerating orderings, spotting the missing cancel(), noticing that a catch (e: Exception)
swallows CancellationException, listing every writer of a shared field. Mechanical, exhaustive work.
Bad: knowing what your architecture guarantees. It doesn't know that a repository is single-threaded by construction, or that a screen can't be open twice, unless you say so. It will produce a plausible race that your DI graph makes impossible.
The rule
Every hypothesis is a lead, never a conclusion. The value isn't that a model solves the bug — it's that it generates candidate interleavings faster than you can, without falling in love with any of them. You still supply the architectural knowledge, and you still write the test that proves it.
That's the same division as any good AI-assisted work: mechanical breadth from the machine, judgement from you.