Skip to content

fix: prevent OOM when viewing large crash/wine logs#605

Merged
utkarshdalal merged 1 commit intoutkarshdalal:masterfrom
jeremybernstein:jb/OOM_log
Feb 22, 2026
Merged

fix: prevent OOM when viewing large crash/wine logs#605
utkarshdalal merged 1 commit intoutkarshdalal:masterfrom
jeremybernstein:jb/OOM_log

Conversation

@jeremybernstein
Copy link
Contributor

@jeremybernstein jeremybernstein commented Feb 22, 2026

Summary

  • Read crash/wine log files off main thread via produceState + Dispatchers.IO
  • Cap display at 256KB tail via RandomAccessFile.seek(), preventing OOM on large logs
  • Show truncation indicator with file size info

Test plan

  • Open a crash log > 256KB and verify it loads without OOM
  • Verify small logs still display in full
  • Check wine debug log dialog works the same way

Summary by cubic

Prevents OOM when opening large crash or Wine logs by reading off the main thread and showing only the last 256 KB with a clear truncation note.

  • Bug Fixes
    • Load crash/Wine logs on Dispatchers.IO via produceState with a “Loading…” placeholder; tail reader drops a partial first line and adds a size header.
    • Show friendly error text when a file is missing or cannot be read.

Written for commit 089e434. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling when loading debug and crash logs, showing friendly messages if logs cannot be read.
  • Refactor

    • Log content now loads asynchronously to prevent UI freezes during large file reads.
    • Log display is size-limited to improve performance and responsiveness.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 22, 2026

📝 Walkthrough

Walkthrough

Replaces synchronous log reads with asynchronous tail reads using produceState and Dispatchers.IO for crash and Wine debug logs, adds a MAX_LOG_DISPLAY_BYTES constant, and introduces a private readTail(file) helper with error handling to avoid UI blocking.

Changes

Cohort / File(s) Summary
Debug Settings Log Loading
app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt
Replaces direct synchronous file reads with produceState+Dispatchers.IO produced tail strings (crashText, wineText). Adds MAX_LOG_DISPLAY_BYTES and a private readTail(file) helper that bounds reads and handles errors. Updates dialogs to show produced text and conditions visibility on readiness; preserves save/channel behaviors and adds coroutine imports (Dispatchers, withContext).

Sequence Diagram(s)

sequenceDiagram
  participant UI as "Settings UI"
  participant Producer as "produceState Coroutine"
  participant FS as "File System (log file)"
  UI->>Producer: request log tail (visible)
  Producer->>FS: readTail(file) on Dispatchers.IO
  FS-->>Producer: file tail or error
  Producer-->>UI: emit produced tail string
  UI->>UI: open Crash/Wine dialog with produced content
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰
I nibble at tails of logs all night,
Fetching the last crumbs with gentle byte,
On IO threads I dance and sing,
So UIs stay light on trembling wing,
Hooray—no freeze; the dialogs take flight! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: prevent OOM when viewing large crash/wine logs' clearly and accurately describes the main change: preventing out-of-memory errors when viewing large crash or Wine log files by implementing tail-based reading with a 256KB size limit.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 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.

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 4 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 (5)
app/src/main/java/app/gamenative/ui/screen/library/components/LibraryAppItem.kt (2)

309-343: The two-tier fallback (banner → icon → text) is well structured.

The flow gracefully degrades: if the banner image fails and an icon URL is available, it shows the icon on a solid background; if the icon also fails, it falls back to text. The iconFallbackFailed guard prevents retries and the matchParentSize() overlay is appropriate.

One minor observation: when the banner fails before iconUrl resolves (initially null), hideText is set to false immediately. When iconUrl later resolves and SideEffect re-fires, showIconFallback becomes true but hideText remains false, so both the icon overlay and the GameInfoBlock text will render simultaneously. If that's unintended, you could set hideText = true when showIconFallback is set:

Optional: hide text when icon fallback activates
                         onFailure = {
                             if (!iconUrl.isNullOrEmpty() && !iconFallbackFailed) {
                                 showIconFallback = true
+                                hideText = true
                             } else {
                                 hideText = false
                             }
                             alpha = 0.1f
                         },
🤖 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/library/components/LibraryAppItem.kt`
around lines 309 - 343, When the banner onFailure sets showIconFallback = true
(and later when showIconFallback is set by SideEffect), also set hideText = true
so the GameInfoBlock text doesn't render simultaneously with the icon overlay;
update the banner failure handler and the place where showIconFallback is
toggled (references: showIconFallback, hideText, iconFallbackFailed,
ListItemImage onFailure) to ensure hideText is true whenever showIconFallback
becomes true, and restore hideText = false only when iconFallbackFailed is true
or the icon onFailure path runs.

215-232: findSteamGridDBImage performs file I/O on the main thread inside remember.

This local function calls folder.listFiles() synchronously during composition. While this is pre-existing code and not introduced by this PR, it's inconsistent with the new produceState + Dispatchers.IO pattern used for iconUrl. Consider moving this into a produceState or LaunchedEffect in a follow-up to keep the GRID path fully off the main thread.

🤖 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/library/components/LibraryAppItem.kt`
around lines 215 - 232, The function findSteamGridDBImage performs synchronous
file I/O (folder.listFiles) on the main thread during composition; move this
logic out of the composable and into an asynchronous producer (e.g., use
produceState or LaunchedEffect with Dispatchers.IO) that computes the grid image
path for CustomGame apps and exposes a state value the UI observes;
specifically, extract the body of findSteamGridDBImage
(CustomGameScanner.getFolderPathFromAppId, java.io.File(path).listFiles,
filtering by "steamgriddb_$imageType" and supported extensions, and
android.net.Uri.fromFile) into that IO coroutine so the composable only reads
the resulting state and does not perform file I/O on the main thread.
app/src/main/java/app/gamenative/ui/util/Images.kt (1)

49-51: Wrapping onFailure in SideEffect will re-fire on every recomposition of the failure slot.

Unlike the previous direct call (which also ran during composition but only conceptually "once"), SideEffect explicitly contracts to run after every successful recomposition. The callers in LibraryAppItem guard against infinite loops via iconFallbackFailed and Compose state deduplication — but any future caller that mutates state inside onFailure without similar guards will trigger an infinite recomposition loop.

Consider adding a brief KDoc on the onFailure parameter to warn callers about this behavior.

🤖 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/util/Images.kt` around lines 49 - 51, The
failure slot currently wraps onFailure with SideEffect (so it will run after
every successful recomposition), which can cause infinite recompositions if
callers mutate state without guards; update the KDoc for the onFailure parameter
(in the composable defined in Images.kt where failure = { SideEffect {
onFailure() } } is used) to clearly warn that SideEffect runs after every
recomposition and that callers must ensure their onFailure implementation is
idempotent or guarded (e.g., use flags like iconFallbackFailed or other Compose
state deduplication) to avoid triggering recomposition loops; mention
LibraryAppItem as an example of correct guarding.
app/src/main/java/app/gamenative/utils/CustomGameScanner.kt (1)

196-219: findCachedIconForCustomGame skips the hasContainer guard used by the other overload.

In findIconFileForCustomGame(context, appId) (Line 146), you call cm.hasContainer(appId) before cm.getContainerById(appId). Here, getContainerById is called directly (Line 207). If getContainerById throws on a missing container (rather than returning null), the catch block handles it — but adding hasContainer would be more consistent and avoid relying on exception flow for a normal case.

Optional: add hasContainer guard for consistency
         try {
             val cm = ContainerManager(context)
-            val container = cm.getContainerById(appId)
-            val relExe = container?.executablePath
+            if (cm.hasContainer(appId)) {
+                val container = cm.getContainerById(appId)
+                val relExe = container?.executablePath
-            if (!relExe.isNullOrEmpty()) {
-                val exeFile = File(folder, relExe.replace('/', File.separatorChar))
-                val outIco = File(exeFile.parentFile, exeFile.nameWithoutExtension + ".extracted.ico")
-                if (outIco.exists()) return outIco.absolutePath
+                if (!relExe.isNullOrEmpty()) {
+                    val exeFile = File(folder, relExe.replace('/', File.separatorChar))
+                    val outIco = File(exeFile.parentFile, exeFile.nameWithoutExtension + ".extracted.ico")
+                    if (outIco.exists()) return outIco.absolutePath
+                }
             }
         } catch (e: Exception) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/utils/CustomGameScanner.kt` around lines 196
- 219, The findCachedIconForCustomGame function currently calls
ContainerManager.getContainerById(appId) directly; instead, first check
ContainerManager.hasContainer(appId) and only call getContainerById when that
returns true to avoid relying on exceptions for the normal "no container"
case—update the block that constructs ContainerManager(context) to call
cm.hasContainer(appId) before cm.getContainerById(appId) and keep the existing
try/catch for unexpected errors.
app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt (1)

287-309: readTail is solid but has a minor TOCTOU window between file.length() and readFully.

If the log file is truncated or rotated between the length() call (Line 291) and readFully (Line 299), an EOFException will be thrown. The catch block on Line 307 handles this gracefully, so this isn't a bug — just noting it for awareness.

