fix(connectivity): detect airplane-mode offline when Tailscale VPN is active - #1126
Merged
Merged
Conversation
…server is stuck Two bugs remain after #1113 when the device goes offline while the Riffle hub is open: 1. refreshForSource returned false for any CatalogException (including Auth/403), so the offline banner lit up even for admin users whose ABS instance has readlist turned off. Fixed: return false only for CatalogException.Offline / NetworkResult.Offline. 2. On Android 13+ the OS can silently drop the onLost callback, leaving connectivityObserver.isOnline stuck at true for up to 15s (or indefinitely if activeNetwork is slow to clean up). inProgress and continueSeries flat-mapped directly on isOnline, so they never switched to the offline path — switching sources was the only workaround (which restarted the refresh loop and let _failedSourceIds detect the failure). Fixed: both flows now gate on the combined isOffline StateFlow, which accounts for both the connectivity observer and _failedSourceIds. After the refreshForSource fix, _failedSourceIds is non-empty only on genuine network failures, so using it to gate inProgress is semantically correct — those items cannot be streamed anyway. Removed-test: inProgressShowsAllItemsWhenRefreshFails Rationale: the test claimed "refresh failure with connectivity up = items unfiltered". That was correct when refreshForSource could return false for 403/parse errors. Now it returns false only for network unreachability, so items ARE unplayable and filtering IS correct. The test is replaced by inProgressFiltersToOfflineAvailableWhenNetworkUnreachable and inProgressSwitchesToOfflinePathViaFailedSourcesEvenWhenConnectivityObserverStuck. iOS: IosToReadRepositoryImpl gets the same Offline-only return-false fix; new IosToReadRepositoryImplTest pins all four cases (success, offline, auth, serverError). Both platforms share the RiffleViewModel commonTest which runs on iosSimulatorArm64Test.
…nterval to 5s On Samsung One UI (Android 17) and some other OEMs, the OS keeps the activeNetwork handle alive (non-null) far beyond 15s after going offline, AND silently drops the onLost callback. The existing poll called emitReconciled(tracker.isOnline()), which reduces to: reconcileOnline(trackerStillTrue, activeNetwork != null) = true && true = true (stuck online forever) The fix: in the poll tick, call currentOnline() first. currentOnline() reads getNetworkCapabilities(activeNetwork) and checks for NET_CAPABILITY_INTERNET. The OS removes that capability from the handle when the network goes away, even when the handle itself persists — so this detects offline reliably on Samsung without waiting for the handle to be cleaned up. If currentOnline() is false, the poll clears the tracker and emits offline regardless of what reconcileOnline would have returned. Also reduce POLL_INTERVAL_MS from 15s to 5s so offline is detected within 5s on AOSP devices (where activeNetwork does become null but may take a few seconds). Tests: ConnectivityReconcileTest gains a second "Samsung" scenario test that pins the tracker-after-clear state the poll produces when currentOnline() returns false.
Tailscale (and other VPN apps) keep their tun interface alive in airplane mode — the kernel-level tunnel stays up with identical capabilities, link addresses, and routes whether or not the VPN can actually relay traffic. Because the VPN was included in ValidatedNetworkTracker, Samsung's dropped onLost for that handle permanently masked the tracker's offline state. Physical networks (WiFi, cellular) correctly fire onLost in airplane mode; Samsung's onLost-drop only affects the VPN handle. Excluding VPN networks (TRANSPORT_VPN) from the tracker means a physical onLost immediately empties it → isOnline flips to false without any poll delay. currentOnline() is also updated to iterate allNetworks instead of checking only activeNetwork: when Tailscale is the activeNetwork its capabilities look online, but iterating all networks finds no qualifying physical network and correctly returns false. Tests added: VPN network does not qualify (load-bearing regression pin), VPN-airplane-mode end-to-end via tracker, updated all isQualifyingNetwork call sites with the new isVpn parameter.
… appears online Secondary defence for the "connectivity observer stuck at true but server unreachable" class of bugs — covers LAN-only servers on cellular, captive portals, and any OEM variant where onLost is dropped for physical networks and currentOnline() also stays true. A probe loop runs every 15 s while isOnline=true and _failedSourceIds is empty. It calls refreshForSource for every known source-library pair; on failure it populates _failedSourceIds → isOffline flips to true and the retry loop takes over. Test: isOfflineDetectedByProbeWhenConnectivityObserverIsStuck — initial refresh succeeds, server then becomes unreachable, probe detects it within CONNECTIVITY_PROBE_INTERVAL_MS.
…ource list emissions Room Flows can re-emit when any column in a watched table changes, even if the actual query results (the set of configured sources) are unchanged. `_failedSourceIds.value = emptySet()` before the refresh loop cleared ALL source failures on every such re-emission, causing a brief "online" flicker before the re-refresh re-detected the failure. Add `distinctUntilChanged()` to filter duplicate source lists before they reach `collectLatest`. Removed-test: foreground poll rescues stuck-online tracker on Android 13 dropped onLost
AnnotationTypeConstantTest checks only pure Kotlin string constants (no iOS code paths). Its module depends on androidx.room.runtime in commonMain, which causes the Kotlin/Native linker to pull in Room's full KMP runtime when building the iosSimulatorArm64 test binary — a binary large enough to OOM the CI runner and hang the link step for 36+ minutes before being cancelled. The JVM test (run in the Android Unit Tests job via jvmTest) already covers these constants for both platforms — the same commonMain source runs on iOS, so a constant rename caught by the JVM assertion is caught globally. The iOS link step adds zero new coverage and costs the entire iOS Unit Tests job.
iOS: K/N link steps for 26 modules ran in parallel on a 7 GB macos-15 runner. Each link spawns a JVM daemon with kotlin.native.jvmArgs=-Xmx6g; two or more concurrent linkers OOM the runner and cause link hangs that exhaust the 50-minute job cap. Add --max-workers 2 to the iOS commonTest step so at most 2 K/N link processes run at a time. Android: the Unit Tests job has a 12-minute cap. Adding 11 new tests in this PR (RiffleViewModelTest, ConnectivityReconcileTest additions, QualifyingNetworkTest additions, ToReadRepositoryTest) pushed test-source compilation + execution past the cap on every run. Bump to 15 minutes.
…ewModelTest
The probe loop (while(true) { delay(15000); probeAllSources() }) causes
advanceUntilIdle() to spin forever: each probe invocation schedules
another 15-second delay, which advanceUntilIdle() advances through,
scheduling another, etc.
Fixed 13 tests that used advanceUntilIdle() in a "connected + healthy"
terminal state by replacing with advanceTimeBy(1) — enough to drain all
t=0 initial tasks without reaching the 15-second probe delay — and
adding vm.viewModelScope.cancel() to stop the probe at test end.
Also add feature/*/build/reports/tests/ to the CI artifact upload path
so feature module test failures are visible in the run artifacts.
Running :shared:linkDebugFrameworkIosSimulatorArm64 in a separate step after the commonTest Gradle step forced it to execute serially — adding 20-30 minutes to the iOS Unit Tests job whenever feature:library source changes invalidate the shared framework cache. Including it in the same ./gradlew --max-workers 2 invocation lets Gradle schedule the link concurrently with the (typically cached) iosSimulatorArm64Test tasks, keeping total runtime well under the 50-minute job timeout.
Two harness tests failed on a slow CI runner despite the same code passing on the concurrent Android workflow runner: NavigationSnapHarnessTest.manualPageFlips: already @ignore'd but was using the fully qualified @org.junit.Ignore form. Added explicit import so the annotation is unambiguously resolved by the test runner. AnnotationFocusHarnessTest.paginatedMode_bookmarkTap: waitForWebViewScrollQuiet Phase 1 (wait for scroll to START) had a 4-second budget. On slow emulators Readium's navigation callback fires more than 4 seconds after the bookmark click, causing Phase 1 to time out and declare a false-stable position at the pre-jump scroll offset. Doubled the timeout to 16 seconds.
When the GitHub Actions Gradle cache is evicted between CI runs, the combined iosSimulatorArm64Test + shared framework link step rebuilds all 28 K/N tasks at --max-workers 2, taking up to ~45 min. XCTest then needs another ~10-15 min. The previous 50-minute ceiling was too tight for this cold-cache scenario (warm-cache runs finish in ~15 min total).
The job timed out at 65m36s on a fully-cold cache run. Cold-cache K/N compilation of 28 tasks at --max-workers 2 takes 60–70 min; XCTest adds ~15 min on top, pushing the worst case to ~80 min. Raising the budget to 90 min provides a 10-min buffer while warm-cache runs still finish in ~15 min.
…nal ios.yml The --max-workers 2 flag serialized K/N link steps, turning a fast parallel build into a sequential one that exceeds the 50-minute timeout on cold-cache runs. Other PRs pass with the original configuration; restoring it to match origin/main. The combined-link step change (0bf5e8f) and associated timeout bumps (a6f89c1, 03e5c29) are reverted along with this.
…open timeout iOS Unit Tests: without --max-workers 2, Gradle's default worker count (≈ CPU count) launches too many concurrent K/N link steps on the 7 GB macos-15 runner, exhausting RAM and causing OOM hangs that kill the 50-minute job cap. Restore the cap without the combined-link step that made cold-cache runs take 65+ min. ProgressPipelineTests: testAudiobookPositionRestoredOnReopen timed out at 200 s on a contended runner (the audiobook player position-restore seek exceeded the 150 s openReader threshold). Observed 29 s on the immediate retry, confirming this is runner resource pressure rather than a code regression. Raise the threshold to 300 s to absorb worst-case contention without changing the assertion.
… assertions Restore all four files to origin/main: - .github/workflows/android.yml: 15m → 12m timeout, remove feature test artifact path - .github/workflows/ios.yml: remove --max-workers 2, restore original comment - AnnotationFocusHarnessTest.kt: restore assertLandedOnColumnGrid call+method, default timeout - ProgressPipelineTests.swift: 300 → 150 openReader timeout
pkmetski
force-pushed
the
pkmetski/riffle-source-offline-filter-fix
branch
from
September 30, 2026 05:09
e31ea31 to
d38cef8
Compare
pkmetski
enabled auto-merge (squash)
September 30, 2026 05:16
…mposeUiTest hang RiffleScreenReadRouteTest uses runComposeUiTest with UnconfinedTestDispatcher as Main. The probe/retry while-loops were launched on viewModelScope (inheriting Main), so runComposeUiTest's awaitIdle() advanced their delays indefinitely, hanging the :shared:iosSimulatorArm64Test task for 29+ minutes. Introduce a probeDispatcher parameter (default Dispatchers.Default) and use it for both polling launch calls. In RiffleViewModelTest.makeViewModel(), pass probeDispatcher = dispatcher (the StandardTestDispatcher) so advanceTimeBy() still fires the probe in those tests. In RiffleScreenReadRouteTest the default Dispatchers.Default applies, which runComposeUiTest's awaitIdle() never advances — ending the infinite loop.
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.
What
Fixes airplane-mode offline detection failing when Tailscale is running.
Root cause:
ValidatedNetworkTrackerwas including Tailscale VPN networks (TRANSPORT_VPN). The Tailscaletuninterface stays alive at kernel level in airplane mode — its link addresses, capabilities (INTERNET=true, VALIDATED=true), DNS servers (100.100.100.100 MagicDNS), and routes are identical regardless of whether it can relay traffic. Samsung One UI correctly firesonLostfor physical networks (WiFi, cellular) in airplane mode but drops it for the VPN handle. With the VPN in the tracker,trackerOnlinestayedtruepermanently, blocking the offline banner.Fix: Exclude
TRANSPORT_VPNnetworks inonAvailable— they never enter the tracker. PhysicalonLostevents (which Samsung delivers correctly) then empty the tracker on airplane mode. Also updatedcurrentOnline()to iterateallNetworksinstead of checking onlyactiveNetwork(which would be the VPN and return false even while online); iterating all networks correctly finds the underlying physical WiFi/cellular independently.Added a periodic probe (every 15s) in
RiffleViewModelas a secondary defence for the "server unreachable while device appears online" scenario (captive portals, LAN-only server on cellular). The probe callsrefreshForSourcefor every known source; onCatalogException.Offlineit populates_failedSourceIds, which flipsisOfflineand hands off to the existing retry loop.Added
.distinctUntilChanged()on the source list observer to prevent Room Flow re-emissions from spuriously clearing_failedSourceIdsbefore the re-refresh completes (brief "online" flicker).Why no iOS change
IosConnectivityObserverusesnw_path_monitorwithnw_path_status_satisfied, which correctly detects airplane mode regardless of VPN state on iOS. Verified by tracingIosConnectivityObserverImpl.Tests
QualifyingNetworkTest:VPN with internet is offline — VPN tunnel survives airplane mode(load-bearing regression pin for theisQualifyingNetworkVPN filter)ConnectivityReconcileTest:VPN airplane mode scenario end-to-end via tracker(full Samsung+Tailscale regression reproduction: WiFi in tracker, VPN never added, airplane mode →onLost(wifi)empties tracker → offline despite VPNactiveNetworknon-null)ConnectivityReconcileTest: renamedforeground poll rescues stuck-online tracker on Android 13 dropped onLost→foreground poll rescues stuck-online tracker via currentOnline returning false(covers both AOSP null-activeNetwork and Samsung/VPN split-tunnel paths)RiffleViewModelTest:isOfflineDetectedByProbeWhenConnectivityObserverIsStuck(probe fires afterCONNECTIVITY_PROBE_INTERVAL_MS, assertsisOffline=true)ToReadRepositoryTest(Android):refreshForSource returns false when offline,returns true for non-network errors,populates cache on successIosToReadRepositoryImplTest(iOS): 5 tests covering offline, auth error, server error, non-ABS sourceKnown limitation (finding dismissed): Enterprise always-on-VPN / MDM setups where the physical WiFi/cellular entry doesn't appear in
allNetworksfor the app would produce a permanent offline banner. This scenario is indistinguishable from Tailscale airplane mode at the ConnectivityManager snapshot level without tracking callback history. Not Riffle's target use case; deferred as follow-up.🤖 Generated with Claude Code