diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7e2ec759..dbc05c90 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -105,6 +105,18 @@
+
+
+
pendingBackgroundedExit =
+ new java.util.concurrent.atomic.AtomicReference<>(null);
private boolean isDarkMode;
private boolean enableLogsMenu;
@@ -529,6 +538,11 @@ public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
AppUtils.hideSystemUI(this);
AppUtils.keepScreenOn(this);
+ // Promote the app to a foreground service so the OS does not reap our
+ // process while the screen is locked or the activity is backgrounded.
+ // Without this, locking the phone while wine processes are paused can
+ // tear down the whole container. The service stops on session exit.
+ com.winlator.cmod.runtime.system.SessionKeepAliveService.startGameSession(this);
// Clean up any shared debug logs and prepare for fresh session logging
DebugFragment.Companion.cleanupSharedLogs();
com.winlator.cmod.runtime.system.LogManager.prepareForNewSession(this);
@@ -1620,6 +1634,7 @@ private void handleCapturedPointer(MotionEvent event) {
@Override
public void onResume() {
super.onResume();
+ activityBackgrounded.set(false);
applyPreferredRefreshRate();
boolean gyroEnabled = preferences.getBoolean("gyro_enabled", false);
@@ -1631,8 +1646,32 @@ public void onResume() {
boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get();
if (!cleaningUp && environment != null) {
+ // Resume GL rendering for the foreground X surface. We intentionally
+ // do NOT auto-resume wine processes here: we no longer auto-pause
+ // them on background, so there is nothing to flip back. The drawer
+ // "Pause all Wine processes" toggle remains the user-controlled
+ // path for explicit suspend/resume.
xServerView.onResume();
- environment.onResume();
+ }
+
+ // If an async exit was deferred while we were backgrounded (launcher
+ // waitFor returned during screen lock, or steam exit watch decided to
+ // drain), reconcile it now that we can see the real process state.
+ // If wine session processes are still alive the trigger was spurious
+ // and we leave the session intact; otherwise honor the original exit.
+ String deferred = pendingBackgroundedExit.getAndSet(null);
+ if (deferred != null && !cleaningUp) {
+ ArrayList alive = ProcessHelper.listRunningWineProcesses();
+ if (alive.isEmpty()) {
+ Log.i("XServerDisplayActivity",
+ "Honoring deferred exit (" + deferred + "); no wine processes remain on resume");
+ exit();
+ return;
+ }
+ Log.w("XServerDisplayActivity",
+ "Dropping spurious background exit (" + deferred + "); "
+ + alive.size() + " wine process(es) still alive: "
+ + ProcessHelper.listRunningWineProcessDetails());
}
if (inputControlsView != null && touchpadView != null) {
ControlsProfile activeProfile = inputControlsView.getProfile();
@@ -1642,14 +1681,12 @@ public void onResume() {
}
startTime = System.currentTimeMillis();
handler.postDelayed(savePlaytimeRunnable, SAVE_INTERVAL_MS);
- if (!cleaningUp) {
- ProcessHelper.resumeAllWineProcesses();
- }
}
@Override
public void onPause() {
super.onPause();
+ activityBackgrounded.set(true);
boolean gyroEnabled = preferences.getBoolean("gyro_enabled", false);
if (gyroEnabled) {
@@ -1661,9 +1698,15 @@ public void onPause() {
boolean cleaningUp = exitRequested.get() || sessionCleanupStarted.get() || activityDestroyed.get();
if (!cleaningUp && !isInPictureInPictureMode()) {
- // Only pause environment and xServerView if not in PiP mode
+ // Pause GL rendering only — do NOT SIGSTOP wine on backgrounding.
+ // Auto-stopping wine across long screen-locked windows let the OS
+ // reap the suspended children (OOM killer prefers stopped procs,
+ // and the freezer can target them), which manifested as the
+ // container closing on unlock. Leaving wine running and trusting
+ // the keep-alive foreground service keeps the container intact.
+ // The drawer "Pause all Wine processes" toggle is still available
+ // for the user to manually suspend the session for battery.
if (environment != null) {
- environment.onPause();
xServerView.onPause();
}
}
@@ -1677,9 +1720,6 @@ public void onPause() {
savePlaytimeData();
handler.removeCallbacks(savePlaytimeRunnable);
- if (!cleaningUp) {
- ProcessHelper.pauseAllWineProcesses();
- }
}
@@ -1893,6 +1933,20 @@ private void requestExitOnUiThread(String reason) {
Log.d("XServerDisplayActivity", "Skipping exit request after teardown: " + reason);
return;
}
+ // Defer if the user can't see the screen. The steam exit watch
+ // polls via WinHandler IPC, which times out while wine processes
+ // are SIGSTOP'd by onPause — that can be misread as "drained" and
+ // fire a false-positive exit. The resume-side reconciliation in
+ // onResume() rechecks via /proc and drops spurious triggers.
+ if (activityBackgrounded.get()
+ && !exitRequested.get()
+ && !sessionCleanupStarted.get()) {
+ if (pendingBackgroundedExit.compareAndSet(null, reason)) {
+ Log.w("XServerDisplayActivity",
+ "Deferring exit until resume — " + reason);
+ }
+ return;
+ }
exit();
});
}
@@ -2063,6 +2117,7 @@ private void performForcedSessionCleanup(String trigger) {
}
Log.d("XServerLeakCheck", "Forced cleanup final process snapshot: "
+ ProcessHelper.listRunningWineProcessDetails());
+ com.winlator.cmod.runtime.system.SessionKeepAliveService.stopGameSession(getApplicationContext());
}
private void exit() {
@@ -2118,6 +2173,7 @@ public void run() {
xServer = null;
xServerView = null;
if (preloaderDialog != null && preloaderDialog.isShowing()) preloaderDialog.closeOnUiThread();
+ com.winlator.cmod.runtime.system.SessionKeepAliveService.stopGameSession(getApplicationContext());
closeAfterSessionExit();
}
}, 1000);
@@ -3793,6 +3849,25 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException {
return;
}
+ // The activity may be backgrounded (screen locked, app minimized).
+ // If so, defer the exit until onResume can re-check whether the
+ // wine session truly ended. Locking the phone shouldn't tear the
+ // container down; if the launcher waitFor returned spuriously
+ // (e.g. because the OS reaped a transient outer process while
+ // wine itself was still alive), the resume-side reconciliation
+ // will see live processes and keep the session running.
+ if (activityBackgrounded.get()
+ && !exitRequested.get()
+ && !sessionCleanupStarted.get()
+ && !activityDestroyed.get()) {
+ String reason = "guest launcher terminated (status=" + status + ") while backgrounded";
+ if (pendingBackgroundedExit.compareAndSet(null, reason)) {
+ Log.w("XServerDisplayActivity",
+ "Deferring exit until resume — " + reason);
+ }
+ return;
+ }
+
exit();
});
diff --git a/app/src/main/runtime/system/ProcessHelper.java b/app/src/main/runtime/system/ProcessHelper.java
index aed79327..1379cdcf 100644
--- a/app/src/main/runtime/system/ProcessHelper.java
+++ b/app/src/main/runtime/system/ProcessHelper.java
@@ -93,6 +93,25 @@ public static void resumeProcess(int pid) {
if (PRINT_DEBUG) Log.d("ProcessHelper", "Process resumed with pid: " + pid);
}
+ /**
+ * Best-effort write of /proc/[pid]/oom_score_adj for one of our own
+ * children. SIGSTOP'd processes are otherwise prime OOM-kill targets during
+ * long screen-locked windows; this lowers their kill priority so the OS
+ * leaves a manually-paused wine session alone over multi-minute windows.
+ * Silently ignored if the file is not writable on this device.
+ */
+ public static void setOomScoreAdj(int pid, int score) {
+ if (pid <= 0) return;
+ java.io.File f = new java.io.File("/proc/" + pid + "/oom_score_adj");
+ try (java.io.FileWriter w = new java.io.FileWriter(f, false)) {
+ w.write(Integer.toString(score));
+ } catch (Throwable t) {
+ // Some Android versions deny writes even for our own children. Don't
+ // spam logs — fall back silently and rely on the foreground service.
+ if (PRINT_DEBUG) Log.d(TAG, "oom_score_adj write skipped for pid " + pid + ": " + t);
+ }
+ }
+
public static void terminateProcess(int pid) {
Process.sendSignal(pid, SIGTERM);
if (PRINT_DEBUG) Log.d("ProcessHelper", "Process terminated with pid: " + pid);
@@ -165,11 +184,23 @@ public static ArrayList terminateSessionProcessesAndWait(
return finalRemaining;
}
+ // Aggressive OOM protection for paused wine processes. -1000 marks the
+ // process as oom_score_adj OOM_SCORE_ADJ_MIN, telling the kernel never to
+ // kill it on memory pressure. We restore to 0 (default) on resume so a
+ // running wine process is back to normal priority.
+ private static final int OOM_SCORE_ADJ_PROTECT = -1000;
+ private static final int OOM_SCORE_ADJ_DEFAULT = 0;
+
public static void pauseAllWineProcesses() {
ArrayList processes = listRunningWineProcesses();
if (!processes.isEmpty()) Log.d(TAG, "Pausing session processes: " + processes);
for (String process : processes) {
- suspendProcess(Integer.parseInt(process));
+ int pid = Integer.parseInt(process);
+ // Make the OS never OOM-kill the paused process. Without this, the
+ // kernel happily reaps SIGSTOP'd processes during long screen-locked
+ // windows because they look idle and unreclaimable.
+ setOomScoreAdj(pid, OOM_SCORE_ADJ_PROTECT);
+ suspendProcess(pid);
}
}
@@ -177,7 +208,9 @@ public static void resumeAllWineProcesses() {
ArrayList processes = listRunningWineProcesses();
if (!processes.isEmpty()) Log.d(TAG, "Resuming session processes: " + processes);
for (String process : processes) {
- resumeProcess(Integer.parseInt(process));
+ int pid = Integer.parseInt(process);
+ resumeProcess(pid);
+ setOomScoreAdj(pid, OOM_SCORE_ADJ_DEFAULT);
}
}
diff --git a/app/src/main/runtime/system/SessionKeepAliveService.java b/app/src/main/runtime/system/SessionKeepAliveService.java
new file mode 100644
index 00000000..5a94260b
--- /dev/null
+++ b/app/src/main/runtime/system/SessionKeepAliveService.java
@@ -0,0 +1,262 @@
+package com.winlator.cmod.runtime.system;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+
+import com.winlator.cmod.R;
+import com.winlator.cmod.app.shell.UnifiedActivity;
+
+import java.util.HashSet;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Foreground service that keeps the WinNative process alive while a wine
+ * session is in the background or while a component download/install is
+ * running. Without it, Android can reap the app process when the screen is
+ * locked, taking the wine container (and any in-flight download) with it.
+ *
+ * Reasons are reference-counted via static helpers. The service stops itself
+ * once no reasons remain. On task removal (user swipe-away) it does a
+ * defensive wine cleanup and lets the process exit, matching the previous
+ * "swipe = close" behaviour.
+ */
+public class SessionKeepAliveService extends Service {
+ private static final String TAG = "SessionKeepAlive";
+
+ private static final String CHANNEL_ID = "winnative_session_keepalive";
+ private static final int NOTIFICATION_ID = 0xC0DE;
+
+ private static final String ACTION_GAME_START = "com.winlator.cmod.action.SESSION_GAME_START";
+ private static final String ACTION_GAME_STOP = "com.winlator.cmod.action.SESSION_GAME_STOP";
+ private static final String ACTION_DL_START = "com.winlator.cmod.action.SESSION_DL_START";
+ private static final String ACTION_DL_STOP = "com.winlator.cmod.action.SESSION_DL_STOP";
+ private static final String ACTION_REFRESH = "com.winlator.cmod.action.SESSION_REFRESH";
+
+ private static final String EXTRA_TAG = "tag";
+
+ private static final AtomicBoolean gameActive = new AtomicBoolean(false);
+ private static final HashSet activeDownloads = new HashSet<>();
+ private static final AtomicBoolean serviceRunning = new AtomicBoolean(false);
+
+ public static void startGameSession(Context ctx) {
+ if (ctx == null) return;
+ if (gameActive.compareAndSet(false, true)) {
+ sendCommand(ctx, ACTION_GAME_START, null);
+ }
+ }
+
+ public static void stopGameSession(Context ctx) {
+ if (ctx == null) return;
+ if (gameActive.compareAndSet(true, false)) {
+ sendCommand(ctx, ACTION_GAME_STOP, null);
+ }
+ }
+
+ public static void startDownload(Context ctx, String tag) {
+ if (ctx == null) return;
+ String key = tag == null ? "default" : tag;
+ boolean added;
+ synchronized (activeDownloads) {
+ added = activeDownloads.add(key);
+ }
+ if (added) {
+ sendCommand(ctx, ACTION_DL_START, key);
+ }
+ }
+
+ public static void stopDownload(Context ctx, String tag) {
+ if (ctx == null) return;
+ String key = tag == null ? "default" : tag;
+ boolean removed;
+ synchronized (activeDownloads) {
+ removed = activeDownloads.remove(key);
+ }
+ if (removed) {
+ sendCommand(ctx, ACTION_DL_STOP, key);
+ }
+ }
+
+ public static boolean isGameSessionActive() {
+ return gameActive.get();
+ }
+
+ private static boolean hasReason() {
+ if (gameActive.get()) return true;
+ synchronized (activeDownloads) {
+ return !activeDownloads.isEmpty();
+ }
+ }
+
+ private static void sendCommand(Context ctx, String action, @Nullable String tag) {
+ Context app = ctx.getApplicationContext();
+ Intent intent = new Intent(app, SessionKeepAliveService.class);
+ intent.setAction(action);
+ if (tag != null) intent.putExtra(EXTRA_TAG, tag);
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ app.startForegroundService(intent);
+ } else {
+ app.startService(intent);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to send command " + action, e);
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ ensureChannel();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ // Always promote to foreground first so Android does not consider
+ // the start a violation (and so the notification reflects current
+ // reasons), even if the command immediately tells us to stop.
+ ensureForeground();
+ serviceRunning.set(true);
+
+ if (!hasReason()) {
+ Log.d(TAG, "No active reason; stopping keep-alive service");
+ stopForegroundCompat();
+ stopSelf();
+ serviceRunning.set(false);
+ }
+ return START_NOT_STICKY;
+ }
+
+ private void ensureForeground() {
+ Notification n = buildNotification();
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+ } else {
+ startForeground(NOTIFICATION_ID, n);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to startForeground", e);
+ }
+ }
+
+ private void stopForegroundCompat() {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ stopForeground(STOP_FOREGROUND_REMOVE);
+ } else {
+ stopForeground(true);
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to stopForeground", e);
+ }
+ }
+
+ private void ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
+ NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
+ if (nm == null) return;
+ if (nm.getNotificationChannel(CHANNEL_ID) != null) return;
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ "WinNative session keep-alive",
+ NotificationManager.IMPORTANCE_LOW);
+ channel.setDescription(
+ "Keeps WinNative running in the background so a paused game session or "
+ + "an active component download is not interrupted by screen lock.");
+ channel.setShowBadge(false);
+ nm.createNotificationChannel(channel);
+ }
+
+ private Notification buildNotification() {
+ boolean game = gameActive.get();
+ boolean dl;
+ synchronized (activeDownloads) {
+ dl = !activeDownloads.isEmpty();
+ }
+ String content;
+ if (game && dl) {
+ content = "Session paused — downloads continuing in background";
+ } else if (game) {
+ content = "Game session is paused in the background";
+ } else if (dl) {
+ content = "Downloading components in the background";
+ } else {
+ content = "WinNative is running in the background";
+ }
+
+ Intent openIntent = new Intent(this, UnifiedActivity.class);
+ openIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ PendingIntent contentIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ openIntent,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+
+ return new NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentTitle(getString(R.string.common_ui_app_name))
+ .setContentText(content)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setOngoing(true)
+ .setShowWhen(false)
+ .setContentIntent(contentIntent)
+ .build();
+ }
+
+ @Override
+ public void onTaskRemoved(Intent rootIntent) {
+ super.onTaskRemoved(rootIntent);
+ Log.i(TAG, "Task removed (user swipe). Tearing down session and exiting process.");
+
+ // Clear reasons so any subsequent re-entry will not keep us alive.
+ gameActive.set(false);
+ synchronized (activeDownloads) {
+ activeDownloads.clear();
+ }
+
+ // Give the activity's own onDestroy → performForcedSessionCleanup a
+ // chance to run first; then defensively clean any wine processes that
+ // might still be alive, and exit the process so swipe behaves like the
+ // pre-existing "swipe-away closes everything" flow.
+ Handler handler = new Handler(Looper.getMainLooper());
+ handler.postDelayed(() -> {
+ try {
+ ProcessHelper.terminateSessionProcessesAndWait(1500, true);
+ ProcessHelper.drainDeadChildren("session keep-alive task removed");
+ } catch (Throwable t) {
+ Log.w(TAG, "Defensive wine cleanup on task removal failed", t);
+ }
+ stopForegroundCompat();
+ stopSelf();
+ serviceRunning.set(false);
+ // Match the previous swipe behaviour: actually exit the process.
+ android.os.Process.killProcess(android.os.Process.myPid());
+ }, 1500L);
+ }
+
+ @Override
+ public void onDestroy() {
+ serviceRunning.set(false);
+ super.onDestroy();
+ }
+
+ @Nullable
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}