Skip to content

fix: prevent crashes from stale intents after process death#546

Open
jeremybernstein wants to merge 1 commit intoutkarshdalal:masterfrom
jeremybernstein:jb/intents_crash_pr
Open

fix: prevent crashes from stale intents after process death#546
jeremybernstein wants to merge 1 commit intoutkarshdalal:masterfrom
jeremybernstein:jb/intents_crash_pr

Conversation

@jeremybernstein
Copy link
Contributor

@jeremybernstein jeremybernstein commented Feb 17, 2026

Summary

  • Guard getContainer() in XServerScreen — if container is gone after process death, navigate back instead of crashing
  • Pop to Home (not just one level) when exiting XServer, with fallback to clear entire backstack if Home is missing

Test plan

  • Kill app process while game is running, reopen via recent tasks — should land on Home, not crash
  • Normal game launch/exit flow still works as before

Fixes #619

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

PluviaMain now clears intermediate routes when navigating to XServer and uses a guarded popBackStack-with-fallback when returning to Home. XServerScreen wraps container retrieval in try/catch, logs errors, and early-exits to navigation if container is null. SettingsGroupDebug adds readTail (256 KB cap) and uses it for conditional crash/wine log dialogs.

Changes

Cohort / File(s) Summary
Navigation / XServer control
app/src/main/java/app/gamenative/ui/PluviaMain.kt, app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
PluviaMain: navigate to XServer using popUpTo to clear intermediate routes; returning from XServer now attempts a guarded popBackStack and falls back to explicit navigate-to-Home that clears the back stack. XServerScreen: wraps ContainerUtils.getContainer(...) in try/catch, logs exceptions, treats null as failure and triggers an early back-navigation exit.
Log reading & dialogs
app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt
Adds MAX_LOG_DISPLAY_BYTES = 256 * 1024L and readTail(file) to read entire small files or the last 256 KB with partial-line handling and error messages. Crash and Wine log dialogs are rendered only when the dialog flag is set and file exists; dialog text is populated asynchronously via produceState(readTail) and shows a loading message until ready.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Pluvia as PluviaMain (NavHost)
    participant Nav as NavController
    participant XScreen as XServerScreen
    participant Container as ContainerUtils

    User->>Pluvia: Tap "Launch XServer"
    Pluvia->>Nav: navigate(XServer) with popUpTo(clear intermediate)
    Nav->>XScreen: show XServer route
    XScreen->>Container: getContainer(context, appId)
    alt container retrieved
        XScreen->>XScreen: proceed with startup
    else exception or null
        XScreen->>Pluvia: trigger back-navigation
        Pluvia->>Nav: navigate(Home) with clearBackStack
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐇 I nibbled at the code's last tail,

popUpTo cleared the winding trail,
A careful catch made errors tame,
Logs read slow — then lit aflame,
Homeward hops, a cozy compile. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title directly aligns with a primary change in the PR: handling process death crashes by catching null containers in XServerScreen and preventing back-navigation crashes. However, the PR also addresses OOM issues with large logs, which is mentioned in the commit messages but not in the title.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jeremybernstein
Copy link
Contributor Author

