Skip to content

fix(ios): restore profile dedup + remove dead reconnect path#210

Merged
arul28 merged 2 commits into
mainfrom
ade/restore-ios-profile-dedup-followup
Apr 27, 2026
Merged

fix(ios): restore profile dedup + remove dead reconnect path#210
arul28 merged 2 commits into
mainfrom
ade/restore-ios-profile-dedup-followup

Conversation

@arul28
Copy link
Copy Markdown
Owner

@arul28 arul28 commented Apr 27, 2026

Summary

Follow-up to #209. The rebase resolution there kept the dict-based saved-profile API but dropped the recency-aware dedup that pre-existed on main. This restores it and cleans up dead code Greptile flagged in the PR review.

  • shouldPreferProfile(_:over:) picks the better profile when two map to the same storage key (newer updatedAt, then Tailscale presence, then last-successful-address presence).
  • loadSavedProfilesRaw() uses it during legacy [HostConnectionProfile] array migration so collisions don't fall to last-write-wins.
  • saveSavedProfiles(_:) runs a paranoia dedup pass before encoding.
  • reconnectToSavedHost(_:preferTailnet:) and its only helper profile(forSavedHost:) removed — both unreachable post-rebase, flagged by Greptile P2-A in Context Doc Generation #209.

Net diff: +29 / -33 in apps/ios/ADE/Services/SyncService.swift. Single file.

