Persist the dns resolver settings in the app's own local state - #343
Merged
Ryanmello07 merged 1 commit intoSep 9, 2026
Merged
Conversation
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
force-pushed
the
fix/ios-dns-settings-persistence
branch
from
September 9, 2026 01:19
21dbdb5 to
23d5659
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
DnsSettingsStorekept no copy of its own: it readdevice.getDnsResolverSettings()and wrotedevice.setDnsResolverSettings(...), and with the RPC service downDeviceRemoteparks the edit in memory that dies with the app process.It is worse than a lost edit
DnsSettingsViewopens on whatever the device reports, and falls back to a blankDnsSettings()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 ownLocalState, andDeviceManager.initDeviceseeds the next device from that mirror, abovesetDeviceso the seed cannot race the store's first read.One difference from the rules, and it decides the design
DeviceLocal.SetDnsResolverSettingsreturns 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:
The mux-disabled branch turns out to be self-consistent rather than a hazard:
dnsResolverSettingsWithLockreturns 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 —upgradeMuxSettingsis initialized to the default and the only non-test caller ofSetUpgradeMuxSettingsis 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'sif letreads as "leave the extension's resolver alone". Sopersisttakes 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 onBlockActionOverridesMirrorTests:SdkAsyncLocalStateover 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 theDnsSettingsprojection — 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.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa