← Writing

The state machine I didn't know I had

10 min read

ZumNum is a small app I built for learning German numbers. It says a number out loud, you type what you heard, it tells you if you got it right.

It had a bug. Tap the play button twice quickly, and the app would jump to the next question before you’d answered the current one.

Small, silly bug. Fixing it took an afternoon and deleted about 1,700 lines of code — including every last trace of a framework I’d built the whole app around.

Here’s what was actually going on.

How the app used to work

The app was built with Combine, Apple’s framework for reactive programming.

The idea behind it is genuinely appealing. Instead of calling functions in order, you describe relationships once: when this value changes, update that one. Then you stop thinking about it. The updates flow through on their own.

My app had a chain of five of these relationships:

speech synthesizer → voice manager → step manager → game engine → view model

Read that as a row of dominoes. iOS tells the speech synthesizer “the number finished playing.” That knocks over the first domino, which knocks over the next, and so on down the line until the screen updates.

Each link was a few lines. Each file was small and did one job. Honestly, it looked like good code — it’s the shape you get from following all the usual advice.

The bug I couldn’t see

Here’s the part I missed for months.

iOS delivers that “speech finished” message on a background thread. Not the main thread, where the rest of the app lives. That’s normal, and it’s documented, and I knew it.

What I didn’t think about: because each domino knocks over the next one immediately, all five links ran on that background thread.

And at the fourth link, my code did this:

  1. Read the list of questions
  2. Change one item in it
  3. Write the list back

Three separate steps. Meanwhile, if you typed an answer, a completely different part of the app changed that same list from the main thread.

Two threads. Same data. Nothing coordinating them. That’s a data race, and it can corrupt the list or lose an update, depending on the exact timing.

The thing that fooled me

Combine does make a promise about safety here. The container I was using guarantees that each individual update is atomic — two updates can’t tangle up with each other halfway through.

I think that promise is exactly what fooled me. It sounds like “this is thread-safe, stop worrying.”

But my code wasn’t doing one update. It was doing three things in a row — read, change, write — and the other thread could slip in between them. Here’s the note I ended up writing when I finally worked it out:

CurrentValueSubject makes each send atomic; it does not make read-then-write atomic.

Combine gave me safe delivery. I heard safe state. Those aren’t the same thing.

Why it stayed hidden

I’ve thought about this more than the fix itself, because the fix was easy once I understood it and the blindness was not.

No single file showed the problem. Open any one of those five files and the threading is not just hard to spot — it isn’t there. There’s no line saying “this runs on a background thread.”

To know which thread the fourth link ran on, you had to trace four relationships backwards through four files to a callback in a fifth, and then already know a fact about how iOS delivers speech callbacks.

The threading was a property of the whole chain. And the chain only really exists while the app is running.

The second problem

There was another thing wrong, and it’s the one that actually caused the skipping.

When speech stops, iOS calls the same method whether it finished naturally or was cut off early. One message for two completely different situations.

My code treated that message as “the number finished playing — move on to the answer.”

So: you tap play a second time. The app cancels the first utterance to start the new one. Cancelling produces the same message as finishing. The app moves on to the next question.

Working exactly as written. Just not as intended.

That one isn’t Combine’s fault. That’s a modelling mistake — I never asked what the message meant. But the chain is what let me get away with never asking, because a stream of events doesn’t force you to define them.

Fix one: just wait for it

The whole speech system now sits behind a single function:

/// Speaks `number`, returning only once playback has genuinely finished.
///
/// - Throws: `CancellationError` if playback was interrupted before it
///   finished — either by cancelling the calling task, or by a later
///   `speak` superseding this one.
func speak(_ number: Int, rate: SpeechRate) async throws

That’s it. You call it, you await it, and it comes back when the sound has actually stopped.

This sounds like a small change. It’s the whole fix.

Before: finishing was a message that arrived from somewhere, on some thread, whether or not anyone was ready for it.

After: finishing is a function returning. It comes back to the exact place that called it, on the thread that called it. There’s nowhere for the background thread to sneak in, because there’s no longer a chain of dominoes running on it.

And cancelling now throws an error instead of returning normally. Finished and cancelled come back on completely different paths. You can’t confuse them by accident, because the compiler won’t let you treat an error as a normal return.

Fix two: write down what can actually happen

The chain of dominoes was a state machine. It had states, and events, and rules about which led to which.

I’d just never written it down. It was scattered across five files as “who subscribes to whom.”