Test plan

  • iOS smoke: pair host, force-quit, relaunch — saved profile still loads, token still resolves under new : keychain key.
  • iOS upgrade path: install previous TestFlight build with array-format profiles, install this build, confirm profiles migrate without loss (covered by the existing legacy-array fallback in Context Doc Generation #209 + the new dedup logic preserves the best profile when keys collide).
  • CI green (typecheck-desktop, lint-desktop, ade-cli typecheck — Swift compile is iOS-side, not CI-gated locally).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved saved host profile persistence by deduplicating entries to prevent duplicate saved hosts from accumulating.
  • Refactor

    • Streamlined reconnect behavior to use standard reconnection flows.

Greptile Summary

This PR restores the recency-aware profile deduplication logic (shouldPreferProfile, deduplicatedProfiles) that was dropped in the #209 rebase, and removes the unreachable reconnectToSavedHost/profile(forSavedHost:) pair. The dedup pass runs both during legacy array migration and as a paranoia pass in saveSavedProfiles.

The two P2 findings are edge cases in deduplicatedProfiles: (1) when a bridging profile spans two pre-existing canonical slots, only the first-matched slot is merged and the second leaks through; (2) stale identity-key pointers remain in keyToIndex after a replacement, which can cause a later unrelated profile to merge into the wrong slot. Neither causes data corruption, but the dedup may be incomplete in adversarial orderings.

Confidence Score: 5/5

Safe to merge; all findings are P2 edge cases in the dedup logic that do not corrupt data.

Only P2 findings present — both are incomplete-dedup scenarios requiring specific dict-iteration orderings and profile key configurations that are unlikely in practice. No P0/P1 issues found.

apps/ios/ADE/Services/SyncService.swift — specifically the deduplicatedProfiles slot-bridging and stale-key logic.

Important Files Changed

Filename Overview
apps/ios/ADE/Services/SyncService.swift Adds recency-aware profile dedup (shouldPreferProfile, deduplicatedProfiles) and removes the dead reconnectToSavedHost/profile(forSavedHost:) pair; two edge-case dedup misses in the slot-bridging and stale-key logic of deduplicatedProfiles.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[saveSavedProfiles] --> B[deduplicatedProfiles]
    B --> C{For each profile}
    C --> D[profileIdentityKeys]
    D --> E{Any key in keyToIndex?}
    E -- yes --> F{shouldPreferProfile?}
    F -- yes --> G[Replace canonical slot\nUpdate winner keys in keyToIndex]
    F -- no --> H[Skip]
    E -- no --> I[Append new canonical slot\nAdd all keys to keyToIndex]
    G --> C
    H --> C
    I --> C
    C --> J[Rebuild output dict via profileStorageKey]
    J --> K[Encode and write to UserDefaults]
Loading

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/ios/ADE/Services/SyncService.swift
Line: 1510-1518

Comment:
**Slot-bridging miss in `deduplicatedProfiles`**

When a profile whose identity keys span two already-distinct canonical slots is processed, only the first-matched slot is merged. The second slot remains as a separate entry in `canonicalProfiles` and is emitted at the end, producing a dedup miss.

Concrete scenario (dict iteration order is undefined, so this order is reachable):
1. Profile A (keys `[K1]`) → slot 0.
2. Profile B (keys `[K2]`) → slot 1.
3. Profile C (keys `[K1, K2]`) → `lazy.compactMap { keyToIndex[$0] }.first` yields slot 0 (K1 wins). C is merged into slot 0. `keyToIndex[K2]` is overwritten to 0. But slot 1 (B) survives in `canonicalProfiles` and is output independently.

The output dict ends up with both slot-0 (merged A+C) and slot-1 (B), even though C established they are the same host. A union-find structure would handle this correctly by merging the two slots when a bridging profile is encountered.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/ios/ADE/Services/SyncService.swift
Line: 1510-1514

Comment:
**Stale identity keys after slot replacement**

When a challenger profile wins (`canonicalProfiles[index] = profile`), the winner's identity keys are added to `keyToIndex`, but the loser's keys that are NOT in the winner's key set remain pointing at that slot. If a later profile shares only a stale key (one that belonged to the now-replaced loser), it will be incorrectly merged into the winner's slot despite having no identity overlap with the winner.

Example: A has `[K1, K2]`, B has `[K1, K3]`, B beats A → `keyToIndex`: `{K1:0, K2:0(stale), K3:0}`. Profile C arrives with `[K2]` only — it matches slot 0 through the stale addr key, and may get incorrectly merged with B. Consider clearing or rebuilding `keyToIndex` entries for the loser when a replacement occurs.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix(ios): broader tailnet detection + cr..." | Re-trigger Greptile

PR #209's rebase resolution kept the dict-based saved-profile API but
dropped the recency-aware dedup that pre-existed on main. Restore it:

- shouldPreferProfile picks the newer/Tailscale-preferring/has-last-
  successful-address profile when two map to the same storage key.
- loadSavedProfilesRaw uses it during legacy [HostConnectionProfile]
  array migration so collisions don't fall to last-write-wins.
- saveSavedProfiles runs a paranoia dedup pass before encoding.
- reconnectToSavedHost (and its only helper profile(forSavedHost:))
  removed; both were unreachable post-rebase, flagged by Greptile.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
ade Ignored Ignored Preview Apr 27, 2026 2:44am

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 27, 2026

@copilot review but do not make fixes

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Warning

Rate limit exceeded

@arul28 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 50 minutes and 22 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 05dfac16-c864-465f-927b-ed2fd7c66c14

📥 Commits

Reviewing files that changed from the base of the PR and between c15d78b and 6eabc94.

📒 Files selected for processing (1)
  • apps/ios/ADE/Services/SyncService.swift
📝 Walkthrough

Walkthrough

Updated saved host profile persistence to deduplicate entries by storage key using a preference function, and removed the public reconnectToSavedHost(_:preferTailnet:) method, consolidating reconnect behavior into existing reconnect flows.

Changes

Cohort / File(s) Summary
Profile Deduplication & Migration
apps/ios/ADE/Services/SyncService.swift
Added deterministic preference logic (shouldPreferProfile) to deduplicate saved profiles during persistence and legacy array-to-dictionary migration. Updated saveSavedProfiles to encode normalized, deduplicated dictionary. Removed public reconnectToSavedHost(_:preferTailnet:) method and migrated its logic to existing reconnect/reconnectIfPossible flows.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

ios

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: restoring profile deduplication logic and removing the dead reconnectToSavedHost code path, which aligns with the primary objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ade/restore-ios-profile-dedup-followup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 27, 2026

@copilot review but do not make fixes

Here's the review. No blocking issues — changes are correct and well-scoped. Observations below:

shouldPreferProfile — string-based timestamp comparison
updatedAt is String (ISO8601), so > is a lexicographic comparison. This works correctly only when all timestamps are in UTC with identical format/precision. The same pattern is already used in savedReconnectHosts (line 1531), so this isn't new risk, but it's worth noting: a timestamp encoded with a non-UTC offset (e.g., +05:30) would compare incorrectly. Not a problem with ISO8601DateFormatter() in its default config, but it's a latent fragility.

deduplicatedProfiles called from saveSavedProfiles — redundancy vs correctness
The input is already a [String: HostConnectionProfile] keyed by storage keys, so in normal operation the pass is a no-op (each profile recomputes to the key it's already stored under). The only time it would actually collapse entries is if two profiles in the dict produce the same profileStorageKey output despite having different dict keys — possible across a migration boundary where a profile's primary identifying field (e.g., hostIdentity) was backfilled. That use-case is real, so the pass is justified. A brief comment noting why a re-key pass is needed on an already-keyed dict would prevent future readers from treating it as dead code.

deduplicatedProfiles — profiles silently dropped
Profiles where profileStorageKey returns nil are silently dropped. This is intentional (nowhere to store them), but the log at line 1452 only covers migration; there's no equivalent warning here. If a healthy profile loses all its identifying fields (edge case), it would vanish quietly on next save.

Tiebreaker is not a total order
When updatedAt is equal and both profiles have the same presence/absence of tailscaleAddress and lastSuccessfulAddress, shouldPreferProfile returns false and iteration order determines the winner. For a paranoia-dedup pass this is acceptable, but it means the result is non-deterministic across Swift version/runtime dictionary iteration changes. Low risk given this is only triggered during collision resolution.

Removed functions — confirmed clean
reconnectToSavedHost and profile(forSavedHost:) have zero callers anywhere in the Swift codebase. The deletion is safe.

Cosmetic nit
Line 1452: "...to dict format..." has a double space.

Overall: logic is sound, deletion is safe, dedup priority order (newer > tailscale > lastSuccessful) is reasonable. The string-timestamp comparison is the only item I'd keep an eye on long-term.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/ios/ADE/Services/SyncService.swift`:
- Around line 1479-1490: deduplicatedProfiles currently only collapses entries
with identical profileStorageKey values so it misses pairs where an older
profile used an older storage key; change deduplicatedProfiles to compute a
canonical identity key (e.g., prefer profile.hostIdentity or
profile.lastHostDeviceId when present, falling back to profileStorageKey or
address) for each profile, use that canonical key to dedupe, and keep selection
logic using shouldPreferProfile(existing, new) so the stronger/newer profile
replaces stale ones; update references to profileStorageKey only as a fallback
and ensure returned dictionary uses the latest profile values for each canonical
identity.
- Around line 1426-1434: The tie-breaker in shouldPreferProfile(_:over:) only
checks candidate.tailscaleAddress != nil but must treat any tailnet route in
lastSuccessfulAddress or savedAddressCandidates as "Tailscale presence"; add a
small helper (e.g. isTailscalePresent(_: HostConnectionProfile) -> Bool) that
returns true if profile.tailscaleAddress != nil OR profile.lastSuccessfulAddress
is a tailnet route OR profile.savedAddressCandidates contains a tailnet route,
then replace the tie-break condition that currently checks
candidate.tailscaleAddress != nil && existing.tailscaleAddress == nil with a
comparison using isTailscalePresent(candidate) && !isTailscalePresent(existing)
so equal-updatedAt profiles prefer the one with any tailscale route encoded in
any of those fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f1d5d756-a64c-4834-908c-a9c785a81bd1

📥 Commits

Reviewing files that changed from the base of the PR and between 468d842 and c15d78b.

📒 Files selected for processing (1)
  • apps/ios/ADE/Services/SyncService.swift

Comment thread apps/ios/ADE/Services/SyncService.swift
Comment thread apps/ios/ADE/Services/SyncService.swift
Addresses two CodeRabbit findings on #210:

- profileHasTailnetRoute checks tailscaleAddress, lastSuccessfulAddress
  (via syncIsTailscaleRoute), and savedAddressCandidates so the
  shouldPreferProfile tie-break treats tailnet routes encoded in any
  field as equivalent to a populated tailscaleAddress.
- deduplicatedProfiles now matches on hostIdentity, lastHostDeviceId,
  hostName:port, and lastSuccessfulAddress:port simultaneously, so
  profiles whose storage key evolved (e.g., older entry written before
  hostIdentity was learned) collapse with the newer stronger-keyed
  entry instead of leaving both in savedReconnectHosts.
@arul28 arul28 merged commit d47a0ff into main Apr 27, 2026
6 of 7 checks passed
@arul28 arul28 deleted the ade/restore-ios-profile-dedup-followup branch April 27, 2026 02:44
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.

2 participants