android: CharonVpnService - safer foreground and teardown handling

- DISCONNECT (plain startService) no longer promotes to the foreground,
  and startForeground() refusals on Android 12+ are logged, not fatal
- drop the placeholder notification when nothing was running, so the
  service does not stay in the foreground with a stale Disabled state
- onDestroy: shutdownNow/await the profile executor before closing the
  database; profile loads catch errors instead of killing the process
- unbind by bind result, not by mService (binding leaked if destroyed
  before onServiceConnected)
- the connection thread's crash guard can no longer throw itself

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-10 09:09:39 +07:00
1 parent 2b40eebf5b
commit 358dcb94b2
1 file changed
+83 -10
@@ -94,6 +94,7 @@ import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
@@ -169,6 +170,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
private UnderlyingNetworkTracker mUnderlyingTracker;
private Handler mHandler;
private VpnStateService mService;
private boolean mBound;
private final Object mServiceLock = new Object();
private final ServiceConnection mServiceConnection = new ServiceConnection()
{
@@ -217,8 +219,14 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* first millisecond, and it avoids the system killing a fresh process
* when the profile fails to load before addNotification() runs. The
* state-specific notification replaces this one (via stateChanged()). */
mShowNotification = true;
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
boolean disconnect = intent != null && DISCONNECT_ACTION.equals(intent.getAction());
if (!disconnect)
{ /* DISCONNECT arrives via plain startService(): promoting to the
* foreground from the background is not allowed there (Android 12+)
* and would crash, and there is nothing to keep alive anyway */
mShowNotification = true;
startForegroundSafe();
}
if (intent != null)
{
if (VPN_SERVICE_ACTION.equals(intent.getAction()))
@@ -282,7 +290,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
return null;
}
return mDataSource.getVpnProfile(uuid);
try
{
return mDataSource.getVpnProfile(uuid);
}
catch (RuntimeException e)
{ /* e.g. the service is being destroyed and closed the database;
* an uncaught exception on the executor would kill the process */
DiagLog.i(TAG, "loading profile failed: " + e);
return null;
}
}
/**
@@ -391,8 +408,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
/* use a separate thread as main thread for charon */
mConnectionHandler = new Thread(this);
/* the thread is started when the service is bound */
bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
mBound = bindService(new Intent(this, VpnStateService.class),
mServiceConnection, Service.BIND_AUTO_CREATE);
createNotificationChannel();
}
@@ -418,12 +435,28 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
{
e.printStackTrace();
}
if (mService != null)
synchronized (mServiceLock)
{
mService.unregisterListener(this);
if (mService != null)
{
mService.unregisterListener(this);
}
}
if (mBound)
{ /* unbind even if onServiceConnected() never ran, or the binding leaks */
unbindService(mServiceConnection);
mBound = false;
}
/* queued profile loads must not run against the closed database */
mProfileExecutor.shutdownNow();
try
{
mProfileExecutor.awaitTermination(2, TimeUnit.SECONDS);
}
catch (InterruptedException e)
{
Thread.currentThread().interrupt();
}
mProfileExecutor.shutdown();
mDataSource.close();
}
@@ -461,6 +494,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
if (mNextProfile == null)
{
setState(State.DISABLED);
/* nothing was running (e.g. DISCONNECT while already
* disconnected, or the profile failed to load): drop the
* placeholder notification so the service is not kept
* in the foreground with a stale "Disabled" state */
removeNotification();
if (mTerminate)
{
break;
@@ -537,7 +575,25 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
* and let the regular retry machinery handle it. */
Log.e(TAG, "Exception in the connection thread", ex);
DiagLog.i(TAG, "exception in connection thread: " + ex);
stopCurrentConnection();
try
{
stopCurrentConnection();
}
catch (Exception again)
{ /* the exception may have come from stopCurrentConnection()
* itself (e.g. a broken next profile): drop that profile
* so this guard cannot throw again and kill the thread */
DiagLog.i(TAG, "cleanup after exception failed: " + again);
mNextProfile = null;
if (mCurrentProfile != null)
{
SimpleFetcher.disable();
deinitializeCharon();
sUserspacePushActive = false;
}
removeNotification();
mBuilderAdapter.closeBlocking();
}
setError(ErrorState.GENERIC_ERROR);
setState(State.DISABLED);
mCurrentProfile = null;
@@ -593,11 +649,28 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
public void run()
{
mShowNotification = true;
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
startForegroundSafe();
}
});
}
/**
* Promote the service to the foreground. Never throws: on Android 12+ the
* system may refuse the promotion while the app is in the background, which
* must not crash the process.
*/
private void startForegroundSafe()
{
try
{
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
}
catch (RuntimeException e)
{
DiagLog.i(TAG, "startForeground refused: " + e);
}
}
/**
* Remove the permanent notification.
*/