Skip to content

fix(app-state): actually send the coalesced broadcast - #82

Merged
alpha5611331 merged 1 commit into
mainfrom
fix/app-state-broadcast-never-sends
Aug 7, 2026
Merged

fix(app-state): actually send the coalesced broadcast#82
alpha5611331 merged 1 commit into
mainfrom
fix/app-state-broadcast-never-sends

Conversation

@alpha5611331

Copy link
Copy Markdown
Member

Closes #81.

The main process never delivered a single app:state-updated push to the renderer, so the renderer ran forever on the one snapshot it took when the first component mounted. Reported as "the Start button does nothing"; a fresh login was equally dead.

Root cause

this.broadcastTimer = setTimeout(() => {
  this.broadcastTimer = null;   // handle cleared here...
  this.flushRenderer();         // ...so flushRenderer's own guard trips
}, BROADCAST_COALESCE_MS);

flushRenderer() opens with if (!this.broadcastTimer) return; and holds the only webContents.send('app:state-updated', ...) in src/main/. Nothing outside test/ calls it. So every real broadcast went through the timer path, and the timer path sent nothing.

The renderer never recovers because use-app-state.tsx starts its polling fallback only when onAppStateUpdated is absent. It is present, so the renderer subscribed to a channel that never fired and never polled.

Change

Extract the send into private sendToRenderer(). The timer callback calls it directly; flushRenderer() keeps its pending check - which is its documented contract, and what the identical updates do not broadcast check relies on - then calls it. No signature or semantic change to flushRenderer().

Test

Every existing check in test/app-state.test.mjs calls flushRenderer() synchronously while the timer is still pending. That is the one arrangement in which the send worked, which is exactly why this shipped green. Added a check that lets the timer fire on its own with no flush at all.

Verified red before the fix and green after (rebuilding electron-dist/ in between - node test/run.mjs alone runs stale output):

  FAIL a coalesced broadcast reaches the renderer
  FAIL the coalesced broadcast carries the change

pnpm test:main, pnpm lint, and pnpm build all pass on the branch.

What this unblocks

Every consumer of the push channel, not just Start: fresh login navigation, the isLoggedIn: null startup window that could pin MainPage on "Authenticating…" forever, Idle -> Starting -> Running button transitions and TransitionOverlay, live transcripts and both suggestion panels (stuck on placeholder content for a whole interview), credits / user role / backend-live indicators, and the Clear reset from 3a7b660 whose broadcast could not send.

Also audited

Same class of defect across the client, all clear: every ipcMain channel has a matching preload binding and vice versa; all push channels main sends have live preload listeners; the timers in action-lock.service.ts and the stall timers in both suggestion services are correct - none repeats the clear-then-call-a-guarded-method shape.

Two pre-existing items noted in #81 as out of scope: the audioInputDeviceNotFound use-before-declaration in control-panel/index.tsx, and enumerateDevices() returning blank labels before mic permission is granted.

🤖 Generated with Claude Code

notifyRenderer's timer callback nulled broadcastTimer and then called
flushRenderer, which returns early when broadcastTimer is null. Since
flushRenderer holds the only webContents.send for app:state-updated, and
nothing outside the tests calls it, the renderer never received a single
state push.

The renderer does not recover on its own: use-app-state only starts its
polling fallback when onAppStateUpdated is absent, so it subscribed to a
channel that never fired and froze on the snapshot it took at mount.
Start then re-toasted the missing-configuration error forever because
interviewConfigLoaded never arrived, and a fresh login never left the
form because AuthLayout waits on isLoggedIn from the broadcast.

Split the send out into sendToRenderer so the pending check stays on
flushRenderer, where it is the documented contract, and off the timer
path, where it was self-cancelling.

Every existing check flushed synchronously while the timer was still
pending - the one arrangement in which the send worked - so add a check
that lets the timer fire on its own.

Closes #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alpha5611331
alpha5611331 merged commit 12418e7 into main Aug 7, 2026
1 check passed
@alpha5611331
alpha5611331 deleted the fix/app-state-broadcast-never-sends branch August 7, 2026 14:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App state is never broadcast to the renderer: Start button and fresh login are dead

1 participant