One small readability nit: (len - start).toInt() is always equal to MAX_LOG_DISPLAY_BYTES.toInt() in the truncated branch. You could use the constant directly to make the intent clearer.

Optional: use constant directly for clarity
             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())
+                val bytes = ByteArray(MAX_LOG_DISPLAY_BYTES.toInt())
                 raf.readFully(bytes)
🤖 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 - 309, readTail has a TOCTOU window between len/start and
raf.readFully and uses (len - start).toInt() though that value effectively
equals MAX_LOG_DISPLAY_BYTES; update read logic in readTail to allocate the read
buffer using MAX_LOG_DISPLAY_BYTES.toInt() (instead of (len - start).toInt())
and then bound the actual bytes to read by checking raf.length() - start (or use
raf.read into a buffer and use the returned count) so you don't request more
bytes than available; keep the try/catch but consider specifically handling
EOFException from raf.readFully by falling back to the bytes actually read or
returning the partial contents to avoid the transient rotation error.
🤖 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/library/components/LibraryAppItem.kt`:
- Around line 309-343: When the banner onFailure sets showIconFallback = true
(and later when showIconFallback is set by SideEffect), also set hideText = true
so the GameInfoBlock text doesn't render simultaneously with the icon overlay;
update the banner failure handler and the place where showIconFallback is
toggled (references: showIconFallback, hideText, iconFallbackFailed,
ListItemImage onFailure) to ensure hideText is true whenever showIconFallback
becomes true, and restore hideText = false only when iconFallbackFailed is true
or the icon onFailure path runs.
- Around line 215-232: The function findSteamGridDBImage performs synchronous
file I/O (folder.listFiles) on the main thread during composition; move this
logic out of the composable and into an asynchronous producer (e.g., use
produceState or LaunchedEffect with Dispatchers.IO) that computes the grid image
path for CustomGame apps and exposes a state value the UI observes;
specifically, extract the body of findSteamGridDBImage
(CustomGameScanner.getFolderPathFromAppId, java.io.File(path).listFiles,
filtering by "steamgriddb_$imageType" and supported extensions, and
android.net.Uri.fromFile) into that IO coroutine so the composable only reads
the resulting state and does not perform file I/O on the main thread.

In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Around line 287-309: readTail has a TOCTOU window between len/start and
raf.readFully and uses (len - start).toInt() though that value effectively
equals MAX_LOG_DISPLAY_BYTES; update read logic in readTail to allocate the read
buffer using MAX_LOG_DISPLAY_BYTES.toInt() (instead of (len - start).toInt())
and then bound the actual bytes to read by checking raf.length() - start (or use
raf.read into a buffer and use the returned count) so you don't request more
bytes than available; keep the try/catch but consider specifically handling
EOFException from raf.readFully by falling back to the bytes actually read or
returning the partial contents to avoid the transient rotation error.

In `@app/src/main/java/app/gamenative/ui/util/Images.kt`:
- Around line 49-51: The failure slot currently wraps onFailure with SideEffect
(so it will run after every successful recomposition), which can cause infinite
recompositions if callers mutate state without guards; update the KDoc for the
onFailure parameter (in the composable defined in Images.kt where failure = {
SideEffect { onFailure() } } is used) to clearly warn that SideEffect runs after
every recomposition and that callers must ensure their onFailure implementation
is idempotent or guarded (e.g., use flags like iconFallbackFailed or other
Compose state deduplication) to avoid triggering recomposition loops; mention
LibraryAppItem as an example of correct guarding.

In `@app/src/main/java/app/gamenative/utils/CustomGameScanner.kt`:
- Around line 196-219: The findCachedIconForCustomGame function currently calls
ContainerManager.getContainerById(appId) directly; instead, first check
ContainerManager.hasContainer(appId) and only call getContainerById when that
returns true to avoid relying on exceptions for the normal "no container"
case—update the block that constructs ContainerManager(context) to call
cm.hasContainer(appId) before cm.getContainerById(appId) and keep the existing
try/catch for unexpected errors.

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

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

288-288: check() on a compile-time constant runs on every call but can never fail.

MAX_LOG_DISPLAY_BYTES is 262144L, which is trivially <= Int.MAX_VALUE. The assertion adds overhead on every readTail invocation without providing any runtime safety. Replace it with a comment or a compile-time require equivalent (e.g., a @Suppress-annotated val assertion at class initialisation), or simply remove it.

♻️ 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 "File not found: ${file?.name ?: "null"}"
+// MAX_LOG_DISPLAY_BYTES (262144) fits in Int, so the toInt() cast on ByteArray size is safe.
+private fun readTail(file: File?): String {
+    if (file == null || !file.exists()) return "File not found: ${file?.name ?: "null"}"
🤖 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` at
line 288, The runtime check check(MAX_LOG_DISPLAY_BYTES <= Int.MAX_VALUE) in
SettingsGroupDebug (affecting readTail calls) is redundant because
MAX_LOG_DISPLAY_BYTES is a compile-time constant; remove the check and either
replace it with a clarifying comment next to the MAX_LOG_DISPLAY_BYTES
declaration or move a one-time assert into class/object initialization if you
prefer an explicit compile-time-style assertion, ensuring you reference
MAX_LOG_DISPLAY_BYTES and not leave repeated checks inside readTail.
🤖 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 292-305: The KB labels use integer floor division (len / 1024) so
files just over the cap appear identical to the "showing all" case; update the
string construction in the branch that builds the trimmed output (inside the
RandomAccessFile use block) to compute a rounded-up KB value for total size —
e.g. use (len + 1023) / 1024 — or alternatively display the total size in bytes,
and replace the occurrences of len / 1024 and MAX_LOG_DISPLAY_BYTES / 1024 with
the new rounded or bytes representation so the "(...KB, showing last ...KB)"
message always clearly indicates truncation when it occurred.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt`:
- Line 288: The runtime check check(MAX_LOG_DISPLAY_BYTES <= Int.MAX_VALUE) in
SettingsGroupDebug (affecting readTail calls) is redundant because
MAX_LOG_DISPLAY_BYTES is a compile-time constant; remove the check and either
replace it with a clarifying comment next to the MAX_LOG_DISPLAY_BYTES
declaration or move a one-time assert into class/object initialization if you
prefer an explicit compile-time-style assertion, ensuring you reference
MAX_LOG_DISPLAY_BYTES and not leave repeated checks inside 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)

284-285: Consider also annotating readTail with @VisibleForTesting or adding a brief KDoc.

Currently readTail is private and only called from two produceState lambdas, which is fine. If you ever want to unit-test the truncation logic independently (boundary at exactly 256 KB, multi-byte characters, missing files, etc.), making it internal + @VisibleForTesting with a short doc comment would help. Not required for this PR, just a thought.

🤖 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 284 - 285, The helper function readTail (currently private and used
from two produceState lambdas) should be made test-accessible and documented:
change its visibility to internal and annotate with `@VisibleForTesting`, and add
a short KDoc noting its behavior and the MAX_LOG_DISPLAY_BYTES boundary so tests
can target truncation and encoding edge cases; reference the readTail function
name, the MAX_LOG_DISPLAY_BYTES constant, and the produceState call sites when
making this change.
🤖 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 284-285: The helper function readTail (currently private and used
from two produceState lambdas) should be made test-accessible and documented:
change its visibility to internal and annotate with `@VisibleForTesting`, and add
a short KDoc noting its behavior and the MAX_LOG_DISPLAY_BYTES boundary so tests
can target truncation and encoding edge cases; reference the readTail function
name, the MAX_LOG_DISPLAY_BYTES constant, and the produceState call sites when
making this change.

@jeremybernstein
Copy link
Contributor Author

jeremybernstein commented Feb 22, 2026

Currently readTail is private and only called from two produceState lambdas, which is fine. If you ever want to unit-test the truncation logic independently (boundary at exactly 256 KB, multi-byte characters, missing files, etc.), making it internal + @VisibleForTesting with a short doc comment would help. Not required for this PR, just a thought.

I think this is out of scope (and unjustified), if we add tests, we can make this change later. @coderabbitai

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 22, 2026

Tip

For best results, initiate chat on the files or code changes.

@jeremybernstein, understood! You're absolutely right—deferring that change until testing is actually added keeps this PR focused on the core OOM fix. I've noted your preference for keeping PR scope small and targeted, which makes perfect sense here. The visibility change can easily be made when the need arises. 👍


🧠 Learnings used
Learnt from: unbelievableflavour
Repo: utkarshdalal/GameNative PR: 157
File: app/src/main/java/app/gamenative/service/GameManager.kt:60-63
Timestamp: 2025-09-18T12:38:38.471Z
Learning: User prefers to keep PR scope small and focused, deferring non-critical refactoring suggestions to future PRs when the functionality is more fully implemented.

@utkarshdalal utkarshdalal merged commit 7c652ec into utkarshdalal:master Feb 22, 2026
2 checks passed
@jeremybernstein jeremybernstein deleted the jb/OOM_log branch February 23, 2026 07:50
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.

2 participants