Observation coordinator

September 18, 2026 · View on GitHub

Status: connected to the app through StateCache, including the shared batched root reader, Dashboard, targets, statistics, routing readiness and app inventory. Configuration remains owned by ConfigCoordinator; observations never overwrite its confirmed values or optimistic intent.

Ownership and publication

ObservationCoordinator runs the pure reduceObservation reducer under a short synchronized admission/publication lock. Loads and deadline timers belong to a process-lived IO scope. Closing a screen or cancelling an awaiting bridge request only detaches that waiter. Missing application inputs leave a cache pristine; explicit reads return InitializationPending until inputs are supplied.

One immutable ObservationState contains the last successful value, active request, generation, error and quarantine state. Compatibility flows are synchronous projections of that state. A loader returns its complete payload and must not publish secondary state. AppListSnapshot therefore owns apps, profile names, partial-scan warning and root observation ID together. The bridge reads this packet once when rendering an app-list response.

value retains the last successful observation while loading and after failure. It is useful display history, not evidence of current readiness. current is null while loading, stale, failed or quarantined. RoutingGateCache.gate uses current: a shared refresh marks the prior value stale before its direct helper read. The one-second foreground comparison is silent and does not enter this state when its result is unchanged. A real refresh changes readiness freshness, not diagnostic result classification or retention. For eligibility, an invalidated observation that still awaits its re-read is Checking; Unknown is reserved for a read that failed, a quarantined source, or an attempt that never produced a value. Mapping the stale window to Unknown flashed "couldn't determine whether VPN Hide is routed" on every VPN toggle. What the screens render during that window is not the eligibility but the routing knowledge the presentation keeps (RoutingKnowledge.Verifying with the last known fact and the read's reason): a user-requested or network-triggered re-read shows a neutral "Checking…" at once, a background one keeps the last known state for a 2 s grace (transition contract §8). AppVpnStatePoller runs while the main UI is RESUMED, with a one-second delay between completed samples. Each sample is a silent root-helper request for the app-scoped VPN state: current framework VPN session and interfaces plus this app UID's policy rule membership. It does not read or compare global route/rule text and does not refresh RootSnapshotCache. An equal sample leaves the shared observation untouched, so the timer cannot make a stable screen enter Checking. Only a changed fact (or a successful recovery sample after a failed/stale read) requests a process-owned StateCache refresh. That refresh publishes a routed result immediately. VPN_OFF and EXCLUDED require two equal samples 750 ms apart; a state/session change continues settling instead of publishing the tunnel's setup edge as a current negative fact. Probe failures are ignored by the silent timer, never translated to VPN-off. A quarantined or active shared read is not replaced by the timer. ON_RESUME always requests fresh present-tense evidence, and explicit Retry remains available.

The confirmation suite has one owner too, and it is not an edge detector. DiagnosticsCache runs confirmMeasurements over its own presentation: whenever owedConfirmation finds this app eligible with a current measurement key (subject, self configuration, routing identity, coverage, change epoch) that nothing covers, it requests one automatic run for that key. "Covers" means the presented measurement was taken under that key, or the latest non-blocked attempt was (whatever its outcome: a failure is answered by Retry, never by a loop), or the owner already asked for it. A run in flight or a quarantined probe owes nothing. Whether a confirmation is pending is itself a field of the presentation (confirmationPending, computed from the key the owner last claimed), so the Situation words that window as the run it becomes (Checking) instead of as a stale result asking for a manual re-check, and the owner and the surfaces read one fact. The request waits a 300 ms settle window, restarted by every newer presentation so the separate emissions of one change produce one run, and keeps at least five seconds from the previous automatic request so a flapping VPN cannot keep the suite busy. Because the rule reads state rather than transitions, a cold start already routed, a foreground return after the tunnel was re-established (the excluded → included split-tunnel case: a new framework network id is a new routing identity) and a session change seen by the poller all take the same path, and none of them can be lost to a missing baseline. A poll that reveals no new key reruns nothing; the negative confirmation in RoutingGateCache still suppresses the brief false exclusion seen during tunnel setup.

The Dashboard's screen state is a projection, not a cache. DashboardCache caches only DashboardRootFacts, the half that costs root shells and changes with the root snapshot (modules, LSPosed, targets, environment, installed optional hooks); DashboardCache.state combines those facts with the live presentation and assembles the protection tiles, the banners and the screen state on every change of either (assembleDashboardState, pure apart from wording).

A third source joins that combine: DeveloperFlagsCache, the app-local switches on Settings → For developers that change what the Dashboard says (the agent-bridge note, the version-mismatch notices). They are preferences, not observations — a DataStore write invalidates nothing — so holding them inside the cached facts meant a toggle did nothing until the next refresh. As a source of the projection they reach the banners immediately and cost no root read. The canonical config's debugSwitch is not one of them: that one genuinely is read off the device and stays in the environment facts. The tiles and the hero therefore always describe the same instant: nothing follows the suite, nothing compares attempt ids, and a fresh measurement can never sit beside the previous tiles. The state stays null until the root facts exist and the suite has a first terminal attempt, so the Dashboard appears with its first verdict, as before.

There is no ConnectivityManager listener for VPN-state auto-refresh. The app's own Java backend intentionally hides VPN lifecycle changes when its visible network is unchanged, so those callbacks cannot be a reliable trigger. Diagnostic callback registrations remain: they measure hiding rather than drive readiness refresh. Device validation on 2026-09-17 is recorded in the polling/callback report.

The bridge's one-shot state read still joins or reads the terminal attempt (DiagnosticsCache.awaitTerminal) and assembles the same Dashboard state from it (loadDashboardState), so its tiles and the screen's cannot diverge. Refreshing a cache re-reads that cache's own source and nothing else: ContextStateCache has no hook that starts work in another domain, and DashboardCache.refresh never requests a diagnostic run. The explicit run has one entry point, retryDiagnosticsAndDashboard (Retry on both screens, pull-to-refresh, the post-reset re-check): it refreshes the app-VPN observation, queues one explicit diagnostic run and re-reads the Dashboard's root facts. An explicit retry never joins an already-running automatic suite; it waits as the single pending successor, so the user's click cannot disappear. The startup intent (DiagnosticsCache.run: the first suite of the process, a join or a read afterwards), the owed confirmation above and the explicit retry are the only ways a suite starts. Admission never refuses an automatic request at rest; whether one is owed is decided from the presentation. Completed-result retention and measurement classification are unchanged. Screen retry triggers observe known routing transitions: a temporary unknown value during refresh does not count as VPN returning. A terminal failure leaves the Dashboard loading placeholder and remains available for manual retry.

Diagnostic runs are now owned by the process-lived DiagnosticRunCoordinator behind DiagnosticsCache (transition contract §7). Its eligibility read at Checking and its end-context read at Verifying reuse a current app-VPN observation, join an in-flight read, and force the dedicated helper observation only when it is stale, failed or absent. The app-VPN observation's session, interfaces and UID verdict form the measurement routing identity; backend coverage and boot identity still come from RootSnapshotCache. Dashboard derivation joins the diagnostic run through its own handle and reads its finished attempt without starting another suite.

Invalidation advances the generation synchronously. A result from an older generation cannot publish either a value or an error. Its completion starts one successor for the newest requested generation. Equivalent refreshes join the active request; an explicit notBefore timestamp can demand a newer collection. Awaiters follow a superseded request to its successor within their own deadline. An ordinary Ensure does not retry a failed attempt in the same generation.

Every re-read states why it happens. ReadReason is Background (our own process invalidated the observation — a root dependency after a config phase or the startup reconcile, or the foreground-return safety net), Transition (an external signal that the observed fact may have changed: the foreground app-VPN poller found a changed or recoverable fact) or Explicit (the user asked: Retry, refresh, pull-to-refresh, a manual re-check). An observation that is owed a re-read carries a StaleMark(reason, since): set by an invalidation and by a refresh that requests a newer generation, kept while that re-read is owed or running, carried over to the successor of a superseded read, and cleared when the current generation publishes or fails. Overlapping causes keep the earliest since and the strongest reason — Explicit never degrades to Background because a routine invalidation arrived afterwards. The request that is started carries that reason, so a read in flight can be attributed after the mark is gone. Nothing renders this yet; the presentation consumes it in the next stage to word a background top-up differently from a user-requested re-check.

Root dependencies

The root reader uses the same coordinator. Each accepted snapshot has an observation ID and generation. Dashboard, targets and statistics wrap their domain values in RootProjection, retaining this provenance without changing serialized bundle or bridge models. App-VPN readiness is intentionally independent of this dependency graph and owns its direct helper observation.

Root invalidation and a new root refresh synchronously invalidate registered dependents before asynchronous work starts. Registration is lazy and process-lived; callbacks only invalidate, never read or wait. A cold dependent advances its generation but does not load before initialization. Repeated invalidations during an existing read coalesce into one successor, not overlapping root shells.

The mutation coordinator invalidates root observations at every matching phase completion/recovery before exposing phase evidence. Its separate refresh worker awaits this shared reload. It never waits for Dashboard/diagnostics before accepting the next config operation.

Startup self-target preparation runs the same snapshot command as the cache (including the runtime probe). When it writes nothing, its validated sections seed the cache's next load whole (seedSnapshot), so the cold start does not pay a second identical root shell; after a config write only the package inventory seed survives, and any invalidation or explicit refresh discards both seeds. A seeded value is an observation like any other: it gets the request's observation ID and generation when consumed.

The startup runtime reconcile (a forced native activation with no config edit) is one such operation. Cold-start traces on Pixel 8 Pro (2026-09-15, VPN up) showed its completion invalidating the root snapshot about 430 ms after the first read, which restarted the routing gate and the targets/Dashboard derivations while the first derivation and the diagnostic suite were still in flight: a second root snapshot (about 1.1 s, including package inventory) plus a repeated derivation on the critical path, dashboard_ready at about 3.9 s instead of 2.4 s. The reconcile is therefore started only after the Dashboard has painted; its invalidation then refreshes observations in the background.

App inventory subscribes to changes in the accepted pm_packages / pm_users sections instead of every root refresh. Config-only or statistics-only changes therefore do not reload all icons. An explicit app-list refresh also invalidates its scan. UI and bridge requests share this cache; there is no direct loader bypass. If the first root read failed, retry also wakes failed dependents, including inventory, even though no successful source snapshot exists yet.

Deadlines and physical ownership

Each worker has one observation deadline. Expiration fails its awaiters and marks the worker quarantined. It does not cancel the worker or start a replacement. Explicit refresh during quarantine records one retry, but an awaiting caller gets ResourceUnavailable. Only actual worker return releases quarantine; its late result is discarded. A queued refresh or changed generation then starts one successor. Without either, the cache remains failed until explicit retry. There is no automatic endless timeout/retry loop.

The batched root reader uses RootProcessRunner.runAndDrain: a command deadline can stop the launcher, but the worker retains ownership until that process and its stream readers finish. Output retention is bounded; see limits. This is not proof that every root descendant exited after closing its pipes. The snapshot command is read-only and does not intentionally detach work.

Other blocking probes inside the existing Dashboard/routing/diagnostic loaders still use legacy shell/probe adapters. Their coroutine return is not a new proof of descendant-process quiescence. Migrating diagnostic execution, cancellation, capture collection and cleanup is a separate stage.

Presentation boundary and validation

There is no global atomic swap of every screen. While an expensive projection is loading, another screen may already show a newer root observation; the former retains its last-good value. Source IDs make that provenance explicit internally, and generation rejection prevents the old in-flight computation from publishing as the new result. A shared presentation revision incorporating measurements and diagnostic validity remains part of the diagnostic migration.

Host tests control load and timer gates to cover stale success/failure, coalescing, fresh-since requests, waiter cancellation, missing inputs, retry/quarantine and source invalidation during a slow projection. Additional tests cover grouped projections, inventory dependency filtering, config-phase invalidation ordering and process/pipe draining. A diagnostic-observation integration test rejects the VPN-off feedback cycle described above. These tests do not establish Android PackageManager, Binder/root-manager timing or visible behavior on a physical phone.

Validation on 2026-09-15: all 655 JVM tests passed, including 23 new tests, with no failures or skips. Kotlin warnings-as-errors compilation, ktlint, detekt, CPD and Android lintDebug passed. The source and tests were held unchanged throughout the final combined Gradle run.