These change appear to improve/fix random crashing I was getting when (re-)starting the app via Intents (e.g. from ES-DE), or when re-opening my clamshell device after it had been shut (during which time Android apparently killed off the process).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/PluviaMain.kt`:
- Around line 1003-1006: When checking currentRoute ==
PluviaScreen.XServer.route and attempting to pop back to Home, capture the
boolean result of navController.popBackStack(PluviaScreen.Home.route, inclusive
= false); if it returns false (Home not in back stack) then explicitly navigate
to Home (e.g., navController.navigate(PluviaScreen.Home.route) with appropriate
options) as a fallback so the UI does not remain on XServer. Update the block
around currentRoute/PluviaScreen.XServer.route to perform the pop, test the
result, and call navController.navigate to PluviaScreen.Home.route when
popBackStack fails.

In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 275-295: The readTail function risks OOM because
InputStream.skip() can skip fewer bytes than requested; replace the stream-based
tail logic in readTail with a RandomAccessFile-based approach: open the file
with RandomAccessFile, call seek(max(0, file.length() - MAX_LOG_DISPLAY_BYTES)),
read the remaining bytes via a buffered reader, drop the partial first line as
currently done, and prepend the "... (sizeKB, showing last XKB) ..." header;
keep the small-file path (file.readText()) and the exception handling intact and
reference MAX_LOG_DISPLAY_BYTES and readTail when making the change.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt (1)

281-293: Cache file.length() to avoid four separate stat syscalls and a subtle OOM edge case

file.length() is called four times (lines 281, 285, 286, 292). Because the file is actively written to (it's a live log), each call may return a different value. In the worst case, if the file grows significantly between lines 285 and 286, ByteArray(...) can allocate more than MAX_LOG_DISPLAY_BYTES. Since OutOfMemoryError is an Error (not an Exception), the catch block on line 295 won't intercept it, defeating the OOM guard.

♻️ Proposed refactor — capture length once
     } else {
-        java.io.RandomAccessFile(file, "r").use { raf ->
-            raf.seek(maxOf(0L, file.length() - MAX_LOG_DISPLAY_BYTES))
-            val bytes = ByteArray((file.length() - raf.filePointer).toInt())
-            raf.readFully(bytes)
-            val text = bytes.toString(Charsets.UTF_8)
-            // drop partial first line
-            val idx = text.indexOf('\n')
-            val trimmed = if (idx >= 0) text.substring(idx + 1) else text
-            "... (${file.length() / 1024}KB, showing last ${MAX_LOG_DISPLAY_BYTES / 1024}KB) ...\n$trimmed"
-        }
+        val length = file.length()
+        val seekPos = maxOf(0L, length - MAX_LOG_DISPLAY_BYTES)
+        java.io.RandomAccessFile(file, "r").use { raf ->
+            raf.seek(seekPos)
+            val bytes = ByteArray((length - seekPos).toInt())
+            raf.readFully(bytes)
+            val text = bytes.toString(Charsets.UTF_8)
+            // drop partial first line
+            val idx = text.indexOf('\n')
+            val trimmed = if (idx >= 0) text.substring(idx + 1) else text
+            "... (${length / 1024}KB, showing last ${MAX_LOG_DISPLAY_BYTES / 1024}KB) ...\n$trimmed"
+        }
     }

Also update line 281 to reuse the cached value:

-        if (file.length() <= MAX_LOG_DISPLAY_BYTES) {
+        val length = file.length()
+        if (length <= MAX_LOG_DISPLAY_BYTES) {
             file.readText()
         } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`
around lines 281 - 293, Cache the file length once into a local val (e.g. val
len = file.length()) and use that cached len everywhere instead of calling
file.length() multiple times; specifically, in SettingsGroupDebug.kt replace the
repeated file.length() calls with the cached len, compute start = maxOf(0L, len
- MAX_LOG_DISPLAY_BYTES) before raf.seek(start), and compute the byte array size
as val size = (len - start).toInt() so ByteArray(size) cannot grow beyond the
intended window and avoids the OOM/race between syscalls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 281-293: Cache the file length once into a local val (e.g. val len
= file.length()) and use that cached len everywhere instead of calling
file.length() multiple times; specifically, in SettingsGroupDebug.kt replace the
repeated file.length() calls with the cached len, compute start = maxOf(0L, len
- MAX_LOG_DISPLAY_BYTES) before raf.seek(start), and compute the byte array size
as val size = (len - start).toInt() so ByteArray(size) cannot grow beyond the
intended window and avoids the OOM/race between syscalls.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 117-125: The call to readTail(latestCrashFile) is performing
synchronous I/O on the main/composition thread and is re-run on every
recomposition; change the code to compute the file contents off the main thread
and cache the result keyed by latestCrashFile (e.g., use produceState or
remember with a coroutine/dispatcher) and then pass that cached/async-loaded
string into CrashLogDialog (also used for the Wine log block), keeping existing
handlers like onSave/saveResultContract and onDismissRequest unchanged.

---

Duplicate comments:
In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 275-300: The readTail function correctly replaces
InputStream.skip() with RandomAccessFile(file, "r") using seek() and
readFully(); leave the implementation as-is but ensure MAX_LOG_DISPLAY_BYTES
remains <= Int.MAX_VALUE so the ByteArray((len - start).toInt()) cast is safe,
retain the try/catch around readTail to handle EOFException or other IO errors,
and keep the logic in readTail (including the partial-line drop and the summary
prefix) in the RandomAccessFile use within readTail.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt (1)

283-310: LGTM — past InputStream.skip() OOM risk fully addressed.

RandomAccessFile.seek() + readFully() gives deterministic tail reads; readFully throws EOFException (caught by the outer catch) if the file is truncated mid-read, so there's no silent data loss. The check() on line 288 being outside the try/catch is intentional and correct: a developer accidentally raising MAX_LOG_DISPLAY_BYTES past Int.MAX_VALUE should surface as a hard crash, not silently degrade to a "Failed to read file" message.

One optional consistency nit: file.readText() on line 293 (small-file path) already defaults to Charsets.UTF_8 in Kotlin's stdlib, so there is no actual charset inconsistency with bytes.toString(Charsets.UTF_8) on line 300 — but making the encoding explicit (file.readText(Charsets.UTF_8)) would self-document the intent.

