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; + } +}