Skip to content

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

Description

@alpha5611331

Summary

The main process never delivers a single app:state-updated push to the renderer. The renderer runs forever on the one snapshot it takes when the first component mounts.

Reported symptom: the Start button does nothing - it re-toasts "Could not load your saved configuration. Reconnecting - try again in a moment." however many times you press it. That is one manifestation among several; a fresh login is also broken.

The defect

src/main/services/app-state.service.ts:

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

flushRenderer(): void {
  if (!this.broadcastTimer) return;   // always true when called from the timer
  clearTimeout(this.broadcastTimer);
  this.broadcastTimer = null;
  // win.webContents.send('app:state-updated', ...)  <- never reached
}

The timer callback nulls the handle and then calls a method guarded on that handle being non-null.

flushRenderer() is the only caller of webContents.send('app:state-updated', ...) - the single hit across src/main/. Nothing in src/ calls flushRenderer() externally; only test/app-state.test.mjs does. So every real broadcast goes through the timer path, and the timer path is a no-op.

Introduced with the broadcast coalescing in f47e66f.

Why CI never caught it

test/app-state.test.mjs calls flushRenderer() synchronously after each updateState(), while the timer is still pending. That is the one arrangement in which the send works. No check ever lets the timer fire on its own, so the coalesced path - the only path production uses - was untested.

Why the renderer never recovers

use-app-state.tsx starts its 1-second polling fallback only when onAppStateUpdated is absent. It is present (preload.cts), so the renderer subscribes to a channel that never fires and never polls. It gets exactly one snapshot, from refreshState() at first subscribe, and is frozen from then on.

How that kills Start

checkCanStart() in control-panel/index.tsx gates on appState.interviewConfigLoaded, which is set to true only by accountService.pullFromBackend() / updateConfig() after login. That update lands in main and broadcasts into the void.

  1. Click Start -> interviewConfigLoaded is still the false default -> toast.
  2. onFail fires account.refresh(), which succeeds in main and broadcasts - to nobody.
  3. Click Start again -> identical toast. Forever.

Everything else the same defect breaks

Which symptom a user sees depends only on when their single snapshot was taken.

Area Consequence
Fresh login LoginPage.submit does not navigate; AuthLayout navigates to /main only when isLoggedIn === true arrives over the broadcast. Login succeeds in main and the user stays on the form.
Snapshot during startup ping healthCheckService.start() sets isLoggedIn: null before its ping resolves. A snapshot in that window pins MainPage on "Authenticating…" permanently.
Start button (reported) Remembered session: the ping sets isLoggedIn: true before the snapshot, so /main renders, but interviewConfigLoaded never arrives. Toast loop.
Run state Idle -> Starting -> Running -> Idle never reaches the UI. Even a successful start leaves the button on "Start" and TransitionOverlay never appears.
Transcripts / live + action suggestions appendSuggestion and transcriptService.ingest update main only. Panels stay on the setPlaceholderState() content for the whole interview.
Credits, user role, backend-live ConnectingNotice, CreditsDisplay, TrialUserNotice, StatusPanel all frozen at their snapshot values.
Clear (Tools) 3a7b660 fixed Clear to broadcast its reset. That fix is inert - the broadcast it added cannot send.

Checked and cleared

Audited for the same class of defect across the client: every ipcMain channel has a matching preload binding and vice versa; all push channels main sends (hotkey:*, auto-updater:status, notification:push, window:stealth-changed, zoom:level-changed) have live preload listeners; the timers in action-lock.service.ts and the stall timers in suggestion-live.service.ts / suggestion-action.service.ts are correct - none repeats the clear-then-call-a-guarded-method shape.

Fix

Separate sending from scheduling: extract the send into a private sendToRenderer() that both the timer callback and flushRenderer() call, leaving the "is something pending" guard on flushRenderer() alone where it belongs.

Plus a regression check in test/app-state.test.mjs that lets the timer fire naturally with no explicit flush. It fails on main and passes with the fix.

Out of scope

Two pre-existing items found during the audit, neither the cause here:

  • audioInputDeviceNotFound is read in checkCanStart() (control-panel/index.tsx:56) but const-declared 74 lines later at line 130. Safe today only because the closure runs on click, after the render scope initializes. Fragile, not broken.
  • enumerateDevices() returns blank labels until mic permission is granted, so filterAudioDevices falls back to "Input Device N" names that can never match a saved audioInputDeviceName - an independent second source of a Start-blocking toast on a fresh machine.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions