Skip to content
RO
← All writing

The missing div that silenced the mic

  • electron
  • javascript
  • debugging
  • desktop

The app opened fine. The window drew, the animation ran, every card on screen had real data in it. I talked to it and nothing happened. Not an error, not a delay. Nothing.

I’m building a desktop AI assistant for myself, on my own hardware. Electron shell, a 3D animated orb as the main visual, voice in and voice out, a scattering of status cards around the edges. Two days before this, I’d rewritten the whole front end: an older docked side panel became a set of floating cards over the orb. Better looking, more modern, and it deleted an element called #status that the old panel used to show a line of text like “Listening” or “Ready”.

I’d checked that the panel looked right. I hadn’t checked that nothing downstream still expected #status to exist.

The line that ran first

Somewhere in the startup code was this, more or less:

async function startAstro() {
  ChatView.status('Booting...');   // #status no longer exists
  await unlockAudioContext();
  await loadVoiceModels();
  primeWakeWord();
}

ChatView.status() still tried to write to #status. #status was gone. Writing to a null reference throws, and this was the very first statement in startAstro(), so the throw happened before anything else in that function got a chance to run. The audio context never unlocked. The voice model list never loaded. Wake-word detection never started. All three, silently, because line one of four had failed and nobody told line two.

That’s the entire bug. One null.textContent = x, in the wrong place, at the top of a function that everything else depended on.

Why it took so long to find

None of that showed up anywhere. The window rendered. The orb kept animating, because the render loop doesn’t go through startAstro(). The status cards had numbers in them, because those come from a different poll that also doesn’t touch startAstro(). Every visual signal said the app was healthy.

The deeper problem was that Electron’s main process was throwing this exception away. By default, whatever the renderer’s DevTools console logs, errors included, stays in the renderer and never reaches the main process or any file on disk. There was no log line. Not a wrong one, not a buried one. None. I went looking for a stack trace and there was nothing to find, because nothing had been asked to keep one.

I’d hit this exact shape of problem once before on this project, a fault I’d taken to calling “he can’t hear me”: voice dead, everything else fine, no error surface anywhere. That one should have taught me to fix the logging gap there and then. It didn’t, and three weeks later a completely different bug found the same hole.

What changed

Three things, in order of how much I’d trust the app without them.

The main process now listens for the renderer’s console-message, did-fail-load, and render-process-gone events and writes what it hears into the app’s own log file. A crash in the UI layer now leaves a trail whether or not I’m watching DevTools at the time, which is most of the time, because why would I be. Small wrinkle worth noting: a recent Electron version changed console-message from separate positional arguments to a single details object, so the handler now checks for both shapes rather than assuming one.

Second, I looked at why one failed write took the other three steps down with it, and the honest answer was ordering, not the null check. unlockAudioContext() needs a real user click before a browser will let it resolve, which means it can legitimately hang forever if nobody’s clicked anything yet in that window. await-ing it at the top of startup meant everything else was queued up behind a promise with no guaranteed end. I made that call fire-and-forget with a timeout, and moved the steps that don’t depend on it (loading the voice list, for one) ahead of it instead of behind it.

Third, ChatView.status() now checks the element exists before it writes to it, and I put #status back, smaller, in a quieter corner of the new layout, rather than leaving nothing standing in for it.

What I’d take from it

The bug itself is almost boring once you see it: a deleted div, a function that assumed it was still there. What made it dangerous was that it was invisible, and it was invisible because of a decision I’d made and forgotten about: renderer console output just goes nowhere by default in Electron, and I’d never told it to go anywhere else.

Don’t let one startup step block everything after it unless the thing after it actually needs the thing before it to finish. Most of the time it doesn’t, and you find that out the day the blocking step happens to be the one that hangs.

And don’t ship a GUI app, Electron or otherwise, where the renderer can throw and nothing outside that renderer ever finds out. A visible crash is a bug report. A silent one is just an app that quietly stopped working while continuing to look fine, which is worse, because there’s no moment where you’re prompted to go and check.