📝 Explicit charset for self-documentation
-            file.readText()
+            file.readText(Charsets.UTF_8)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`
around lines 283 - 310, Update readTail to explicitly use UTF-8 for the
small-file path: when the fast path uses file.readText(), call
file.readText(Charsets.UTF_8) so its charset matches the RandomAccessFile branch
(which uses Charsets.UTF_8); keep the existing check(MAX_LOG_DISPLAY_BYTES <=
Int.MAX_VALUE) and exception handling unchanged (symbols: readTail,
MAX_LOG_DISPLAY_BYTES, file.readText).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 283-310: Update readTail to explicitly use UTF-8 for the
small-file path: when the fast path uses file.readText(), call
file.readText(Charsets.UTF_8) so its charset matches the RandomAccessFile branch
(which uses Charsets.UTF_8); keep the existing check(MAX_LOG_DISPLAY_BYTES <=
Int.MAX_VALUE) and exception handling unchanged (symbols: readTail,
MAX_LOG_DISPLAY_BYTES, file.readText).

@jeremybernstein
Copy link
Contributor Author

jeremybernstein commented Feb 18, 2026

Verify each finding against the current code and only fix it if needed. Nitpick comments: In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`: - Around line 283-310: Update readTail to explicitly use UTF-8 for the small-file path: when the fast path uses file.readText(), call file.readText(Charsets.UTF_8) so its charset matches the RandomAccessFile branch (which uses Charsets.UTF_8); keep the existing check(MAX_LOG_DISPLAY_BYTES <= Int.MAX_VALUE) and exception handling unchanged (symbols: readTail, MAX_LOG_DISPLAY_BYTES, file.readText).

file.readText() without an argument defaults to Charsets.UTF_8:

https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.io/read-text.html

But ok, to make the intent 100% clear, I'll do it. For you, CR.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 287-310: readTail currently returns null when file is null or
doesn't exist which causes callers (crashText/wineText) to display "Loading..."
forever; change readTail so that when file == null or !file.exists() it returns
a distinct informative string (e.g., "File not found" possibly including
file?.name) instead of null, and in the catch block return "Failed to read file:
${e.message ?: e.javaClass.simpleName}" so the error message never becomes
"null"; update references to readTail (used for latestCrashFile handling)
accordingly so callers can distinguish missing files from a real loading state.

---

Duplicate comments:
In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 156-167: The Crash log dialog can stay stuck showing "Loading..."
because produceState's value can become null if readTail(latestWineLogFile)
returns null; update the produceState block used with
showWineLogDialog/latestWineLogFile so that the state never resolves to null
(e.g., use a non-null initialValue and assign value =
withContext(Dispatchers.IO) { readTail(latestWineLogFile) } ?: "File not found"
or an appropriate fallback), and ensure the CrashLogDialog call uses that
non-null fallback (fileText should not be the literal "Loading..." once the IO
read completes), keeping references: produceState, readTail, latestWineLogFile,
showWineLogDialog, CrashLogDialog, and saveWineLogContract.

