2026-05-17

Builds 34 → 51. Closes the "ghost device on rapid rename" class of bugs that had been chasing us for a week. The fix is architectural: per-device state in the dashboard is now keyed by device UUID, not by name. Names are display-only and resolved at render time via a uuidToNameRef lookup; renames are a single ref mutation + a re-render counter, no Map rewrites anywhere.

What changed today

iOS (RealtimeSubscriber + supporting)

  • Build 45gracefulStopForBackground's detached old-WS teardown stops accumulating ghost presence sockets on bg→fg. Previous shape raced untrack + disconnect against a 1s cap and cancelled the disconnect when the timer won; the URLSession stayed alive on the wire and Phoenix kept the socket's presence entry until heartbeat timeout (~45s). New shape: detach the cleanup, let disconnect() run to completion. Bounded growth.
  • Build 47device:<uuid> broadcast UPDATE handler replaces the previously-silent onPostgresChange(table: "devices"). public.devices is NOT in the supabase_realtime publication, so postgres_changes for that table is a no-op. Migration 20260513000018 already broadcasts every UPDATE on devices to device:<uuid>; that's the actual path that fires. Verified by SELECT on pg_publication_tables.
  • Build 48refetchAndAdoptDeviceName replaces the broadcast-payload-parsing dance. The supabase-swift SDK's nested-JSON shape ([String: AnyJSON] vs plain [String: Any]) defies reliable cross-version unwrap. New shape: the broadcast UPDATE is treated as a content-free "something changed" signal; iOS refetches the canonical row via GET /rest/v1/devices?uuid=eq.<self> and adopts whatever the server returns. One extra round-trip per rename is negligible vs. the alternative of a parsing dance that has failed twice. New helper SupabaseClient.selectOne(from:column:equals:).

Dashboard

  • Build 44DashboardClient devices:all channel now auto-rebuilds on CLOSED status with 1s backoff. Same pattern DevicesClient already had (build 43, commit cabf6e7) but for the main-view channel. Long-running dashboard tabs hit status=CLOSED (Chrome inactive-tab WS pruning, network blips) and supabase-js doesn't auto-reconnect — broadcast + presence handlers silently stop firing until a hard reload.
  • Build 46DashboardEntry gains optional device_uuid: string. Populated at live-ingest sites; broadcast UPDATE handler prefers uuid-based remap with name fallback. Smaller-scope precursor to build 49's full refactor.
  • Build 49 — the big one. Per-device state Maps switch from name-keyed to UUID-keyed: deviceStatuses, deviceInfos, entryCounts, deviceProxies. filterDevices becomes UUID[]; deviceModal stores uuid instead of name. The devices useMemo returns UUIDs. Display name resolves at render time via a new displayName(uuid) callback that reads uuidToNameRef.current. A uuidToNameRev state counter bumps inside refreshUuidToName() so React re-runs every name-deriving memo on rename even though the ref mutation is invisible to it. Rename is now a no-op for state remapping — only uuidToNameRef updates. Filter persistence stays backward-compatible: a translation effect re-derives leftover names to UUIDs once uuidToNameRef populates after cold-start.
  • Build 50recomputePresenceFromChannel also populates per-tracker source flags (mainOnline, tunnelOnline, proxyOnline) so DeviceInfoTab's Source pills (Main / Tunnel / Proxy) light correctly when the modal is opened from the live feed. Previously DevicesClient (the /devices page) had this but the main-view DashboardClient did not — opening the per-device modal from the live feed showed every Source pill as Offline regardless of the actual tracker mix.

Verification

  • Real iPhone test (R1–R5, midday): join / iOS-side rename / bg → "1m ago" / fg → Online within ~2s / dashboard-side rename → iOS pickup. The iOS-pickup half was the last one to land — required the build 47 → 48 progression.
  • End-to-end mock test (M1–M5, late afternoon): insert + subscribe → Online + Main pill / 6 rapid renames in 2.5s — zero ghosts, sidebar shows only the final name / untrack → "1m ago" / retrack → Online / delete → row gone. This is the canonical regression test for the rename-ghost class.

Open follow-ups (not yet fixed)

  • #213 — VPN-bounce still leaks presence sockets. Build 45 closed the bg→fg path; VPN transitions still leak because the OS reroutes traffic during a flip and the previous WS's TCP path goes unreachable, so disconnect() may linger until TCP timeout. Verified during the 2026-05-17 iPhone test: rapid VPN toggle climbed the probe SYNC presence count above 5. The dashboard is unaffected (still bounded by UUID-keyed state) but server-side memory grows. Fix candidate: aggressive socket close (URLSession.invalidateAndCancel) instead of waiting for the peer's close ACK; OR skip the bounce on VPN transitions when the channel is still healthy.

Files touched today (highlights)

FileChange
ios/BusymateHelper/Services/RealtimeSubscriber.swiftdetached teardown (build 45) + broadcast UPDATE handler (47) + refetchAndAdoptDeviceName (48)
ios/Shared/SupabaseClient.swiftnew selectOne(from:column:equals:) helper (build 48)
web/dashboard/app/DashboardClient.tsxchannel auto-rebuild (44), device_uuid on entries (46), full UUID-key refactor (49), per-tracker source flags (50)

Build numbers

Bumped: 34 → 35 → 36 → 37 → 38 → 39 → 40 → 41 → 42 → 43 → 44 → 45 → 46 → 47 → 48 → 49 → 50 → 51. Per feedback_inc_before_deploy, every deploy got a fresh number.