From cf356a713661797857c93c16f2779722b92e21a2 Mon Sep 17 00:00:00 2001 From: Catpotatos Date: Thu, 7 May 2026 22:05:50 +0100 Subject: [PATCH 1/4] =?UTF-8?q?-=20Fixes=20stuck=20loading=20screen=20duri?= =?UTF-8?q?ng=20Mono/XAudio=20install=20-=20Works=20on=20all=20devices=20-?= =?UTF-8?q?=20No=20effect=20on=20gameplay=20=E2=80=94=20these=20commands?= =?UTF-8?q?=20only=20run=20at=20first=20boot,=20container=20swap,=20bootin?= =?UTF-8?q?g=20screen,=20not=20during=20a=20game=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX: Now the app just reads one stream from top to bottom. There's nothing to deadlock against because there's only one pipe, one reader, no coordination needed. Merges stderr into stdout at the OS level — only one pipe. It can never deadlock with itself Issue: Wine logging + Mono install = child writes both stdout and stderr simultaneously. Stderr fills its 64 KB OS pipe buffer WHILE the parent is blocked reading stdout → child blocks on write(stderr) → parent never sees stdout EOF → both sides wait forever. process.waitFor() with no timeout means it sits there permanently. When installing Mono or extracting XAudio DLLs during first boot, some users would get permanently stuck on the loading screen — especially when wine debug logging was turned on, or when setting up a new Proton version. The root cause was a pipe deadlock: wine writes a large amount of debug output to stderr during installation. The OS pipe buffer (64 KB) would fill up, causing wine to freeze waiting for someone to read it. Meanwhile, the app was waiting for wine to finish — so both sides were stuck waiting on each other, forever. --- .../ui/screen/xserver/XServerScreen.kt | 7 +++- .../BionicProgramLauncherComponent.java | 35 ++++++++++++------- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 1910aaf21c..e0c3a97841 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3937,7 +3937,12 @@ private fun unpackExecutableFile( Timber.i("Install mono command $monoCmd") val monoOutput = guestProgramLauncherComponent.execShellCommand(monoCmd) output.append(monoOutput) - Timber.i("Result of mono command " + output) + // Cap log: Mono install produces up to 50 MB of wine debug output. + // Show head + tail so both startup noise and any trailing errors are visible. + val logSnippet = if (monoOutput.length > 1000) { + monoOutput.take(400) + "\n…(${monoOutput.length} chars total)…\n" + monoOutput.takeLast(400) + } else monoOutput + Timber.i("Result of mono command: %s", logSnippet) } catch (e: Exception) { Timber.e("Error during mono installation: $e") } diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index 73b41dff15..d22c38ca90 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -50,6 +50,7 @@ import java.net.InetAddress; import java.util.ArrayList; import java.util.List; +import java.util.Map; import app.gamenative.PluviaApp; import app.gamenative.events.AndroidEvent; @@ -502,22 +503,32 @@ public String execShellCommand(String command, boolean includeStderr) { FileUtils.chmod(box64File, 0755); } - // Execute the command and capture its output + // Execute the command and capture its output. + // Use ProcessBuilder with redirectErrorStream(true) — merges stderr into stdout at the + // OS level so there is only one pipe to read. A single pipe cannot deadlock regardless + // of log volume (no 64 KB pipe-buffer race), so no extra drain threads are needed. try { Log.d("BionicProgramLauncherComponent", "Shell command is " + finalCommand); - java.lang.Process process = Runtime.getRuntime().exec(finalCommand, envVars.toStringArray(), workingDir != null ? workingDir : imageFs.getRootDir()); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream())); - - String line; - while ((line = reader.readLine()) != null) { - output.append(line).append("\n"); + ProcessBuilder pb = new ProcessBuilder(ProcessHelper.splitCommand(finalCommand)); + Map env = pb.environment(); + env.clear(); + for (String kv : envVars.toStringArray()) { + int eq = kv.indexOf('='); + if (eq > 0) env.put(kv.substring(0, eq), kv.substring(eq + 1)); } - if (includeStderr) { - while ((line = errorReader.readLine()) != null) { - output.append(line).append("\n"); - } + pb.directory(workingDir != null ? workingDir : imageFs.getRootDir()); + pb.redirectErrorStream(true); // stderr merged into stdout — one pipe, zero deadlock risk + + java.lang.Process process = pb.start(); + + // Read the single merged stream inline; EOF only arrives after the process exits. + try (BufferedReader r = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String l; + while ((l = r.readLine()) != null) output.append(l).append("\n"); } + + // Process has already exited (we just read its EOF). + // waitFor() reaps the OS process-table entry immediately process.waitFor(); } catch (Exception e) { output.append("Error: ").append(e.getMessage()); From c01701e946102909518ff7c3d06fbb3f499becc2 Mon Sep 17 00:00:00 2001 From: Catpotatos Date: Sat, 9 May 2026 12:29:33 +0100 Subject: [PATCH 2/4] Revert Mono log snip to full log as per original implementation. --- .../java/app/gamenative/ui/screen/xserver/XServerScreen.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index e0c3a97841..1910aaf21c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3937,12 +3937,7 @@ private fun unpackExecutableFile( Timber.i("Install mono command $monoCmd") val monoOutput = guestProgramLauncherComponent.execShellCommand(monoCmd) output.append(monoOutput) - // Cap log: Mono install produces up to 50 MB of wine debug output. - // Show head + tail so both startup noise and any trailing errors are visible. - val logSnippet = if (monoOutput.length > 1000) { - monoOutput.take(400) + "\n…(${monoOutput.length} chars total)…\n" + monoOutput.takeLast(400) - } else monoOutput - Timber.i("Result of mono command: %s", logSnippet) + Timber.i("Result of mono command " + output) } catch (e: Exception) { Timber.e("Error during mono installation: $e") } From 595a124cff5a5d8725d5363b755b6bd1cacb8968 Mon Sep 17 00:00:00 2001 From: Catpotatos Date: Sun, 10 May 2026 20:50:27 +0100 Subject: [PATCH 3/4] Add a safety strategy for when includeStderr=false is used by callers. - a daemon thread drains and discards stderr concurrently so the 64 KB pipe buffer never fills while the main thread reads stdout. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When not merging, drain stderr on a daemon thread to prevent pipe-buffer deadlock. - when includeStderr doesn't explicity say includeStderr=false, then use the merged pipe technique example: The Mono install calls execShellCommand → one child process starts, runs, finishes, pipes are closed. Then later SteamTokenLogin calls execShellCommand → a completely separate child process, separate pipes. Majority reaps the rewards of having a merged pipe, any process that doesn't want stderr doesn't go through the merge and has a drain feature for stderr. --- .../BionicProgramLauncherComponent.java | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index d22c38ca90..cadef2e660 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -45,6 +45,7 @@ import java.io.BufferedReader; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.RandomAccessFile; import java.net.InetAddress; @@ -504,9 +505,14 @@ public String execShellCommand(String command, boolean includeStderr) { } // Execute the command and capture its output. - // Use ProcessBuilder with redirectErrorStream(true) — merges stderr into stdout at the - // OS level so there is only one pipe to read. A single pipe cannot deadlock regardless - // of log volume (no 64 KB pipe-buffer race), so no extra drain threads are needed. + // Use ProcessBuilder to merge stderr merged into stdout + // Deadlock-safe strategy: + // includeStderr=true → redirectErrorStream(true): stderr merged into stdout at the OS + // level, single pipe, zero deadlock risk. + // includeStderr=false → streams are kept separate; a daemon thread drains and discards + // stderr concurrently so the 64 KB pipe buffer never fills while + // the main thread reads stdout. This keeps stdout clean for callers + // such as SteamTokenLogin try { Log.d("BionicProgramLauncherComponent", "Shell command is " + finalCommand); ProcessBuilder pb = new ProcessBuilder(ProcessHelper.splitCommand(finalCommand)); @@ -517,19 +523,38 @@ public String execShellCommand(String command, boolean includeStderr) { if (eq > 0) env.put(kv.substring(0, eq), kv.substring(eq + 1)); } pb.directory(workingDir != null ? workingDir : imageFs.getRootDir()); - pb.redirectErrorStream(true); // stderr merged into stdout — one pipe, zero deadlock risk + pb.redirectErrorStream(includeStderr); // merge only when caller wants stderr java.lang.Process process = pb.start(); - // Read the single merged stream inline; EOF only arrives after the process exits. + // When not merging, drain stderr on a daemon thread to prevent pipe-buffer deadlock. + // SteamTokenLogin uses this when calling includeStderr=false + Thread stderrDrainer = null; + if (!includeStderr) { + final InputStream stderrStream = process.getErrorStream(); + stderrDrainer = new Thread(() -> { + try { + byte[] buf = new byte[4096]; + while (stderrStream.read(buf) != -1) { /* drain & discard */ } + } catch (IOException ignored) {} + }, "stderr-drainer"); + stderrDrainer.setDaemon(true); // won't block app shutdown if something goes wrong + stderrDrainer.start(); + } + + // Read stdout (or the merged stream) inline; EOF arrives after the process exits. try (BufferedReader r = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String l; while ((l = r.readLine()) != null) output.append(l).append("\n"); } - // Process has already exited (we just read its EOF). - // waitFor() reaps the OS process-table entry immediately + // Process has already exited (we drained its stdout to EOF). + // waitFor() reaps the OS process-table entry. process.waitFor(); + + if (stderrDrainer != null) { + stderrDrainer.join(5_000); // bounded wait; daemon thread is reaped on JVM exit anyway + } } catch (Exception e) { output.append("Error: ").append(e.getMessage()); } From f51141ec10d1ac719e44c8347316b63d2bc5d6f3 Mon Sep 17 00:00:00 2001 From: Catpotatos Date: Sun, 17 May 2026 16:22:27 +0100 Subject: [PATCH 4/4] move logic to ProcessHelper.java, call from BionicProgramLauncherComponent and GlibcProgramLauncherComponent --- .../java/com/winlator/core/ProcessHelper.java | 66 +++++++++++++++++++ .../BionicProgramLauncherComponent.java | 62 +---------------- .../GlibcProgramLauncherComponent.java | 25 +------ 3 files changed, 72 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/com/winlator/core/ProcessHelper.java b/app/src/main/java/com/winlator/core/ProcessHelper.java index e8c16fac6a..4c98aec674 100644 --- a/app/src/main/java/com/winlator/core/ProcessHelper.java +++ b/app/src/main/java/com/winlator/core/ProcessHelper.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.Executors; public abstract class ProcessHelper { @@ -106,6 +107,71 @@ public static int exec(String command, String[] envp, File workingDir) { return exec(command, envp, workingDir, null); } + // Execute the command and capture its output. + // Use ProcessBuilder to merge stderr into stdout + // Deadlock-safe strategy: + // includeStderr=true - redirectErrorStream(true): stderr merged into stdout at the OS + // level, single pipe, zero deadlock risk. + // includeStderr=false - streams are kept separate; a daemon thread drains and discards + // stderr concurrently so the 64 KB pipe buffer never fills while + // the main thread reads stdout. This keeps stdout clean for callers + // such as SteamTokenLogin + public static String execWithOutput(String command, String[] envp, File workingDir, boolean includeStderr) { + StringBuilder output = new StringBuilder(); + Thread stderrDrainer = null; + try { + ProcessBuilder pb = new ProcessBuilder(splitCommand(command)); + Map env = pb.environment(); + env.clear(); + if (envp != null) { + for (String kv : envp) { + int eq = kv.indexOf('='); + if (eq > 0) env.put(kv.substring(0, eq), kv.substring(eq + 1)); + } + } + if (workingDir != null) pb.directory(workingDir); + pb.redirectErrorStream(includeStderr); // merge only when caller wants stderr + + java.lang.Process process = pb.start(); + + // When not merging, drain stderr on a daemon thread to prevent pipe-buffer deadlock. + // SteamTokenLogin uses this when calling includeStderr=false + if (!includeStderr) { + final InputStream stderrStream = process.getErrorStream(); + stderrDrainer = new Thread(() -> { + try { + byte[] buf = new byte[4096]; + while (stderrStream.read(buf) != -1) { + if (Thread.currentThread().isInterrupted()) + break; + } + } catch (IOException ignored) {} + }, "stderr-drainer"); + stderrDrainer.setDaemon(true); // won't block app shutdown if something goes wrong + stderrDrainer.start(); + } + + // Read stdout (or the merged stream) inline; EOF arrives after the process exits. + try (BufferedReader r = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String l; + while ((l = r.readLine()) != null) output.append(l).append("\n"); + } + + // Process has already exited (we drained its stdout to EOF). + // waitFor() reaps the OS process-table entry. + process.waitFor(); + + if (stderrDrainer != null) { + stderrDrainer.join(5_000); // bounded wait; daemon thread is reaped on JVM exit anyway + } + } catch (Exception e) { + output.append("Error: ").append(e.getMessage()); + } + + // Format output: trim trailing whitespace/newlines + return output.toString().trim(); + } + public static class ProcessInfo { public final int pid; public final int ppid; diff --git a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java index cadef2e660..216798a241 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/BionicProgramLauncherComponent.java @@ -45,13 +45,11 @@ import java.io.BufferedReader; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.io.InputStreamReader; import java.io.RandomAccessFile; import java.net.InetAddress; import java.util.ArrayList; import java.util.List; -import java.util.Map; import app.gamenative.PluviaApp; import app.gamenative.events.AndroidEvent; @@ -504,63 +502,9 @@ public String execShellCommand(String command, boolean includeStderr) { FileUtils.chmod(box64File, 0755); } - // Execute the command and capture its output. - // Use ProcessBuilder to merge stderr merged into stdout - // Deadlock-safe strategy: - // includeStderr=true → redirectErrorStream(true): stderr merged into stdout at the OS - // level, single pipe, zero deadlock risk. - // includeStderr=false → streams are kept separate; a daemon thread drains and discards - // stderr concurrently so the 64 KB pipe buffer never fills while - // the main thread reads stdout. This keeps stdout clean for callers - // such as SteamTokenLogin - try { - Log.d("BionicProgramLauncherComponent", "Shell command is " + finalCommand); - ProcessBuilder pb = new ProcessBuilder(ProcessHelper.splitCommand(finalCommand)); - Map env = pb.environment(); - env.clear(); - for (String kv : envVars.toStringArray()) { - int eq = kv.indexOf('='); - if (eq > 0) env.put(kv.substring(0, eq), kv.substring(eq + 1)); - } - pb.directory(workingDir != null ? workingDir : imageFs.getRootDir()); - pb.redirectErrorStream(includeStderr); // merge only when caller wants stderr - - java.lang.Process process = pb.start(); - - // When not merging, drain stderr on a daemon thread to prevent pipe-buffer deadlock. - // SteamTokenLogin uses this when calling includeStderr=false - Thread stderrDrainer = null; - if (!includeStderr) { - final InputStream stderrStream = process.getErrorStream(); - stderrDrainer = new Thread(() -> { - try { - byte[] buf = new byte[4096]; - while (stderrStream.read(buf) != -1) { /* drain & discard */ } - } catch (IOException ignored) {} - }, "stderr-drainer"); - stderrDrainer.setDaemon(true); // won't block app shutdown if something goes wrong - stderrDrainer.start(); - } - - // Read stdout (or the merged stream) inline; EOF arrives after the process exits. - try (BufferedReader r = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String l; - while ((l = r.readLine()) != null) output.append(l).append("\n"); - } - - // Process has already exited (we drained its stdout to EOF). - // waitFor() reaps the OS process-table entry. - process.waitFor(); - - if (stderrDrainer != null) { - stderrDrainer.join(5_000); // bounded wait; daemon thread is reaped on JVM exit anyway - } - } catch (Exception e) { - output.append("Error: ").append(e.getMessage()); - } - - // Format output: trim trailing whitespace/newlines - return output.toString().trim(); + Log.d("BionicProgramLauncherComponent", "Shell command is " + finalCommand); + return ProcessHelper.execWithOutput(finalCommand, envVars.toStringArray(), + workingDir != null ? workingDir : imageFs.getRootDir(), includeStderr); } public void restartWineServer() { diff --git a/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java b/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java index 9f3542cac1..2b19666e63 100644 --- a/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java +++ b/app/src/main/java/com/winlator/xenvironment/components/GlibcProgramLauncherComponent.java @@ -331,27 +331,8 @@ public String execShellCommand(String command, boolean includeStderr) { String finalCommand = box64Path + " " + command; // Execute the command and capture its output - try { - Log.d("GlibcProgramLauncherComponent", "Shell command is " + finalCommand); - java.lang.Process process = Runtime.getRuntime().exec(finalCommand, envVars.toStringArray(), workingDir != null ? workingDir : imageFs.getRootDir()); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream())); - - String line; - while ((line = reader.readLine()) != null) { - output.append(line).append("\n"); - } - if (includeStderr) { - while ((line = errorReader.readLine()) != null) { - output.append(line).append("\n"); - } - } - process.waitFor(); - } catch (Exception e) { - output.append("Error: ").append(e.getMessage()); - } - - // Format output: trim trailing whitespace/newlines - return output.toString().trim(); + Log.d("GlibcProgramLauncherComponent", "Shell command is " + finalCommand); + return ProcessHelper.execWithOutput(finalCommand, envVars.toStringArray(), + workingDir != null ? workingDir : imageFs.getRootDir(), includeStderr); } }