Persist split rules in the app's own local state - #340
Open
Ryanmello07 wants to merge 1 commit into
Open
Conversation
The split rules live in the network extension's container, which the app process cannot read. BlockActionsStore held no copy of its own, so with the tunnel down the app had no source of rules at all: the sheet showed the never-configured empty state over rules the extension still had on disk, and a rule added in that state lived only in DeviceRemote's memory and went away with the process. Worse than not saving, it destroyed. DeviceRemote's offline mutators rebuild an authoritative full list from whatever base they can see, and with nothing seeded that base is empty -- so the truncated list crossed on the next connect and replaced the extension's saved rules. Give split rules the two halves transport settings already has here: the store mirrors every list it can vouch for as the whole list, and initDevice seeds the next device from that mirror. The app is the sole author of these rules -- DeviceLocal changes its list only through the rpc setters the app drives, plus its own startup seed -- so the mirror replacing the extension's copy on connect is the one author replaying its own record, not two authors racing. Two distinctions the fix rests on, both covered by the new tests: a store that was never written reads back nil and seeds nothing, so rules left by an older build survive the first launch after this ships; and an empty list is written as an empty list rather than as nil, because nil deletes the store and a deleted store reads back as "never edited", which would resurrect the rules the user just removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa
This was referenced Sep 8, 2026
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
Split rules do not survive leaving the app. Configure a few, close the app, come back, and the Local statistics card reads "0 split rules" over the never-configured empty state — while the rules are still sitting in the extension's
.block_action_overrides.The rules live in the network extension's container, which the app process cannot read.
BlockActionsStorekept no copy of its own:splitRulesis written only fromdevice.getBlockActionOverrides(), and with the RPC service downDeviceRemoteanswers out of its own memory (device_rpc.go:4661-4663), which a fresh process has nothing in. An empty answer is indistinguishable from "the user has no rules", so the app renders zero.It also destroys rules, which is the worse half
DeviceRemote's offline mutators rebuild an authoritative full list from whatever base they can see (device_rpc.go:4529-4534). With nothing seeded, that base is empty. So a rule added while the tunnel was down does not merely fail to save — the truncated one-element list crosses on the next connect andDeviceLocalRpc.Syncapplies it as a wholesale replace (device_rpc.go:9092-9093→device_local.go:5642), erasing the rules the extension had persisted.The fix
Give split rules the two halves transport settings already has in this tree:
BlockActionsStoretakes alocalState:insetup, exactly asTransportSettingsStore.setup(_:localState:)does, and writes the rules to the app's own store — but only from a read it can vouch for as the whole list. Two reads qualify: one taken off a connected device (the extension's own list), and one taken right after an edit made here (that list plus the edit). Mirroring anything else would replace the app's only durable copy with a list it never read.DeviceManager.initDeviceseeds the next device from that mirror, next to the transport-settings seed whose comment already names this exact hazard.A remote-connected listener re-reads once the RPC is actually up, because the override change the extension replays on connect arrives over the reverse sync — before the remote publishes its service — so that read still comes out of the remote's own memory. This is also what backfills the mirror on the first connect after upgrade.
Why app-wins on connect cannot lose data
The patch adds no arbitration, because the wire has none to add it to —
deviceRemoteValuecarries only{Value, IsSet}. What it changes is what the app is allowed to put in that slot.These rules have exactly one author.
DeviceLocalchanges its override list solely through the RPC setters the app drives, plus its own construction seed from its own dotfile (device_local.go:1374); nothing inapp/extensiontouches block actions. So "the app wins" is the single author replaying its own record, not two authors racing. The extension's copy is a replica.Two distinctions carry the rest, and both are pinned by the new tests:
if letqueues nothing andstate.BlockActionOverrides.IsSetstays false. Rules left by a pre-fix build survive the first launch after this ships untouched. The unreadable/corrupt case reports the same way and fails toward the extension's copy too.LocalState.SetBlockActionOverrides(nil)is anos.Remove, and a deleted store reads back as "never edited" — which would resurrect the rules the user just deleted.exportedList.MarshalJSONrenders empty as[], so "I removed my last rule offline" round-trips as a real value.One incidental improvement: because the mirror is written from the read-back rather than from the pushed value,
hostedSafeBlockActionOverridestripping a route override converges the app onto the extension's stored form instead of re-pushing the pre-strip version forever.Known limitation
The seed makes
state.BlockActionOverrides.IsSettrue on every cold launch, so the first sync of each launch is a full replace. If the app's mirror is ever behind the extension's real list, that difference is lost silently. The mirror can only fall behind in narrow windows — an edit whose RPC succeeded but whose store write then failed, or an app container restored from backup out of step with the extension's — and the next connected read re-mirrors. This is the same contractrouteLocal,blockerEnabled,connectLocationand the transport settings already operate under, but it is worth stating rather than discovering.Not in scope
DnsSettingsStoretakes nolocalStateeither, andSdkLocalStatealready exposesgetDnsResolverSettings/setDnsResolverSettings, so it takes the same Swift-only treatment. Left for a follow-up to keep this diff to one subject. Note the two DNS-specific guards:DeviceLocal.SetDnsResolverSettingsreturns early without persisting on nil settings and when the mux is disabled, so a mirrored write there is not proof of an extension-side write — it would need to mirror from the read-back, as this patch already does.DeviceRemote's offline mutators still rebuild a full list from a possibly-empty base andSyncstill applies it unguarded. This patch stops the iOS app from feeding it a bad list; it does not disarm it. A base-relative apply for list-valued settings is a wire change and belongs in its own SDK PR.sdk/device_rpc_test.go'sTestDeviceRemoteBlockAndDnsshares one network space between theDeviceRemoteand theDeviceLocal, so both sides read the same dotfile. That is why the suite is green while iOS loses data. Callingtesting_newNetworkSpacetwice — ascontrol_ip_family_test.goandlog_verbosity_test.goalready do for this exact two-process problem — would make this class of bug visible in the repo where the footgun lives.SplitRulesView.swift:721) andupdateRulewith empty hosts delegates toremoveRule, so "Update" with everything deselected deletes the rule. Pre-existing, and there is a separate "Remove rule" button; not touched here.Tests
BlockActionOverridesMirrorTestspins the store contract with no device and no mocking —SdkAsyncLocalState(localStorageHome)over a per-test temporary directory. Covers the id/hosts/mode round trip, fields the app does not model (blockOverride, a missingrouteOverride), empty-round-trips-as-empty, nil-deletes-the-store, and never-written-reads-nil.No test is proposed for
BlockActionsStoreitself:SdkDeviceRemoteis a concrete gomobile class rather than a protocol, and every existing file innetworkTestsis a pure-function or value-type test. Inventing a mocking layer for one store would be a larger and riskier change than the fix.Verification
Builds for iOS and macOS; the new tests pass.
On-device checks this needs before merge, in order — the first launch after upgrade is expected to still show zero, because no mirror exists yet and reading a missing mirror as "no rules" is the wipe the design deliberately refuses:
The VPN must actually stop for 2-5 to be the disconnected case: with providing on or route-local off the extension keeps running and the rules would have rendered anyway.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AWV4MaF9JBcQppSX9UxATa