5 min read

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.

AIKotlinCoroutinesDebuggingAndroid
HR

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's stream() emits on Dispatchers.IO. _state is a MutableStateFlow. 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 _state inconsistent. For each ordering, walk through it step by step showing the value of _state after 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.

Keep reading