← Build log

The Day I Broke Working Software to Fix a Log Message

There’s a specific kind of stupid that only happens when things are going well. You ship two real fixes, the app works, and then — flush with momentum — you go looking for the next thing to improve. Three hours later the app records zero seconds of audio, your transcription pastes the words “VoiceCatcher clipboard test” into a Slack channel, and you’re staring at a 10,000-line wall of warnings about a bug that was never actually hurting anyone.

That was my Tuesday. Two lessons came out of it, both expensive, both the kind you only learn by doing the dumb thing yourself: stop optimizing things that already work, and stop paying GitHub to lint your markdown on a Mac.

The single rule, stated plainly so you can steal it: a log message is not a bug — the bar for modifying working software is “this produces a wrong, user-visible result,” not “this offends me.” And on the infrastructure side: GitHub Actions bills macOS runners at 10× the Linux rate, so a solo Mac developer who already runs tests locally is usually paying for a cloud build they don’t need.

TLDR

What you’ll gain:

  • A dead-simple rule for when to walk away from a “fix” — before it costs you a working build
  • The exact GitHub Actions mistake that was burning ~125 billable minutes per doc commit, and the two-line config change that stopped it
  • A recovery move (git stash over git reset --hard) that lets you nuke a bad session without losing the work, in case some of it was salvageable
  • An honest failure post-mortem you can hold up next to your own “I’ll just quickly refactor this” moments

Bottom line: The most dangerous moment in solo development isn’t when something’s broken. It’s the hour after you fixed something, when you feel competent. That’s when you reach for the working code.


The setup: two clean wins, then the itch

Quick context for anyone new here: I’m building VoiceCatcher, a local-first dictation app (Tauri 2 + Rust backend, React front end), as part of a portfolio of owned-not-rented software. Earlier that day I’d landed two genuinely good fixes — a recording-lifecycle rewrite and a multi-channel mic bug — and verified both live. The app worked. Auto-record, transcribe, paste. Clean.

Then I noticed two small things while testing.

First, the very first transcription after launching the app took 4 to 11 seconds, because the Whisper model loads lazily on first use. Annoying but fixable: warm the model in the background at startup. I did. It worked — transcription model warmed on launch elapsed_ms=4576. First recording instant now. Good, real, committed.

Second — and this is where it starts — I noticed that after a recording stopped, the logs filled with this:

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

Thousands of them. Ten thousand, five hundred and ninety-eight in one session, to be exact. The audio stream wasn’t shutting down when the recording ended — it kept shoving samples into a buffer nobody was draining, for about five seconds, until the buffer filled and started complaining.

Here’s the thing I want you to sit with, because I didn’t: every single recording still worked. The transcription completed. The text pasted. The overflow was log noise. Cosmetic. A messy exhaust pipe on a car that drove perfectly.

I decided to fix it anyway.

Why “it’s just a log message” is a trap

The overflow had a real root cause, and that’s exactly what made it seductive. On macOS, cpal (the Rust audio library) gives you a Stream handle, and dropping that handle is supposed to stop audio capture. It doesn’t, reliably — you have to explicitly call stream.pause(), which under the hood calls AudioUnit::stop(). The VoiceCatcher code had wrapped the stream in a Box<dyn Any> to make it thread-safe (Send), and in doing so had thrown away the ability to call pause() at all. The stream could only be dropped, never properly stopped.

That’s a legitimately interesting bug. A real architectural wart. The kind of thing that makes an engineer’s hands twitch toward the keyboard.

So I refactored it. Built a proper typed teardown that captured the concrete stream and called pause() before dropping it. Clean design. Passed the full test suite — 177 tests green, clippy clean, TypeScript clean. I was proud of it.

Then I ran it on real hardware and the app captured zero milliseconds of audio.

input warmed, capturing warmup_ms=604 retained_samples=0 reason="ceiling"
pipeline finalized duration_secs=0.0 sample_count=0
transcription failed, returning without transcript error=Audio too short: minimum 100ms required, got 0ms

My “fix” to a harmless log message had broken the entire point of the application. The thing that records voice no longer recorded voice.

And — because the universe has a sense of humor — the broken state was masked by the model warm-up feature I’d added earlier in the same uncommitted pile. The UI just sat there saying “Getting ready…” forever, because the audio that was supposed to flip it into “Recording” never arrived. So now I had two symptoms (no audio, stuck UI) layered on top of each other, and I did what every overconfident builder does next.

I kept patching.

The rabbit hole, documented

I want to be specific about how a smart person digs a hole, because the abstract version (“don’t over-engineer”) never stops anyone. The concrete version might.

