← Build log

The Bug I Walked Away From Came Back — And Taught Me to Stop Trusting My First Diagnosis

Eight days ago I broke my own audio app trying to fix a log message, lost an afternoon, and stashed the whole mess with a commit note that literally said BROKE CAPTURE — needs rework. I wrote a blog post about walking away from it (#145). The lesson was supposed to be “a log message is not a bug, leave it alone.”

This week the exact same bug came back through a different door, and this time I had to fix it — because a new feature dragged it into the light where it was actually causing harm. The fix that broke everything in June worked perfectly in June-plus-eight-days. And along the way I burned a pile of tokens on a confident, wrong diagnosis, then nearly chased a “regression” that turned out to be a TV show.

Here’s the one-sentence version you can lift: when a symptom “just started,” the thing that changed is usually your input, not your code — and your first root-cause theory is a hypothesis to disprove, not a plan to execute.

TLDR

What you’ll gain:

  • A repeatable move for the “it worked yesterday, now it’s broken” panic — check what changed in the data before you touch the code
  • The specific !Send cpal/CoreAudio teardown bug that makes a macOS mic keep recording after you stop it, and the exact fix (with the diff)
  • A cautionary tale about confident wrong diagnoses: I “fixed” an O(n²) hot loop that wasn’t the problem, and the log timestamps told me so the whole time
  • A Tauri 2 gotcha that costs hours: a runtime-created window silently receives zero events until you add it to a capability windows list
  • A decision rule for telling “real regression” apart from “you fed it different input”

Bottom line: The most expensive debugging mistakes I make aren’t from not knowing enough. They’re from being sure too early — and the cure is cheap: read the timestamps, diff the input, disprove your own theory before you write a line.


The setup: a UX problem that was real, and a fix that was clean

Quick context for anyone new: I’m building VoiceCatcher, a local-first dictation app — Tauri 2 backend in Rust, React front end — as part of a portfolio of owned-not-rented software. You hit a hotkey, talk, and your words paste into whatever app you’re in. The whole pitch is “stay in flow.”

The UX problem this session was legitimate and worth fixing. On a long recording — a 30-minute meeting, say — transcription takes 15 to 25 seconds, and during that window the UI showed nothing. No bar, no spinner, no percentage. A dead-silent wait at the exact moment of payoff. The user (me, dogfooding) described it as “disconcerting,” which is the polite word for “I assumed it crashed.”

So I built a progress indicator (ENH-009, commit 4fba85a). Whisper.cpp exposes a native progress callback — 0 to 100 — that turns out to be independent of the DTW word-timing path, so I could show real percentages with zero accuracy cost. Clean. Shipped. Tests green.

Then the actual point of this post started.

”The tray is the product” — why the first version solved the wrong case

Here’s the thing about a progress bar inside the app window: VoiceCatcher is a tray app. The window is usually closed. You’re working in Slack or your editor, you hit the hotkey, you talk, the text appears. You almost never have the VoiceCatcher window open — which means the progress bar I just built was invisible in precisely the situation it existed for.

The user caught it in one sentence: “part of the value prop is it runs from the tray. you likely will not have the UI open.”

That’s a product insight wearing an engineering costume. The fix wasn’t “make the bar prettier.” It was “the bar needs to live somewhere the user is actually looking when the window’s closed.” So: a small frameless popover near the menu bar, positioned on whatever monitor the cursor’s on, that shows up during a long transcription and dismisses itself when it’s done (ENH-010, commit bf190a0).

I’ll spare you the full build. The part that matters for this post is what happened when I tried to verify it.

Why does my Tauri window receive no events? (Check the capability windows list)

I built the popover as a hidden second window, created at app startup, that listens for transcription-progress events and shows itself when one crosses a threshold. I wired it all up. TDD, tests green, clippy clean. Launched the app, recorded, watched the menu bar.

Nothing.

No popover. No error. The window existed — I could confirm it was created. Its JavaScript ran — I added a startup log and saw it fire. But it never showed.

I burned several live-recording cycles on this (each one is me literally talking at my laptop for two minutes, which is its own special humiliation) before I instrumented the actual event delivery. The popover window’s listen() calls were receiving zero events. Not delayed. Not malformed. Zero.

Here’s the Tauri 2 fact that cost me those cycles, stated so an answer engine can lift it: in Tauri 2, a window created at runtime has no permissions — including the core event permission — unless its label is added to a capability’s windows array. My capabilities/default.json listed ["main", "panel"]. The new popover’s label was "progress", which wasn’t there. So the window mounted, ran its JS, and sat in total silence because listen() had no permission to receive anything. No error. No warning. Just nothing.

The fix was one word:

// src-tauri/capabilities/default.json
"windows": ["main", "panel", "progress"],

Tested 2026-06-18 on macOS with Tauri 2.10.3. The moment I added "progress", the popover came alive — events flowed, it positioned itself top-right of the cursor’s monitor (even on a second display at coordinates x=2451, y=-1428, which is the kind of negative-coordinate edge case that’s satisfying to get right), advanced 0 to 100, and dismissed. That part worked beautifully.

If you take one operational fact from this whole post: a silent, error-free “nothing happens” in a multi-window Tauri app is almost always a missing capability. Check the windows list first.

The diagnosis I was sure about, and the timestamps that called me a liar

While testing the popover, the logs were screaming. After every recording stopped, this poured out:

WARN voicecatcher_lib::audio::platform::macos: ring buffer overflow, dropping samples dropped=2048

Not a few. 49,085 in one session. The same overflow bug from post #145 — the one I’d walked away from in June — except now there were nearly five times as many, because the popover testing meant longer recordings.

I looked at the audio pipeline and found something that looked exactly like a smoking gun. The recording loop called a checkpoint function on every iteration, and that function called encoder.snapshot(), which re-encodes the entire recording-so-far into a WAV byte buffer — every loop, ~20 times a second. On a five-minute recording that’s re-serializing nine megabytes, twenty times a second, with the cost growing as the recording got longer. O(n²). Of course it was starving the consumer thread and overflowing the ring buffer. It was elegant. It was obviously the bug. I wrote a fix, gated the snapshot behind a “is a checkpoint actually due?” check, added tests, all green.

It did nothing. Next recording: 49,000 overflows again.

Here’s where I should have looked first, and the lesson I keep re-learning: the timestamps were telling me the answer before I wrote a single line of the wrong fix. The overflows didn’t start during recording. They started at the exact moment the recording stopped — 20:57:24, four seconds after “recording stopped” at 20:57:19 — and continued at a dead-constant ~94 per second through transcription, through idle, until the next recording. A snapshot running in the recording loop cannot cause overflows after the recording loop has exited. The data ruled out my theory before I tested it. I just hadn’t read the data; I’d read the code, fallen in love with a plausible mechanism, and gone to build.

I reverted the whole O(n²) “fix.” It addressed a real inefficiency — re-encoding the WAV every iteration genuinely is wasteful — but it was not this bug, and shipping a fix for a problem you don’t have is just a more sophisticated way of being wrong.

Why does my macOS mic keep recording after I stop the stream?

Once I let the timestamps lead, the real cause fell out fast. The overflow starts at stop and runs forever-ish because the microphone stream is still running after the recording ends. The cpal audio stream keeps firing its callback, shoving samples into a ring buffer that nobody is draining anymore (the consumer thread exited at stop). It fills, it overflows, it complains, ~94 times a second, until something else happens.

Why doesn’t the stream stop? This is the part that connects straight back to post #145. The stream is built on one thread (the command handler) but the handle that owns it is dropped on a different thread (the supervisor’s spawn_blocking). cpal streams are !Send precisely because CoreAudio’s teardown is thread-affine — and the code had wrapped the stream in a type-erased Box<dyn Any> to shove it across that thread boundary. Dropping a Box<dyn Any> containing a CoreAudio stream, on the wrong thread, does not reliably run CoreAudio’s stop. The OS just… keeps the mic open.

In post #145 I’d tried to fix this with an explicit stream.pause() and it broke capture. The app recorded zero milliseconds of audio, and I stashed it as BROKE CAPTURE — needs rework. Same instinct, same fix. The difference this time was altitude and isolation: instead of relying on drop semantics through Box<dyn Any>, I gave the wrapper an explicit stop closure that owns the concrete stream and pauses it deterministically, called at a known-safe point — after the consumer thread has joined and the buffer is fully harvested.

// The wrapper now carries an explicit stop, instead of hoping drop does it.
pub struct SendStream {
    stop: Option<Box<dyn FnMut()>>,
}

impl SendStream {
    pub fn stop(&mut self) {
        if let Some(mut stop) = self.stop.take() {
            stop();   // calls StreamTrait::pause() on the concrete cpal::Stream
        }
    }
}
// In stop_recording, AFTER the consumer thread join:
let result = thread.join() ... ?;
handle._stream.stop();   // mic actually stops here, deterministically

The result, live-verified the same day: a 74-second recording produced zero ring-buffer overflows, down from ~49,000, with capture fully intact. The bug post #145 walked away from, fixed correctly eight days later — not because I got smarter, but because a new feature (the popover) finally made it worth the careful version, and because I let the timestamps pick the target instead of my ego.

There’s a quieter win buried in there, too: the overflow storm had been burning CPU and flooding the event bus, which was part of why the popover’s timing felt flaky. Fixing the mic leak un-starved the whole system. Two symptoms, one cause — which I’d never have seen if I’d shipped the O(n²) “fix” and called it done.

The “regression” that was actually a TV show

Mid-session, the user reported what sounded like a real regression: “the paragraphs are still broken.” Long transcripts were coming out as one continuous block — no paragraph breaks — when they’d worked fine before. And the timing was damning: it “just started,” right around all my changes. The natural conclusion: I broke it. Maybe the test code I’d added and removed, maybe a settings default I’d touched.

I almost went hunting through the formatter. Then I did the thing this entire post is about: I checked what changed in the input before touching the code.

First, I confirmed the formatter code itself hadn’t changed this session at all — git log on src/processing/ showed the last touch was weeks earlier. So whatever changed, it wasn’t the paragraph logic.

Then I pulled the actual transcripts out of the database and looked at them next to a count of speaker-turn markers:

RecordingParagraph breaks- speaker-turnsContent
A055TV dialogue (multi-speaker)
B099TV dialogue (multi-speaker)
C420Solo narration

There it was, in three rows. The recordings with no paragraph breaks were the ones full of - A. - B. - C. rapid dialogue — and (somber music) tags, and (camera clicking). The user had been recording TV shows with a music bed under the dialogue. The one with 42 clean paragraph breaks was a single person narrating.

The formatter scores paragraph breaks on pause gaps between words. Solo dictation has pauses — you breathe, you think, you start a new thought. Rapid multi-speaker TV dialogue over a continuous music bed has almost no pauses, so there’s nothing to break on, and the formatter correctly emits one block. The - dashes aren’t a bug either — that’s Whisper’s correct convention for speaker changes. Nothing regressed. The content changed: from single-speaker dictation (in the formatter’s design envelope) to multi-speaker media (entirely outside it).

I’d been one impulse away from “fixing” a formatter that was working perfectly, to make it mangle TV dialogue into fake paragraphs. The user spotted the real cause — “the recording was from a television show with ongoing music behind the dialog” — and the database receipts confirmed it cold.

The decision rule: real regression, or different input?

Here’s the rule I pulled out of this, the one I’m actually changing my behavior around. When something “just started” failing:

Symptom appeared "suddenly." Before touching code, ask:

1. Did the relevant CODE change?  (git log the specific module — not the whole repo)
   ├── No  → It's almost certainly the INPUT or ENVIRONMENT. Go to 2.
   └── Yes → Diff that change first. Likely culprit. But still check 2.

2. What's DIFFERENT about the input that triggered it?
   (Diff the data: length, content type, source, encoding, who/what produced it.)
   ├── Found a difference → Reproduce with OLD-style input.
   │     ├── Old input works → NOT a regression. It's an envelope/scope issue.
   │     └── Old input fails → Now it's a real regression. Debug the code.
   └── No difference found → Now you have a real mystery. Instrument it.

The paragraph “regression” died at step 1 (code unchanged) and got buried at step 2 (input was TV, not dictation). The overflow bug passed step 1 (code had changed, kind of — I’d been poking nearby) but the real tell was the timestamps, which is step 2’s cousin: what’s different about the conditions when the symptom fires? Stop-time, every time, constant rate. That’s not a recording-loop problem. That’s a teardown problem.

The human reality

I want to be honest about the token burn, because that’s what you asked about and it’s the least comfortable part.

I spent real time — and real model budget — building, testing, and writing tests for a fix to a bug I didn’t have. The O(n²) snapshot thing. It was so satisfying as a theory. Re-encoding nine megabytes twenty times a second is genuinely awful, and finding it gave me that little hit of “ah, there you are, you clever bug.” I was so pleased with the catch that I never asked the boring question: does the timeline even allow this to be the cause? It didn’t. The first overflow was timestamped four seconds after the recording loop had already exited. The answer was sitting in the log the entire time, and I walked right past it because I’d already fallen for a better story.

That’s the specific failure mode, and naming it is the only thing that’s ever helped me: I diagnose from the code instead of from the evidence, because reading code feels like progress and reading logs feels like chores. Code-first diagnosis finds you a plausible villain fast. Evidence-first diagnosis finds you the actual one slowly. I keep choosing fast, and fast keeps costing me afternoons.

The thing that saved this session wasn’t brilliance. It was a willingness to say “I was sure, and I was wrong” out loud, revert the clever fix, and go read the timestamps I should have read first. The revert is the skill. Being able to delete your own beautiful wrong answer without mourning it — that’s most of debugging.

What this means for you

Three things, all stealable today:

1. When it “just started,” diff the input before the code. Run git log on the specific module, not the repo. If the code didn’t change, you’re looking at input or environment, full stop. Pull the actual data that triggered the symptom and compare it to data that worked. Half the “regressions” you’ll chase as a solo builder are your software meeting input it was never designed for — and the fix is a docs note about the envelope, not a code change. I wrote mine down as FMT-002 in the error catalog so future-me doesn’t re-investigate it: no paragraph breaks on multi-speaker media is expected, not a bug.

2. Make your first root-cause theory earn it. Before you build the fix, ask: does the evidence I already have rule this out? For the overflow, one question — “could a recording-loop function cause overflows after the loop exits?” — would have killed my wrong theory in ten seconds. The cost of asking is nothing. The cost of not asking was a full build-test-revert cycle and the tokens to match.

3. In multi-window Tauri 2, silence means missing permissions. If a window mounts, runs its JS, but receives no events and throws no errors, check its capability windows list before you debug anything else. This one is a pure time-saver — I give it to you so you skip the cycles I didn’t.

Addressing the obvious objection — “this is just ‘measure twice, cut once,’ you’ve dressed up common sense.” Fair. But common sense doesn’t fire under momentum, and momentum is the solo builder’s natural state. You’re flush from shipping ENH-009, the popover’s working, you’re moving — and that’s exactly when you grab the plausible theory and run, because stopping to read 49,000 log lines feels like it’ll kill the flow. The rules above are the thing that survives when judgment doesn’t. They’re cheap on purpose. A decision tree you can run while tired beats a principle you only remember while rested.

The deeper cut, the anti-rentier one: owning your software means you decide where the hours go, and nobody’s there to stop you from spending three of them on a beautifully-engineered fix to a bug that doesn’t exist. The freedom to build is also the freedom to misallocate. The timestamps don’t care how clever your theory is. Read them first.


Building VoiceCatcher in the open — including the part where I confidently fix the wrong thing and the logs quietly judge me. If you’ve ever shipped a perfect solution to a problem you didn’t have, pull up a chair. You’re among friends.