Comment on lines 287 to 310
private fun readTail(file: File?): String? {
check(MAX_LOG_DISPLAY_BYTES <= Int.MAX_VALUE) { "MAX_LOG_DISPLAY_BYTES exceeds Int.MAX_VALUE" }
if (file == null || !file.exists()) return null
return try {
val len = file.length()
if (len <= MAX_LOG_DISPLAY_BYTES) {
file.readText(Charsets.UTF_8)
} else {
val start = maxOf(0L, len - MAX_LOG_DISPLAY_BYTES)
java.io.RandomAccessFile(file, "r").use { raf ->
raf.seek(start)
val bytes = ByteArray((len - start).toInt())
raf.readFully(bytes)
val text = bytes.toString(Charsets.UTF_8)
// drop partial first line
val idx = text.indexOf('\n')
val trimmed = if (idx >= 0) text.substring(idx + 1) else text
"... (${len / 1024}KB, showing last ${MAX_LOG_DISPLAY_BYTES / 1024}KB) ...\n$trimmed"
}
}
} catch (e: Exception) {
"Failed to read file: ${e.message}"
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

readTail returns null for "file not found", making callers show "Loading..." indefinitely.

Line 289 returns null for both file == null and !file.exists(). Both callers use crashText ?: "Loading..." / wineText ?: "Loading...", so if latestCrashFile existed when LaunchedEffect ran but was deleted before the user opens the dialog, readTail silently returns null and the dialog shows "Loading..." with no way to distinguish it from a genuine in-progress load.

🛠️ Proposed fix — return a distinct string for the "file not found" case
-    if (file == null || !file.exists()) return null
+    if (file == null) return null
+    if (!file.exists()) return "File no longer available."

Separate optional nit: e.message is nullable on the JVM; for certain exceptions the error string becomes "Failed to read file: null". Consider e.message ?: e.javaClass.simpleName as a fallback.

✨ Proposed improvement
-        "Failed to read file: ${e.message}"
+        "Failed to read file: ${e.message ?: e.javaClass.simpleName}"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private fun readTail(file: File?): String? {
check(MAX_LOG_DISPLAY_BYTES <= Int.MAX_VALUE) { "MAX_LOG_DISPLAY_BYTES exceeds Int.MAX_VALUE" }
if (file == null || !file.exists()) return null
return try {
val len = file.length()
if (len <= MAX_LOG_DISPLAY_BYTES) {
file.readText(Charsets.UTF_8)
} else {
val start = maxOf(0L, len - MAX_LOG_DISPLAY_BYTES)
java.io.RandomAccessFile(file, "r").use { raf ->
raf.seek(start)
val bytes = ByteArray((len - start).toInt())
raf.readFully(bytes)
val text = bytes.toString(Charsets.UTF_8)
// drop partial first line
val idx = text.indexOf('\n')
val trimmed = if (idx >= 0) text.substring(idx + 1) else text
"... (${len / 1024}KB, showing last ${MAX_LOG_DISPLAY_BYTES / 1024}KB) ...\n$trimmed"
}
}
} catch (e: Exception) {
"Failed to read file: ${e.message}"
}
}
private fun readTail(file: File?): String? {
check(MAX_LOG_DISPLAY_BYTES <= Int.MAX_VALUE) { "MAX_LOG_DISPLAY_BYTES exceeds Int.MAX_VALUE" }
if (file == null) return null
if (!file.exists()) return "File no longer available."
return try {
val len = file.length()
if (len <= MAX_LOG_DISPLAY_BYTES) {
file.readText(Charsets.UTF_8)
} else {
val start = maxOf(0L, len - MAX_LOG_DISPLAY_BYTES)
java.io.RandomAccessFile(file, "r").use { raf ->
raf.seek(start)
val bytes = ByteArray((len - start).toInt())
raf.readFully(bytes)
val text = bytes.toString(Charsets.UTF_8)
// drop partial first line
val idx = text.indexOf('\n')
val trimmed = if (idx >= 0) text.substring(idx + 1) else text
"... (${len / 1024}KB, showing last ${MAX_LOG_DISPLAY_BYTES / 1024}KB) ...\n$trimmed"
}
}
} catch (e: Exception) {
"Failed to read file: ${e.message ?: e.javaClass.simpleName}"
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`
around lines 287 - 310, readTail currently returns null when file is null or
doesn't exist which causes callers (crashText/wineText) to display "Loading..."
forever; change readTail so that when file == null or !file.exists() it returns
a distinct informative string (e.g., "File not found" possibly including
file?.name) instead of null, and in the catch block return "Failed to read file:
${e.message ?: e.javaClass.simpleName}" so the error message never becomes
"null"; update references to readTail (used for latestCrashFile handling)
accordingly so callers can distinguish missing files from a real loading state.

Comment on lines +263 to +273
try {
ContainerUtils.getContainer(context, appId)
} catch (e: Exception) {
Timber.e(e, "Container unavailable for $appId, likely process death")
null
}
}

if (container == null) {
LaunchedEffect(Unit) { navigateBack() }
return
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this? I don't think we should make this change. It just closes the game if the container doesn't exist? Isn't that what happens anyway if the container doesn't exist?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The difference is crash vs graceful exit. Without the guard, if the container is gone after process death (Intents launch is the typical launch case), getContainer() throws and the app crashes. With the guard, it catches the exception and navigates back to Home cleanly. So same end result (game closes), but without a crash dialog for the user.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this file also seem unnecessary? Please confirm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, I put two distinct issues into a single PR, I'm going to close/reopen 2 cleaner PRs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#605 is the logs OOM PR. This PR is cleaned up and isolated to the container death scenario.

@jeremybernstein jeremybernstein changed the title fix: process death crash loop and OOM on large logs fix: prevent crashes from stale intents after process death Feb 22, 2026
@utkarshdalal
Copy link
Owner

@jeremybernstein lgtm but there's a conflict

@jeremybernstein
Copy link
Contributor Author

dang, rebased yesterday. I'll take care of it.

@jeremybernstein
Copy link
Contributor Author

ah probably conflicts with #609 which is in a similar domain

@jeremybernstein
Copy link
Contributor Author

@utkarshdalal this will merge cleanly now. I am still seeing steam.steamclient.AsyncJobFailedException fairly often when launching from ES-DE, but I think that's a different issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crash from stale intents after process death

2 participants