Patch 1. The stream teardown didn’t work, so I added an “escape hatch” — a timeout so the UI couldn’t get stuck in “Getting ready” forever. That’s not fixing the bug. That’s installing a smoke detector instead of putting out the fire. It made the symptom less alarming, which is worse, because now the broken thing looks half-okay.

Patch 2. Still no audio. I started instrumenting the audio callback, theorizing about CoreAudio threading, reading cpal’s source in ~/.cargo/registry. Genuinely useful spelunking. Completely the wrong altitude. I was debugging the inside of a change that should never have been attempted.

It was around here that the person I was building with — let’s call him the voice of reason, because that’s what he was — said the sentence that should be tattooed on every solo builder’s monitor:

“you are completely regressing here. now it does not auto stop, and does not record and paste… this feels like a rabbit hole that is getting much worse.”

He was right. I’d traded a working app with an ugly log for a broken app with a clever architecture. That is a terrible trade, and I’d made it one defensible-looking step at a time.

How do you climb out of a rabbit hole without losing the work?

This is the part worth copying. When you realize you’ve dug yourself into a broken state across a dozen files, the instinct is git reset --hard and burn it all down. Don’t. Some of that work was good — the model warm-up was fine, a clipboard fix in there was genuinely important. You want a surgical retreat, not a massacre.

The move:

git stash push -u -m "session WIP: warmup + warming-state + stream-teardown (BROKE CAPTURE — needs rework)"

The -u includes untracked files (a new icon, in my case). Now your working tree snaps back to your last good commit — instantly, totally — and every broken change is sitting safe in stash@{0}, fully recoverable. You’re back on solid ground in one command, and nothing is lost.

Then you cherry-pick the survivors back out, one at a time, verified, committed alone:

git checkout stash@{0} -- src-tauri/src/platform/clipboard.rs src-tauri/src/settings.rs
# gate it, commit it by itself
git checkout stash@{0} -- src-tauri/src/lib.rs
# gate it, verify it live, commit it by itself

Two real fixes recovered (32144b0, the clipboard fix; the model warm-up). The entire warming-state-plus-stream-refactor disaster left parked in the stash for a future, focused, well-rested attempt. The known-good commit — the one where everything worked — never got touched.

The lesson inside the lesson: had I committed each piece the moment it passed live verification, one bad change couldn’t have taken the others hostage. I’d bundled four unverified things into one pile, and when one rotted, it spoiled the rest. Commit early, commit small, commit working.

The bug I should have actually fixed: GitHub was quietly robbing me

Mid-recovery, the voice of reason dropped a different bomb: “I need to understand what GH Actions are being invoked, as I am running out.”

Running out of GitHub Actions minutes. So I went and read the workflow file, which I should have done weeks earlier.

Here’s what ci.yml was doing on every push to main — including pushes that only changed a markdown file:

JobRunnerCost multiplier
Lint & testmacos-latest10×
Build (macOS ARM64)macos-latest10×
Build (macOS Intel)macos-latest10×

GitHub bills macOS runners at ten times the rate of Linux. And I had three of them firing on every commit. The receipts, pulled straight from gh run list:

  • ENH-007 (a backlog markdown edit): 12m 35s
  • Blog post #144 (pure docs): 10m 38s
  • A PRD handoff doc (also pure docs): 13m 4s

A 12-minute macOS run bills as roughly 120 minutes of standard quota. I was paying ~125 equivalent minutes to confirm that a blog post compiled. It does not compile. It’s a blog post.

Why was CI even on GitHub at all?

This is the question the voice of reason asked, and it’s the right one: why not just run it all locally? For a solo developer who runs the full gate (npm run lint, npm run test, cargo clippy -- -D warnings, cargo test) before every commit anyway, what is the cloud actually buying you?

The honest answer: not much, in this specific case. CI earns its keep when you have multiple contributors whose machines you can’t see, or when you need a clean-room build to catch “works on my machine” cruft, or when you ship to platforms you can’t test locally. A solo builder on one Mac, shipping a Mac app, who already gates locally, hits almost none of those.

And there was a structural reason the usual escape hatch didn’t apply. The obvious cost fix is “move the cheap stuff to Linux.” Can’t. VoiceCatcher’s Rust layer is welded to macOS — CGEventTap for the paste monitor, CoreAudio for capture, Tauri’s mac-only dependencies. It won’t even compile on a Linux runner without a pile of system packages, and half of it is #[cfg(target_os = "macos")]-gated anyway. The frontend tests could run on Linux, but splitting the pipeline to save on the cheap half while still paying 10× for the Rust half is rearranging deck chairs.

