Disclosure: I'm the developer of the app described here, and this post was drafted and published by an AI agent from the project's source code.

Bolo turns an Android phone into a microphone for a computer: you speak on the phone and the text is typed at the cursor over local Wi-Fi. The computer side is a small Node.js helper in the tray. It updates itself, and updating means restarting, which is exactly what you must not do in the middle of someone's sentence.

This post is about the 91-line module that decides when the restart happens (src/updateGate.js), and the bug that made it necessary.

The first rule was too cautious

The original rule was simple: if a phone is connected, skip the update check. A phone mid-dictation should never see the helper vanish.

It held up until a helper was still on the previous version after a release had shipped. Its phone was simply always connected, so the check never ran. "Don't update while connected" quietly became "never update" for the most active users, who are the ones who most need fixes.

The lesson: "connected" is not "busy". A phone can sit connected for hours while nobody says a word.

Split the decision in two

The fix separates two things the old rule had glued together:

  1. Checking for an update runs on its normal schedule (every 6 hours, plus on demand), whether or not a phone is connected.
  2. Restarting waits until no text is being typed right now and none has been typed for 2 minutes.

Busy is measured where the typing happens. The message handler wraps every message that types text (a dictation, a live partial transcript, a clipboard paste) in a begin/end pair:

async function handleMessage(ws, message) { const typing = message.type === 'text' || message.type === 'realtime_text' || message.type === 'clipboard_paste'; if (!typing) return handleMessageInner(ws, message); updateGate.begin(); try { return await handleMessageInner(ws, message); } finally { updateGate.end(); } } 

The finally matters: a dictation that throws still ends, so a failure can't leave the gate stuck "busy" forever.

The gate itself

Two pieces of state, a counter and a timestamp:

let lastActivityAt = 0; let inFlight = 0; function begin() { inFlight++; lastActivityAt = now(); } function end() { inFlight = Math.max(0, inFlight - 1); lastActivityAt = now(); } function isIdle(idleMs = IDLE_MS) { return inFlight === 0 && now() - lastActivityAt >= idleMs; } 
  • A counter, not a boolean. Two messages can overlap (a partial transcript and the final text). With a boolean, the first end() would declare the gate idle while the second is still typing.
  • Math.max(0, …) so an unmatched end() can't push the counter negative and make "idle" impossible.
  • The clock is injectable (now), so the tests move time forward instead of sleeping for two minutes.

Waiting is a re-check loop that sleeps only as long as it has to:

function waitForIdle({ idleMs = IDLE_MS, pollMs = POLL_MS } = {}) { return new Promise((resolve) => { const check = () => { if (isIdle(idleMs)) return resolve(); const t = setTimeout(check, Math.min(pollMs, Math.max(50, idleMs - idleForMs()))); t.unref?.(); }; check(); }); } 

If 100 seconds of the 120 have already passed, it waits 15 seconds (the poll cap), not another full two minutes. unref() keeps a pending wait from holding the process open on its own.

One waiter at a time

The 6-hour timer keeps firing while an update waits for a quiet moment. Without a guard, a busy afternoon could stack several waiters, and the first quiet moment would trigger several restarts in a row. So there is exactly one pending update:

let pending = null; async function runGatedUpdate({ version, check, apply, interactive, ... }) { if (pending) { if (interactive) log(`[Update] ${pending} will install at the next idle moment.`); return 'pending'; } const result = await check(version); if (!result.enabled || !result.updateAvailable) return /* 'disabled' | 'current' */; pending = result.version; try { await waitForIdle(); await apply(result); // restarts the helper return 'applied'; } finally { pending = null; } } 

The finally again: if apply fails (a download error, say), pending clears, and the next scheduled check can try again instead of being told forever that an update is "pending".

When you ask for an update check by hand while you're dictating, you get a plain answer ("will install at the next idle moment") instead of a restart under your cursor.

What I'd keep from this

  • Name the condition you actually care about. "Phone connected" was a stand-in for "user is typing", and the stand-in was wrong most of the day.
  • Separate detecting an update from applying it; only the second needs to wait.
  • Count in-flight work; don't flag it. Always release in finally.
  • Allow one pending action, and clear it on failure.
  • Make time injectable so a 2-minute rule is testable in milliseconds.

Bolo is on Google Play (search "Bolo AI"), and the Windows companion is on GitHub releases. The phone and the computer talk over your local Wi-Fi.