So I wrote it down. Every event that can happen to a round is now one case in a list:

enum GameFlowEvent: Equatable {
    case playTapped
    case answerSubmitted
    case nextTapped

    /// Playback finished on its own.
    case speechFinished

    /// Playback was cancelled before it finished — the player tapped play
    /// again, or left the screen. Deliberately NOT the same as finishing.
    case speechCancelled

    /// Cut short by a phone call, Siri, or an alarm. Recoverable, so the
    /// player keeps their progress.
    case speechInterrupted

    /// The app couldn't produce the audio at all. Not fixable by replaying.
    case speechFailed
}

Look at what happened to that single “speech stopped” message. It became four separate events, because those are four genuinely different situations that deserve four different responses.

Then there’s one plain function that takes the current state and an event, and returns the new state. No side effects, no surprises — just a table you can read and test.

Two nice things fell out of it for free:

Nonsense can’t happen. If an event doesn’t make sense in the current state, nothing happens. The old code had a crash for “move to the next question when there isn’t one.” There’s now no rule for that combination, so it quietly does nothing instead of crashing.

Late messages sort themselves out. If a speech-finished event arrives after the round has already moved on, it lands in a state that has no rule for it, and gets ignored. That used to be the skipping bug.

The fix had a bug of its own

I’d be selling you something if I stopped there.

Waiting for a function to return means something has to make it return. Playback finishing does that. Cancelling does that. Between them, that covers everything the app does.

It doesn’t cover what the phone does.

If a call comes in — or Siri, or an alarm, or you unplug your headphones — iOS takes the audio away and stops playback mid-sentence. The “finished” callback never fires. So nothing ever makes that function return.

The app just sits there. Waiting. Forever.

Not a crash, not a race — the screen simply freezes on “playing” and never moves again. You take a call, come back, and the app is dead.

That’s the trade I made without noticing. A message that never arrives is a missed update. A function that never returns is a hang.

The fix needed one more distinction.

An interruption does have to make the function return. But it can’t pretend to be a cancellation. The app treats cancellation as “the user asked for this, stay put” — so a phone call disguised as a cancellation would freeze the screen in exactly the same way.

So an interruption became its own kind of error, with its own event. The round drops back to the number. Your typed answer is still there. You tap play and carry on.

That’s how one message became four events. I did not see that coming when I drew the first version.

What I’d actually tell you

The easy lesson here is “Combine bad, async/await good.” I don’t think that’s right, and I don’t think it’s the useful takeaway.

Here’s the useful one: if I’d done a straight swap, the bug would have survived.

Picture it. Every chain becomes an async stream. Every subscription becomes a loop. It compiles, the tests pass, and it is the same five dominoes — same hidden threading, same unwritten state machine, same one message meaning two things. Tap play twice and it still skips. I’d have concluded modern Swift concurrency was overrated.

What actually fixed the bug was writing down two things I’d been leaving implicit:

  1. Which thread things run on. Swift concurrency helps enormously here, because the language makes you say it.
  2. What states the app can be in. Swift concurrency does nothing for this at all. That part is just sitting down with a piece of paper.

A chain of reactive operators is a state machine. You just haven’t written it down. And a compiler can’t check something you never wrote.

Combine still had one job I genuinely needed: telling several screens at once that something changed. I kept the idea and dropped the framework — that’s now a few lines using Apple’s @Observable, with no subscriptions to leak and no threading question to get wrong.

The receipts

Two commits, one afternoon. Ninety-four files, roughly 4,500 lines added and 1,700 deleted. Seven types deleted entirely. Combine had been in the app for almost exactly a year.

The commit message lists four bugs that became impossible: the data race, a crash, a locale bug in number parsing, and the double-tap. To be straight with you, only two of those were Combine’s doing — the crash was a fatalError I’d left in release builds, and the locale bug was me misusing NumberFormatter. They got fixed because the rewrite touched the same files, not because reactive programming caused them. It’d be easy to blame all four on the framework, and it wouldn’t be honest.

One last thing I didn’t expect: the rewrite revealed a bug rather than fixing one. Once the speech function could safely run off the main thread, the app started preparing the next number while the current one was still playing. Two threads then began writing to an unprotected list inside one of my test helpers — something I’d marked as safe long ago and never checked.

The note I left in the commit was blunter than I usually am:

Its @unchecked Sendable invariant was a lie.

It had always been a lie. Nothing had ever run it from two threads before, so nothing had ever caught me.