So we made the call: kill automatic CI entirely.

# ci.yml
on:
  workflow_dispatch:   # was: push + pull_request to main

That’s it. The workflow still exists — you can run it by hand from the Actions tab or with gh workflow run ci.yml before a release, when a clean-room check actually matters. But zero automatic runs. The release workflow (release.yml) was already gated to version tags, so it was never the drain.

The satisfying part: proving it cost nothing

There’s a subtle trap when you push a “disable CI” commit — does the act of pushing it trigger one last expensive run under the old rules? I wanted certainty, not vibes. GitHub evaluates a push’s triggers against the workflow file as of the pushed commit. Since the head commit was now workflow_dispatch-only, pushing it should fire nothing.

git push origin main
sleep 8
gh run list --workflow=ci.yml --limit 2
# most recent run still timestamped from BEFORE the push. Nothing fired. Confirmed.

Stopped the bleed, verified it stopped, committed as 4c241c7. The whole fix was a two-line trigger change and a check that took eight seconds.

The human reality

I’m not going to dress this up. The technically interesting bug (the cpal stream teardown) was the bait, and I took it whole. There’s a specific ego pull in a problem that’s just hard enough to feel worthy of you — a Box<dyn Any> that threw away pause(), a CoreAudio thread that won’t die. It scratches the part of your brain that became an engineer in the first place. And that pull is completely uncorrelated with whether the problem matters.

The overflow warnings felt urgent because there were ten thousand of them. Volume reads as severity. But ten thousand harmless warnings are still harmless — they’re just loud. I let the loudness make the decision, and I burned an afternoon and a working build on a cosmetic cleanup nobody asked for.

The thing I learned about myself: I’m most reckless right after I’ve been competent. The two good fixes earlier in the day didn’t make me careful with the next change. They made me cavalier. Success is a sedative for judgment.

What this means for you

Two rules came out of this session, and I’ve actually changed how I work because of them.

Rule 1: A log message is not a bug. Before you touch working code to “clean up” something, ask one question — is this actually causing a wrong outcome a user would notice? If the recording records and the paste pastes, the overflow in the logs is a TODO, not a fire. Write it down. Walk away. The bar for modifying working software is “this produces a wrong result,” not “this offends me.”

Here’s the decision tree I now run before any “quick cleanup”:

Does the thing currently produce a wrong USER-VISIBLE outcome?
├── No  → Is it a security/data-loss risk?
│        ├── No  → Backlog it. Do NOT touch working code. Walk away.
│        └── Yes → Fix it (carefully, isolated, tested).
└── Yes → Is the fix isolated to one file/concept?
         ├── Yes → Fix it. Verify live. Commit alone.
         └── No  → STOP. Write a PRD. This is a project, not a patch.

That last branch is real. The actual remaining issue from the session — Auto-mode sometimes cuts you off mid-sentence because the silence threshold is a single hardcoded number that can’t be right across a studio mic and a MacBook’s built-in — touches the detector’s core math. So it doesn’t get a tired-afternoon patch. It gets a written spec (an adaptive, noise-floor-relative detector — standard voice-activity-detection stuff) and a fresh session. Knowing the difference between a patch and a project is most of the skill.

Rule 2: Audit your CI bill before it audits you. If you’re a solo builder on GitHub Actions, go read your workflow on: triggers right now. Today. Ask three questions: What runner am I using? (macOS and Windows are 10× and 2× Linux.) What triggers it? (Every push? Even docs?) Do I actually need a cloud build, or am I already gating locally? For a lot of solo Mac projects, the right answer is workflow_dispatch and a local gate — and you’ll feel the difference at the end of the billing month.

Addressing the obvious objection — “but disabling CI means a bad commit slips through”: for a one-person team that runs lint and tests locally before every commit, automatic CI was duplicating a check I already do, at 10× cost, to catch a mistake I wasn’t making. The clean-room build still exists on a manual trigger for when it earns its keep — right before cutting a release. That’s not skipping safety. That’s putting the expensive safety check where the risk actually is.

The bigger thing, the one that’s bugging me a week later: the entire afternoon’s damage came from treating “I can fix this” as “I should fix this.” Owned software — the whole anti-rentier point of what we’re building — means you’re the one deciding where your hours go. Nobody’s assigning you the overflow ticket. Which means nobody’s stopping you from spending your one wild and precious afternoon on it, either. The freedom cuts both ways. Spend it on the thing a user would feel.


Building VoiceCatcher in the open, mistakes and all. If you’ve ever broken a working build to fix something nobody asked about, pull up a chair — you’re among friends here.