Recover native tunnel failures safely#620
Conversation
kasnder
left a comment
There was a problem hiding this comment.
Verdict: Solid recovery design, but one release-only blocker — a stale ProGuard keep rule will strip the renamed JNI callback and crash the exact path this PR hardens.
Blocking
app/proguard-rules.pro:38— the keep rule still listsvoid nativeExit(java.lang.String);, but this PR changes the method tovoid nativeExit(int, java.lang.String).releasehasminifyEnabled = true(app/build.gradle:186), so R8 runs; the new 2-arg method has no Java caller (it's invoked by name from JNI, invisible to R8), and line 24's-keepnamesonly prevents renaming of surviving members, not removal — the explicit-keepblock at lines 37–46 is what actually retains these callbacks. Since line 38 no longer matches the new signature, R8 can strip/renamenativeExit(int,String). At runtimejniGetMethodID(env, cls, "nativeExit", "(ILjava/lang/String;)V")(netguard.c:538) then returns NULL and the followingCallVoidMethodaborts — i.e. the native-failure path this PR adds crashes in release. The PR's validation only built the debug variant (no minify), so this wasn't exercised. Fix: change line 38 tovoid nativeExit(int, java.lang.String);.
Should fix
ServiceSinkhole.javanativeExit(int,String)—showErrorNotification(displayReason)fires on every native exit, before the recovery decision. A transient failure that auto-recovers within ~1s still raises a hard error notification, and for non-FD reasons it surfaces the raw technicalreasonstring to the user. This partly contradicts the "recovers transparently" goal; consider notifying only when the budget is exhausted (NO_RETRY), or using a softer transient message.
Nits
session.c(epoll_ctl tun setup) — the two earlier setup failures nowgoto cleanup, but theepoll_ctltun failure only setsstopping = 1and relies on fall-through into thewhile (!stopping)guard. Correct today, but asymmetric and fragile if code is later inserted before the loop; a matchinggoto cleanupwould be consistent.ServiceSinkhole.java—tunnelThreadwas madevolatilehere, but the recovery runnable also readsvpn(written on the command looper) without volatile/synchronization. Minor visibility gap; consider making it consistent. (unverified impact)NativeFailureRecoveryPolicy.onFailure—initialDelayMs << failuresis safe for the shipped config (max shift 4) but would overflow / shift past 63 for a largemaxRetries; guard or document, since the class is generic.
What's good
- JNI migration is otherwise complete and consistent: all 8
report_exitcall sites (session.c ×4, ip.c ×4) updated, plus the declaration (netguard.h) and definition (netguard.c:535);jniCheckExceptionstill guards the upcall so a Java-side failure can't crash the VpnService. - The added
goto cleanupgenuinely fixes a real duplicate-report bug: previouslyepoll_createfailure fell through and re-reported viaepoll_ctlon a negative fd. - Recovery is properly bounded and battery-safe — 1→16s backoff, 5 attempts, then a clean
stop— with a synchronized policy, cancellation on start/stop/onDestroy, and re-validation inside the runnable, so no restart storm.
Automated review by Claude Opus 4.8 (agent-assisted).
The native-failure recovery path changed ServiceSinkhole.nativeExit() from a 1-arg to a 2-arg (int, String) method, but the R8 keep rule still referenced the old signature. Since minifyEnabled=true for release and the method is only looked up by name/descriptor from JNI (jniGetMethodID with "(ILjava/lang/String;)V"), R8 could strip or rename the new method, making CallVoidMethod abort at runtime on native failure. Updated the keep rule to match the real signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nativeExit() showed a hard error notification (raw technical reason for non-FD failures) on every native tunnel exit, before deciding whether to auto-recover. That contradicts the transparent-recovery goal: a transient failure that is silently retried still alarmed the user with a scary, technical notification. Defer showErrorNotification() to the terminal give-up path (recovery budget exhausted / NO_RETRY), where TC is actually going down and the user should be told why. Transient failures now recover silently. Also skip the notification entirely when TC is disabled or intentionally paused, since a native exit is expected there. The reason string is now computed only where it is used. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two follow-ups from review of the native tunnel recovery path: - session.c: the "epoll add tun" setup failure only set stopping=1 and relied on fall-through into the `while (!stopping)` guard, while the two earlier epoll setup failures goto cleanup. Add a matching goto cleanup so the failure path is consistent and stays correct if code is later inserted before the loop. - ServiceSinkhole: mark `vpn` volatile. The native recovery runnable runs on the main looper and reads `vpn` for its pre-check, but `vpn` is written on the command looper; without volatile that cross-thread read had no visibility guarantee (tunnelThread was already made volatile for the same reason). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NativeFailureRecoveryPolicy is a generic utility: with a large maxRetries the shift amount grows, and `initialDelayMs << failures` could overflow a signed long (or shift past 63 and wrap), yielding a negative delay that would make postDelayed fire immediately — a restart storm. Harmless for the shipped config (max shift 4), but guard it since the class is generic. Clamp the shift to Long.MAX_VALUE when it would overflow. Add a test with a generous config asserting every backoff stays positive and non-decreasing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The implementation now looks sound and the earlier release blocker is fixed. One small user-facing correction before merge: |
|
Implemented in |
What changed
Why
A transient native tunnel failure previously disabled protection immediately and required user intervention. Repeated failures also lacked a bounded recovery policy. This change automatically repairs short-lived failures while retaining a finite budget for persistent faults.
User impact
Protection now recovers automatically from transient native tunnel failures. Persistent failures still surface an error and stop after a bounded number of attempts instead of creating a restart loop.
Validation
./gradlew assembleGithubDebug testGithubDebugUnitTest -x wgbridgeBuildThe bridge rebuild task was excluded from the final post-rebase run because it stalled without output; it is unrelated to this change and the existing bridge artifact was used. The same branch passed a complete assembly and unit suite, including that task, before rebasing the single implementation commit onto the current default branch.