Skip to content

fix(backend): stop mock modem re-broadcast clobbering merged client state (fixes flaky shard-2 E2E)#155

Merged
andrescera merged 1 commit into
mainfrom
fix/e2e-modem-scan-isolation
Jul 16, 2026
Merged

fix(backend): stop mock modem re-broadcast clobbering merged client state (fixes flaky shard-2 E2E)#155
andrescera merged 1 commit into
mainfrom
fix/e2e-modem-scan-isolation

Conversation

@andrescera

Copy link
Copy Markdown
Member

What

In mock mode, broadcastFromDiff (apps/backend/src/modules/modems/modem-update-loop.ts) re-broadcast the full modem descriptor for every modem on every reconcile diff (broadcastModems(undefined)). This change makes mock mode use the same path as a real device: a full descriptor only for newly-added modems, a status-only partial for every changed modem. One file, +13/-9.

Why

The desktop shard-2 E2E job (modem-config-surface.spec.ts:100 — "a modem scan holds pending across same-list re-broadcasts…") failed byte-identically on every PR:

expect(locator).toBeDisabled() failed
Locator: getByRole('dialog').getByTestId('modem-scan-button')
Expected: disabled   Received: enabled

Root cause (a timing race, not a cross-file leak):

  • The spec injects available_networks over the page socket via dev.emit, which is page-scoped and never persisted in the backend modemsState — so the backend's real available_networks for that modem stays empty ({}).
  • The mock feedback tick (runMockFeedbackBroadcast, ~5s) re-sent the full modem state on every signal fluctuation, and buildModemMessage always includes available_networks (and config) in a full descriptor.
  • The frontend merges each status.modems frame field-by-field (subscriptions.svelte.ts mergeModemList, shallow), so an incoming available_networks:{} overwrote the injected set.
  • That flipped the scan signature and false-confirmed the pending scan, re-enabling the button.

The race only lands inside the 5s assertion window on slower/contended CI runners (backend up for minutes across ~25 prior shard-2 specs), so it passed locally but failed deterministically in CI. sim_lock is only serialized when present, which is why the sibling sim-unlock-surface.spec.ts survives the same tick; available_networks/config are always serialized, so they get clobbered.

A real device already sends status-only for a changed modem (the !shouldUseMocks() branch), so it never clobbers the scan list. The mock's full re-send was a stale artifact from before the frontend merged per-field. This aligns the two; real-device behavior is unchanged.

How to verify

  • Repro: temporarily set MOCK_FEEDBACK_BROADCAST_INTERVAL_MS=250 and run modem-config-surface.spec.ts — it fails at the toBeDisabled assertion (byte-identical to CI). With this fix, -g "holds pending" --repeat-each=8 → 8/8 pass.
  • Backend bun test: mock-feedback-broadcast, modem-available-networks-contract, modems-state-cache, modems-configure-applied, mock-modems-output, mock-monitor, monitor-manager, modem-migration, sim-state suites → 75 pass, 0 fail.
  • E2E --project=desktop --workers=4 (grep-invert @visual|@a11y|@gallery): full suite → 226 pass / 3 skipped / 0 fail; modem/network/sim blast-radius subset → 25 pass.
  • bun tsc --noEmit exit 0; biome check clean.

Risks

  • Low. The change only affects the mock-gated broadcast shape; the real-device path is untouched. The initial full snapshot still reaches every client via the post-login buildModemsMessage() push and the discovery diff's added entries. The mock-feedback unit test asserts only .status.signal/.status.connection + dedupe counts, all preserved by a status-only partial.

The mock feedback tick (runMockFeedbackBroadcast, ~5s) re-broadcast FULL
modem state for every modem via broadcastModems(undefined). Since the
frontend now merges each status.modems frame field-by-field
(subscriptions mergeModemList) rather than wholesale-replacing, and
buildModemMessage always includes available_networks (and config) in a
full descriptor, that periodic full re-send overwrote client-side modem
fields with the backend's empty defaults.

This deterministically broke the desktop shard-2 E2E job. A pending modem
scan in modem-config-surface.spec.ts injects available_networks over the
page socket (dev.emit is page-scoped and never persisted in modemsState);
when a feedback tick then re-sent available_networks={} it flipped the
scan signature and false-confirmed the pending scan, re-enabling the scan
button (expect(scan).toBeDisabled() -> enabled). The race only lands
inside the assertion window on slower CI runners, so it passed locally
but failed byte-identically in CI.

Align mock mode with the real device: broadcastFromDiff now sends a full
descriptor only for newly-added modems and a status-only partial for
every changed modem (the non-mock branch already did exactly this). A
periodic status refresh no longer re-sends available_networks/config, so
it can never clobber merged client state. The initial full snapshot still
reaches clients via the post-login buildModemsMessage() push and the
discovery diff's added entries; real-device behavior is unchanged.
@andrescera
andrescera merged commit 59f4905 into main Jul 16, 2026
8 of 10 checks passed
@andrescera
andrescera deleted the fix/e2e-modem-scan-isolation branch July 16, 2026 23:17
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.

1 participant