Skip to content

Persist the dns resolver settings in the app's own local state - #343

Merged
Ryanmello07 merged 1 commit into
urnetwork:mainfrom
Ryanmello07:fix/ios-dns-settings-persistence
Sep 9, 2026
Merged

Persist the dns resolver settings in the app's own local state#343
Ryanmello07 merged 1 commit into
urnetwork:mainfrom
Ryanmello07:fix/ios-dns-settings-persistence

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

Depends on #340. This branch contains #340's commit as well, because a cross-fork PR cannot use a fork branch as its base. Review only the second commit here; the diff shrinks to it once #340 merges.

The bug

Nothing on the DNS settings page survives leaving the app. Configure custom resolvers, close the app, come back, and the form is empty.

Same root cause as #340, one file over. The device persists its resolver settings, but into the network extension's container, which the app process cannot read. DnsSettingsStore kept no copy of its own: it read device.getDnsResolverSettings() and wrote device.setDnsResolverSettings(...), and with the RPC service down DeviceRemote parks the edit in memory that dies with the app process.

It is worse than a lost edit

DnsSettingsView opens on whatever the device reports, and falls back to a blank DnsSettings() when that is nil. With the tunnel down and nothing seeded, the device reports out of its own empty memory — so a tunnel-down launch shows the blank never-configured form over the resolver the extension still holds on disk, and applying from that blank base replaces it on the next connect.

The fix

The two halves from #340: DnsSettingsStore.setup(_:localState:) mirrors every reading it can vouch for into the app's own LocalState, and DeviceManager.initDevice seeds the next device from that mirror, above setDevice so the seed cannot race the store's first read.

One difference from the rules, and it decides the design

DeviceLocal.SetDnsResolverSettings returns without persisting in two cases: nil settings, and when the mux is disabled. Neither fires the change listener. So unlike the split rules, the app cannot treat its own write as accepted.

The mirror is therefore written from the read-back, never from the value just pushed:

  • connected, the read-back is the extension's answer to the edit, so a dropped edit mirrors as the settings still in force rather than latching a rejected value
  • disconnected, it is the edit the remote queued — which is exactly what the next connect applies, so mirroring it is the app replaying its own record

The mux-disabled branch turns out to be self-consistent rather than a hazard: dnsResolverSettingsWithLock returns nil under the same condition that makes the setter bail, so a mux-disabled device both refuses writes and reports nothing, and a device that reports nothing mirrors nothing. (It is also near-unreachable in the app — upgradeMuxSettings is initialized to the default and the only non-test caller of SetUpgradeMuxSettings is the DNS setter itself, always with a non-nil derived value.)

nil versus empty

LocalState.SetDnsResolverSettings(nil) takes the delete branch, and a deleted store reads back exactly like one that was never written — which is what the seed's if let reads as "leave the extension's resolver alone". So persist takes a non-optional settings object, making nil unrepresentable there, and the nil-read branch mirrors nothing.

Settings with everything turned off are a real value — the user emptied the form — and are mirrored and seeded as one. That distinction is what keeps the first launch after this ships from pushing an empty resolver over settings the extension still holds.

The connect re-read

The store also re-reads once the RPC is up. The settings the extension replays on connect arrive over the reverse sync, which runs before the remote publishes its service, so that read still comes out of the remote's own memory and is unvouched.

Without the re-read the getConnected() gate would essentially never be satisfied outside an edit, so a build upgrading from before this fix would keep a nil mirror indefinitely — and sit one edit away from the destructive path above. The listener makes the first tunnel-up session capture the extension's real settings.

Tests

DnsResolverSettingsMirrorTests, modelled on BlockActionOverridesMirrorTests: SdkAsyncLocalState over a per-test temporary directory, no device and no mocking. Covers the round trip, that a field the app does not model (dnsUpgradeMaskAddress) survives the mirror — the mirror copies the SDK object rather than the DnsSettings projection — that an emptied form round-trips as a non-nil value, that nil deletes the store, and that a never-written store reads nil rather than empty.

The last two are the ones this would be silently wrong without.

Manual verification

The first launch after upgrade with the tunnel down still shows the old behaviour, and that is correct — no mirror exists yet, and reading a missing mirror as "no settings" is the wipe this deliberately refuses. It self-heals on the first connect.

  1. Connect. Open DNS settings, configure a custom resolver, apply.
  2. Stop the VPN, force-quit, relaunch still disconnected, open DNS settings → the settings are there. Before: the blank form.
  3. Still disconnected, change a resolver and apply. Force-quit, relaunch → the change is there. Connect → still there, meaning it crossed to the extension.
  4. Disconnected, clear every field and apply. Force-quit, relaunch, connect → still empty, rather than the extension's copy resurrecting.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa

The same shape as the split rules: the device persists its resolver
settings, but into the network extension's container, which the app
process cannot read. DnsSettingsStore kept no copy of its own, so
everything configured on the DNS page died with the app process.

The editor makes it worse than a lost edit. DnsSettingsView opens on
whatever the device reports, and with the rpc down that is the remote's
own empty memory -- so a tunnel-down launch shows the blank
never-configured form over the resolver the extension still holds on disk,
and applying from that blank base replaces it on the next connect.

So the store takes the app's local state, mirrors every reading it can
vouch for, and DeviceManager.initDevice seeds the next device from that
mirror.

One difference from the rules, and it decides the design.
DeviceLocal.SetDnsResolverSettings returns WITHOUT persisting on nil
settings and when the mux is disabled, so the app cannot treat its own
write as accepted -- the mirror is written from the read-back rather than
from the value just pushed. Connected, that is the extension's answer to
the edit, so a dropped edit mirrors as the settings still in force;
disconnected, it is the edit the remote queued, which is what the next
connect applies. The mux-disabled case is self-consistent rather than a
hazard: the same condition that makes the setter bail makes the getter
report nothing, and a device that reports nothing mirrors nothing.

nil is never written: it takes the store's delete branch, and a deleted
store reads back as never configured, which is what the seed reads as
"leave the extension's resolver alone". Settings with everything turned
off are a real value -- the user emptied the form -- and are mirrored and
seeded as one.

The store also re-reads once the rpc is up. The settings the extension
replays on connect arrive over the reverse sync, before the remote
publishes its service, so that read is unvouched; without the re-read a
build upgrading from before this fix would keep a nil mirror indefinitely
and sit one edit away from the destructive path above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa
@Ryanmello07
Ryanmello07 force-pushed the fix/ios-dns-settings-persistence branch from 21dbdb5 to 23d5659 Compare September 9, 2026 01:19
@Ryanmello07
Ryanmello07 merged commit f2eca5c into urnetwork:main Sep 9, 2026
1 check failed
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