From 4bb2fd8197e903814f87d7b69e42e88422a43d02 Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sat, 11 Jul 2026 10:30:07 +0200
Subject: [PATCH 1/6] Recover native tunnel failures safely
---
.../netguard/NativeFailureRecoveryPolicy.java | 54 +++++++++++++++
.../eu/faircode/netguard/ServiceSinkhole.java | 65 ++++++++++++++++---
app/src/main/jni/netguard/ip.c | 20 +++---
app/src/main/jni/netguard/netguard.c | 6 +-
app/src/main/jni/netguard/netguard.h | 2 +-
app/src/main/jni/netguard/session.c | 28 ++++----
app/src/main/res/values/strings.xml | 1 +
.../NativeFailureRecoveryPolicyTest.java | 54 +++++++++++++++
8 files changed, 198 insertions(+), 32 deletions(-)
create mode 100644 app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java
create mode 100644 app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java
diff --git a/app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java b/app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java
new file mode 100644
index 000000000..54b63d012
--- /dev/null
+++ b/app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java
@@ -0,0 +1,54 @@
+/*
+ * This file is part of TrackerControl.
+ *
+ * TrackerControl is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ */
+
+package eu.faircode.netguard;
+
+final class NativeFailureRecoveryPolicy {
+ static final long NO_RETRY = -1L;
+ static final int FILE_DESCRIPTOR_LIMIT_ERROR = 24;
+
+ private final int maxRetries;
+ private final long initialDelayMs;
+ private final long stableWindowMs;
+
+ private int failures;
+ private long lastFailureMs = Long.MIN_VALUE;
+
+ NativeFailureRecoveryPolicy(int maxRetries, long initialDelayMs, long stableWindowMs) {
+ if (maxRetries < 1 || initialDelayMs < 0 || stableWindowMs < 0)
+ throw new IllegalArgumentException();
+
+ this.maxRetries = maxRetries;
+ this.initialDelayMs = initialDelayMs;
+ this.stableWindowMs = stableWindowMs;
+ }
+
+ synchronized long onFailure(long nowMs) {
+ if (lastFailureMs != Long.MIN_VALUE &&
+ (nowMs < lastFailureMs || nowMs - lastFailureMs >= stableWindowMs))
+ failures = 0;
+
+ lastFailureMs = nowMs;
+ if (failures >= maxRetries)
+ return NO_RETRY;
+
+ long delay = initialDelayMs << failures;
+ failures++;
+ return delay;
+ }
+
+ synchronized void reset() {
+ failures = 0;
+ lastFailureMs = Long.MIN_VALUE;
+ }
+
+ static boolean isFileDescriptorExhaustion(int error) {
+ return error == FILE_DESCRIPTOR_LIMIT_ERROR;
+ }
+}
diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
index 5aa8560fd..f6df56f76 100644
--- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
+++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
@@ -175,7 +175,7 @@ public class ServiceSinkhole extends VpnService {
private static Object jni_lock = new Object();
private static long jni_context = 0;
- private Thread tunnelThread = null;
+ private volatile Thread tunnelThread = null;
private ServiceSinkhole.Builder last_builder = null;
private ParcelFileDescriptor vpn = null;
private boolean temporarilyStopped = false;
@@ -196,6 +196,25 @@ public class ServiceSinkhole extends VpnService {
private volatile LogHandler logHandler;
private volatile StatsHandler statsHandler;
+ private static final int NATIVE_RECOVERY_MAX_RETRIES = 5;
+ private static final long NATIVE_RECOVERY_INITIAL_DELAY_MS = 1_000L;
+ private static final long NATIVE_RECOVERY_STABLE_WINDOW_MS = 2 * 60_000L;
+ private final NativeFailureRecoveryPolicy nativeRecoveryPolicy = new NativeFailureRecoveryPolicy(
+ NATIVE_RECOVERY_MAX_RETRIES,
+ NATIVE_RECOVERY_INITIAL_DELAY_MS,
+ NATIVE_RECOVERY_STABLE_WINDOW_MS);
+ private final Handler nativeRecoveryHandler = new Handler(Looper.getMainLooper());
+ private final Runnable nativeRecoveryRunnable = () -> {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ServiceSinkhole.this);
+ if (!prefs.getBoolean("enabled", false) || temporarilyStopped || vpn == null) {
+ Log.i(TAG, "Skipping native recovery because protection is no longer active");
+ return;
+ }
+
+ Log.i(TAG, "Retrying native tunnel after failure");
+ ServiceSinkhole.reload("native tunnel recovery", ServiceSinkhole.this, false);
+ };
+
// Cached preferences for shouldTrackApp() - refreshed in reload()
private volatile SharedPreferences cachedTrackerProtectPrefs;
private volatile boolean cachedManageSystem;
@@ -478,6 +497,8 @@ public void onCallStateChanged(int state, String incomingNumber) {
break;
case start:
+ if (vpn == null)
+ cancelNativeRecovery(true);
start();
break;
@@ -486,6 +507,7 @@ public void onCallStateChanged(int state, String incomingNumber) {
break;
case stop:
+ cancelNativeRecovery(true);
stop(temporarilyStopped);
break;
@@ -1784,6 +1806,7 @@ public void run() {
Log.i(TAG, "Started tunnel thread");
}
+ cancelNativeRecovery(false);
return true;
}
@@ -2088,21 +2111,46 @@ private void stopVPN(ParcelFileDescriptor pfd) {
}
// Called from native code
- private void nativeExit(String reason) {
- Log.w(TAG, "Native exit reason=" + reason);
+ private void nativeExit(int error, String reason) {
+ Log.w(TAG, "Native exit error=" + error + " reason=" + reason);
if (reason != null) {
- showErrorNotification(reason);
-
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
- prefs.edit().putBoolean("enabled", false).apply();
- WidgetMain.updateWidgets(this);
+ String displayReason = (NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(error)
+ ? getString(R.string.msg_native_fd_exhausted)
+ : reason);
+ showErrorNotification(displayReason);
+
+ if (!prefs.getBoolean("enabled", false) || temporarilyStopped)
+ return;
+
+ long delayMs = nativeRecoveryPolicy.onFailure(SystemClock.elapsedRealtime());
+ if (delayMs == NativeFailureRecoveryPolicy.NO_RETRY) {
+ Log.e(TAG, "Native recovery retry budget exhausted");
+ cancelNativeRecovery(false);
+ prefs.edit().putBoolean("enabled", false).apply();
+ WidgetMain.updateWidgets(this);
+ ServiceSinkhole.stop("native recovery exhausted", this, false);
+ return;
+ }
+
+ nativeRecoveryHandler.removeCallbacks(nativeRecoveryRunnable);
+ nativeRecoveryHandler.postDelayed(nativeRecoveryRunnable, delayMs);
+ Log.w(TAG, "Scheduled native recovery in " + delayMs + " ms");
}
}
// Called from native code
private void nativeError(int error, String message) {
Log.w(TAG, "Native error " + error + ": " + message);
- showErrorNotification(message);
+ showErrorNotification(NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(error)
+ ? getString(R.string.msg_native_fd_exhausted)
+ : message);
+ }
+
+ private void cancelNativeRecovery(boolean resetBudget) {
+ nativeRecoveryHandler.removeCallbacks(nativeRecoveryRunnable);
+ if (resetBudget)
+ nativeRecoveryPolicy.reset();
}
// Called from native code
@@ -3260,6 +3308,7 @@ public void onDestroy() {
// Cancel any debounced network-change reload so it doesn't fire post-teardown
networkReloadDebounceHandler.removeCallbacksAndMessages(NETWORK_RELOAD_TOKEN);
+ cancelNativeRecovery(true);
commandLooper.quit();
logLooper.quit();
diff --git a/app/src/main/jni/netguard/ip.c b/app/src/main/jni/netguard/ip.c
index 4f689279b..96008f6f5 100644
--- a/app/src/main/jni/netguard/ip.c
+++ b/app/src/main/jni/netguard/ip.c
@@ -74,12 +74,13 @@ int check_tun(const struct arguments *args,
if (ev->events & EPOLLERR) {
log_android(ANDROID_LOG_ERROR, "tun %d exception", args->tun);
if (fcntl(args->tun, F_GETFL) < 0) {
+ int error = errno;
log_android(ANDROID_LOG_ERROR, "fcntl tun %d F_GETFL error %d: %s",
- args->tun, errno, strerror(errno));
- report_exit(args, "fcntl tun %d F_GETFL error %d: %s",
- args->tun, errno, strerror(errno));
+ args->tun, error, strerror(error));
+ report_exit(args, error, "fcntl tun %d F_GETFL error %d: %s",
+ args->tun, error, strerror(error));
} else
- report_exit(args, "tun %d exception", args->tun);
+ report_exit(args, 0, "tun %d exception", args->tun);
return -1;
}
@@ -88,16 +89,17 @@ int check_tun(const struct arguments *args,
uint8_t *buffer = ng_malloc(get_mtu(), "tun read");
ssize_t length = read(args->tun, buffer, get_mtu());
if (length < 0) {
+ int error = errno;
ng_free(buffer, __FILE__, __LINE__);
log_android(ANDROID_LOG_ERROR, "tun %d read error %d: %s",
- args->tun, errno, strerror(errno));
- if (errno == EINTR || errno == EAGAIN)
+ args->tun, error, strerror(error));
+ if (error == EINTR || error == EAGAIN)
// Retry later
return 0;
else {
- report_exit(args, "tun %d read error %d: %s",
- args->tun, errno, strerror(errno));
+ report_exit(args, error, "tun %d read error %d: %s",
+ args->tun, error, strerror(error));
return -1;
}
} else if (length > 0) {
@@ -119,7 +121,7 @@ int check_tun(const struct arguments *args,
ng_free(buffer, __FILE__, __LINE__);
log_android(ANDROID_LOG_ERROR, "tun %d empty read", args->tun);
- report_exit(args, "tun %d empty read", args->tun);
+ report_exit(args, 0, "tun %d empty read", args->tun);
return -1;
}
}
diff --git a/app/src/main/jni/netguard/netguard.c b/app/src/main/jni/netguard/netguard.c
index 90c19f01d..41de57022 100644
--- a/app/src/main/jni/netguard/netguard.c
+++ b/app/src/main/jni/netguard/netguard.c
@@ -476,10 +476,10 @@ Java_eu_faircode_netguard_Util_is_1numeric_1address(JNIEnv *env, jclass type, js
return numeric;
}
-void report_exit(const struct arguments *args, const char *fmt, ...) {
+void report_exit(const struct arguments *args, int error, const char *fmt, ...) {
jclass cls = (*args->env)->GetObjectClass(args->env, args->instance);
ng_add_alloc(cls, "cls");
- jmethodID mid = jniGetMethodID(args->env, cls, "nativeExit", "(Ljava/lang/String;)V");
+ jmethodID mid = jniGetMethodID(args->env, cls, "nativeExit", "(ILjava/lang/String;)V");
jstring jreason = NULL;
if (fmt != NULL) {
@@ -492,7 +492,7 @@ void report_exit(const struct arguments *args, const char *fmt, ...) {
va_end(argptr);
}
- (*args->env)->CallVoidMethod(args->env, args->instance, mid, jreason);
+ (*args->env)->CallVoidMethod(args->env, args->instance, mid, error, jreason);
jniCheckException(args->env);
if (jreason != NULL) {
diff --git a/app/src/main/jni/netguard/netguard.h b/app/src/main/jni/netguard/netguard.h
index a4f0fbb46..8bf93f166 100644
--- a/app/src/main/jni/netguard/netguard.h
+++ b/app/src/main/jni/netguard/netguard.h
@@ -354,7 +354,7 @@ void handle_signal(int sig, siginfo_t *info, void *context);
void *handle_events(void *a);
-void report_exit(const struct arguments *args, const char *fmt, ...);
+void report_exit(const struct arguments *args, int error, const char *fmt, ...);
void report_error(const struct arguments *args, jint error, const char *fmt, ...);
diff --git a/app/src/main/jni/netguard/session.c b/app/src/main/jni/netguard/session.c
index 1934fbd4b..f8ec568fd 100644
--- a/app/src/main/jni/netguard/session.c
+++ b/app/src/main/jni/netguard/session.c
@@ -57,9 +57,11 @@ void *handle_events(void *a) {
// Open epoll file
int epoll_fd = epoll_create(1);
if (epoll_fd < 0) {
- log_android(ANDROID_LOG_ERROR, "epoll create error %d: %s", errno, strerror(errno));
- report_exit(args, "epoll create error %d: %s", errno, strerror(errno));
+ int error = errno;
+ log_android(ANDROID_LOG_ERROR, "epoll create error %d: %s", error, strerror(error));
+ report_exit(args, error, "epoll create error %d: %s", error, strerror(error));
args->ctx->stopping = 1;
+ goto cleanup;
}
// Monitor stop events
@@ -68,9 +70,11 @@ void *handle_events(void *a) {
ev_pipe.events = EPOLLIN | EPOLLERR;
ev_pipe.data.ptr = &ev_pipe;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, args->ctx->pipefds[0], &ev_pipe)) {
- log_android(ANDROID_LOG_ERROR, "epoll add pipe error %d: %s", errno, strerror(errno));
- report_exit(args, "epoll add pipe error %d: %s", errno, strerror(errno));
+ int error = errno;
+ log_android(ANDROID_LOG_ERROR, "epoll add pipe error %d: %s", error, strerror(error));
+ report_exit(args, error, "epoll add pipe error %d: %s", error, strerror(error));
args->ctx->stopping = 1;
+ goto cleanup;
}
// Monitor tun events
@@ -79,8 +83,9 @@ void *handle_events(void *a) {
ev_tun.events = EPOLLIN | EPOLLERR;
ev_tun.data.ptr = NULL;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, args->tun, &ev_tun)) {
- log_android(ANDROID_LOG_ERROR, "epoll add tun error %d: %s", errno, strerror(errno));
- report_exit(args, "epoll add tun error %d: %s", errno, strerror(errno));
+ int error = errno;
+ log_android(ANDROID_LOG_ERROR, "epoll add tun error %d: %s", error, strerror(error));
+ report_exit(args, error, "epoll add tun error %d: %s", error, strerror(error));
args->ctx->stopping = 1;
}
@@ -181,15 +186,16 @@ void *handle_events(void *a) {
recheck ? EPOLL_MIN_CHECK : timeout * 1000);
if (ready < 0) {
- if (errno == EINTR) {
+ int error = errno;
+ if (error == EINTR) {
log_android(ANDROID_LOG_DEBUG, "epoll interrupted tun %d", args->tun);
continue;
} else {
log_android(ANDROID_LOG_ERROR,
"epoll tun %d error %d: %s",
- args->tun, errno, strerror(errno));
- report_exit(args, "epoll tun %d error %d: %s",
- args->tun, errno, strerror(errno));
+ args->tun, error, strerror(error));
+ report_exit(args, error, "epoll tun %d error %d: %s",
+ args->tun, error, strerror(error));
break;
}
}
@@ -270,6 +276,7 @@ void *handle_events(void *a) {
}
}
+cleanup:
// Close epoll file
if (epoll_fd >= 0 && close(epoll_fd))
log_android(ANDROID_LOG_ERROR,
@@ -368,4 +375,3 @@ void check_allowed(const struct arguments *args) {
s = s->next;
}
}
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8402a02fe..20ecbfbe8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -316,6 +316,7 @@ For this reason, please allow a VPN connection in the next dialog.
Your internet traffic is not being sent to a remote VPN server.
TrackerControl could not start automatically. This is likely because of a bug in your Android version.
An unexpected error has occurred: \'%s\'
+ The VPN ran out of file descriptors. Protection will restart automatically if retry capacity remains.
Secure DNS (DoH) cannot be reached even though you have an active internet connection. If this happens repeatedly, consider disabling it in the Network settings.
Android refused to start the VPN service at this moment. This is likely because of a bug in your Android version.
Try TrackerControl
diff --git a/app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java b/app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java
new file mode 100644
index 000000000..0945f076b
--- /dev/null
+++ b/app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java
@@ -0,0 +1,54 @@
+package eu.faircode.netguard;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class NativeFailureRecoveryPolicyTest {
+ @Test
+ public void retriesUseBoundedExponentialBackoff() {
+ NativeFailureRecoveryPolicy policy = new NativeFailureRecoveryPolicy(5, 1_000L, 120_000L);
+
+ assertEquals(1_000L, policy.onFailure(0L));
+ assertEquals(2_000L, policy.onFailure(1_000L));
+ assertEquals(4_000L, policy.onFailure(3_000L));
+ assertEquals(8_000L, policy.onFailure(7_000L));
+ assertEquals(16_000L, policy.onFailure(15_000L));
+ assertEquals(NativeFailureRecoveryPolicy.NO_RETRY, policy.onFailure(31_000L));
+ }
+
+ @Test
+ public void stableRunRestoresRetryBudget() {
+ NativeFailureRecoveryPolicy policy = new NativeFailureRecoveryPolicy(2, 500L, 10_000L);
+
+ assertEquals(500L, policy.onFailure(0L));
+ assertEquals(1_000L, policy.onFailure(500L));
+ assertEquals(500L, policy.onFailure(10_500L));
+ }
+
+ @Test
+ public void clockResetRestoresRetryBudget() {
+ NativeFailureRecoveryPolicy policy = new NativeFailureRecoveryPolicy(1, 250L, 10_000L);
+
+ assertEquals(250L, policy.onFailure(20_000L));
+ assertEquals(250L, policy.onFailure(1_000L));
+ }
+
+ @Test
+ public void explicitResetRestoresRetryBudget() {
+ NativeFailureRecoveryPolicy policy = new NativeFailureRecoveryPolicy(1, 250L, 10_000L);
+
+ assertEquals(250L, policy.onFailure(0L));
+ assertEquals(NativeFailureRecoveryPolicy.NO_RETRY, policy.onFailure(1L));
+ policy.reset();
+ assertEquals(250L, policy.onFailure(2L));
+ }
+
+ @Test
+ public void identifiesFileDescriptorExhaustion() {
+ assertTrue(NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(24));
+ assertFalse(NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(23));
+ }
+}
From 9f218dbce1132987f3e6140c0c5bf8d5fc5e6401 Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sat, 11 Jul 2026 13:06:51 +0200
Subject: [PATCH 2/6] Update ProGuard keep rule for nativeExit(int,String)
signature
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
---
app/proguard-rules.pro | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index e3c99b966..865785503 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -35,7 +35,7 @@
-keep class eu.faircode.netguard.ResourceRecord { *; }
-keep class eu.faircode.netguard.Usage { *; }
-keep class eu.faircode.netguard.ServiceSinkhole {
- void nativeExit(java.lang.String);
+ void nativeExit(int, java.lang.String);
void nativeError(int, java.lang.String);
void logPacket(eu.faircode.netguard.Packet);
void dnsResolved(eu.faircode.netguard.ResourceRecord);
From d5ee8fe6adc607b95c9e14e44a17decf7b588ed7 Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sat, 11 Jul 2026 13:26:53 +0200
Subject: [PATCH 3/6] Only notify on native failure once recovery is exhausted
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
---
.../java/eu/faircode/netguard/ServiceSinkhole.java | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
index f6df56f76..547985854 100644
--- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
+++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
@@ -2115,17 +2115,18 @@ private void nativeExit(int error, String reason) {
Log.w(TAG, "Native exit error=" + error + " reason=" + reason);
if (reason != null) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
- String displayReason = (NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(error)
- ? getString(R.string.msg_native_fd_exhausted)
- : reason);
- showErrorNotification(displayReason);
+ // A native exit while TC is disabled or intentionally paused is expected: stay silent.
if (!prefs.getBoolean("enabled", false) || temporarilyStopped)
return;
long delayMs = nativeRecoveryPolicy.onFailure(SystemClock.elapsedRealtime());
if (delayMs == NativeFailureRecoveryPolicy.NO_RETRY) {
+ // Recovery budget exhausted: TC is going down, so surface the failure to the user.
Log.e(TAG, "Native recovery retry budget exhausted");
+ showErrorNotification(NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(error)
+ ? getString(R.string.msg_native_fd_exhausted)
+ : reason);
cancelNativeRecovery(false);
prefs.edit().putBoolean("enabled", false).apply();
WidgetMain.updateWidgets(this);
@@ -2133,6 +2134,7 @@ private void nativeExit(int error, String reason) {
return;
}
+ // Transient failure: recover transparently, without alarming the user.
nativeRecoveryHandler.removeCallbacks(nativeRecoveryRunnable);
nativeRecoveryHandler.postDelayed(nativeRecoveryRunnable, delayMs);
Log.w(TAG, "Scheduled native recovery in " + delayMs + " ms");
From d46f7021d18dbd6c87381a523522cebc2f1ee8ad Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sat, 11 Jul 2026 13:34:26 +0200
Subject: [PATCH 4/6] Address native-recovery review nits: consistent cleanup +
vpn visibility
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
---
app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java | 2 +-
app/src/main/jni/netguard/session.c | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
index 547985854..a57afdc4f 100644
--- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
+++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
@@ -177,7 +177,7 @@ public class ServiceSinkhole extends VpnService {
private static long jni_context = 0;
private volatile Thread tunnelThread = null;
private ServiceSinkhole.Builder last_builder = null;
- private ParcelFileDescriptor vpn = null;
+ private volatile ParcelFileDescriptor vpn = null;
private boolean temporarilyStopped = false;
private static long last_hosts_modified = 0;
diff --git a/app/src/main/jni/netguard/session.c b/app/src/main/jni/netguard/session.c
index f8ec568fd..ba69f3788 100644
--- a/app/src/main/jni/netguard/session.c
+++ b/app/src/main/jni/netguard/session.c
@@ -87,6 +87,7 @@ void *handle_events(void *a) {
log_android(ANDROID_LOG_ERROR, "epoll add tun error %d: %s", error, strerror(error));
report_exit(args, error, "epoll add tun error %d: %s", error, strerror(error));
args->ctx->stopping = 1;
+ goto cleanup;
}
// Loop
From 1f3aecdcedaae5f996690b6d9336357dac73eaf8 Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sat, 11 Jul 2026 13:36:29 +0200
Subject: [PATCH 5/6] Clamp recovery backoff to avoid long overflow for large
retry budgets
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../netguard/NativeFailureRecoveryPolicy.java | 6 +++++-
.../NativeFailureRecoveryPolicyTest.java | 16 ++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java b/app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java
index 54b63d012..ff2095eae 100644
--- a/app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java
+++ b/app/src/main/java/eu/faircode/netguard/NativeFailureRecoveryPolicy.java
@@ -38,7 +38,11 @@ synchronized long onFailure(long nowMs) {
if (failures >= maxRetries)
return NO_RETRY;
- long delay = initialDelayMs << failures;
+ // Exponential backoff, clamped to avoid signed-long overflow when
+ // maxRetries (and thus the shift amount) is large.
+ long delay = (failures < Long.SIZE - 1 && initialDelayMs <= (Long.MAX_VALUE >> failures))
+ ? initialDelayMs << failures
+ : Long.MAX_VALUE;
failures++;
return delay;
}
diff --git a/app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java b/app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java
index 0945f076b..cac28dcea 100644
--- a/app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java
+++ b/app/src/test/java/eu/faircode/netguard/NativeFailureRecoveryPolicyTest.java
@@ -46,6 +46,22 @@ public void explicitResetRestoresRetryBudget() {
assertEquals(250L, policy.onFailure(2L));
}
+ @Test
+ public void largeRetryBudgetDoesNotOverflowBackoff() {
+ // A generous config whose naive `initialDelayMs << failures` would
+ // overflow a signed long; every delay must stay positive and non-decreasing.
+ NativeFailureRecoveryPolicy policy = new NativeFailureRecoveryPolicy(80, 1_000L, Long.MAX_VALUE);
+
+ long previous = 0L;
+ for (int i = 0; i < 80; i++) {
+ long delay = policy.onFailure(i);
+ assertTrue("delay must stay positive (no overflow)", delay > 0L);
+ assertTrue("backoff must be non-decreasing", delay >= previous);
+ previous = delay;
+ }
+ assertEquals(NativeFailureRecoveryPolicy.NO_RETRY, policy.onFailure(81L));
+ }
+
@Test
public void identifiesFileDescriptorExhaustion() {
assertTrue(NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(24));
From d3195f005b59d020e7769ee67463e8805b3146ea Mon Sep 17 00:00:00 2001
From: Konrad Kollnig <5175206+kasnder@users.noreply.github.com>
Date: Sat, 11 Jul 2026 15:29:00 +0200
Subject: [PATCH 6/6] Clarify file descriptor failure messages
---
app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java | 4 ++--
app/src/main/res/values/strings.xml | 3 ++-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
index a57afdc4f..5f631db3d 100644
--- a/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
+++ b/app/src/main/java/eu/faircode/netguard/ServiceSinkhole.java
@@ -2125,7 +2125,7 @@ private void nativeExit(int error, String reason) {
// Recovery budget exhausted: TC is going down, so surface the failure to the user.
Log.e(TAG, "Native recovery retry budget exhausted");
showErrorNotification(NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(error)
- ? getString(R.string.msg_native_fd_exhausted)
+ ? getString(R.string.msg_native_fd_recovery_exhausted)
: reason);
cancelNativeRecovery(false);
prefs.edit().putBoolean("enabled", false).apply();
@@ -2145,7 +2145,7 @@ private void nativeExit(int error, String reason) {
private void nativeError(int error, String message) {
Log.w(TAG, "Native error " + error + ": " + message);
showErrorNotification(NativeFailureRecoveryPolicy.isFileDescriptorExhaustion(error)
- ? getString(R.string.msg_native_fd_exhausted)
+ ? getString(R.string.msg_native_fd_error)
: message);
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 20ecbfbe8..c9ee9a10a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -316,7 +316,8 @@ For this reason, please allow a VPN connection in the next dialog.
Your internet traffic is not being sent to a remote VPN server.
TrackerControl could not start automatically. This is likely because of a bug in your Android version.
An unexpected error has occurred: \'%s\'
- The VPN ran out of file descriptors. Protection will restart automatically if retry capacity remains.
+ The VPN repeatedly ran out of file descriptors. Protection has stopped after all automatic restart attempts failed.
+ The VPN ran out of file descriptors. Close unused apps or restart your device, then restart protection.
Secure DNS (DoH) cannot be reached even though you have an active internet connection. If this happens repeatedly, consider disabling it in the Network settings.
Android refused to start the VPN service at this moment. This is likely because of a bug in your Android version.
Try TrackerControl