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 45 —
gracefulStopForBackground's detached old-WS teardown stops accumulating ghost presence sockets on bg→fg. Previous shape raceduntrack + disconnectagainst 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, letdisconnect()run to completion. Bounded growth. - Build 47 —
device:<uuid>broadcast UPDATE handler replaces the previously-silentonPostgresChange(table: "devices").public.devicesis NOT in thesupabase_realtimepublication, so postgres_changes for that table is a no-op. Migration 20260513000018 already broadcasts every UPDATE on devices todevice:<uuid>; that's the actual path that fires. Verified by SELECT onpg_publication_tables. - Build 48 —
refetchAndAdoptDeviceNamereplaces 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 viaGET /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 helperSupabaseClient.selectOne(from:column:equals:).
Dashboard
- Build 44 —
DashboardClientdevices:allchannel now auto-rebuilds onCLOSEDstatus with 1s backoff. Same pattern DevicesClient already had (build 43, commit cabf6e7) but for the main-view channel. Long-running dashboard tabs hitstatus=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 46 —
DashboardEntrygains optionaldevice_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.filterDevicesbecomes UUID[];deviceModalstoresuuidinstead ofname. ThedevicesuseMemo returns UUIDs. Display name resolves at render time via a newdisplayName(uuid)callback that readsuuidToNameRef.current. AuuidToNameRevstate counter bumps insiderefreshUuidToName()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 — onlyuuidToNameRefupdates. Filter persistence stays backward-compatible: a translation effect re-derives leftover names to UUIDs onceuuidToNameRefpopulates after cold-start. - Build 50 —
recomputePresenceFromChannelalso populates per-tracker source flags (mainOnline,tunnelOnline,proxyOnline) soDeviceInfoTab'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)
| File | Change |
|---|---|
ios/BusymateHelper/Services/RealtimeSubscriber.swift | detached teardown (build 45) + broadcast UPDATE handler (47) + refetchAndAdoptDeviceName (48) |
ios/Shared/SupabaseClient.swift | new selectOne(from:column:equals:) helper (build 48) |
web/dashboard/app/DashboardClient.tsx | channel 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.