Skip to content

Fix crash loop caused by corrupted A/B testing cache file#297

Merged
ParaskP7 merged 2 commits intoAutomattic:trunkfrom
akirk:fix/corrupted-assignments-cache
Apr 3, 2026
Merged

Fix crash loop caused by corrupted A/B testing cache file#297
ParaskP7 merged 2 commits intoAutomattic:trunkfrom
akirk:fix/corrupted-assignments-cache

Conversation

@akirk
Copy link
Copy Markdown
Member

@akirk akirk commented Mar 31, 2026

Problem

My Pocket Casts has been stuck in a crash loop. Every launch fails with:

FATAL EXCEPTION: DefaultDispatcher-worker-1
java.io.EOFException: End of input
    at com.automattic.android.experimentation.local.CacheDtoJsonAdapter.b

After digging through the source, I found that assignments.json in files/experiments/ must have been left empty by an interrupted write. The stack trace makes this clear: FileNotFoundException would mean the file doesn't exist; a partial/truncated JSON would give JsonDataException. EOFException: End of input is specifically what Moshi throws when fromJson() receives an empty string — meaning readText() succeeded but returned "".

The reason I can't recover without a code fix: the file lives in filesDir, not cacheDir, so clearing the app cache does nothing. Clearing app data would fix the crash but I have starred episodes stored locally that are no longer available in the feed — that data would be gone permanently.

Fix

Treat an empty or unparseable file as a cache miss, delete it, and let the library re-fetch from the server. I also wrapped the init coroutine in runCatching so future read failures degrade gracefully rather than crashing the process.

Follow-up

Worth discussing whether this file should live in cacheDir instead of filesDir — it's a network cache that can always be re-fetched, and using cacheDir would mean Clear Cache recovers users from this situation automatically in the future.

Test plan

  • Added unit test: empty assignments.json returns null and deletes the file
  • Added unit test: corrupted assignments.json returns null and deletes the file
  • Existing tests continue to pass

An interrupted write to assignments.json (e.g. app killed mid-write) leaves
an empty or truncated file. On next launch, Moshi throws EOFException parsing
it, which propagates out of the FileBasedCache init coroutine and crashes the
app in a loop that Clear Cache cannot fix (file lives in filesDir, not cacheDir).

- Treat empty files as a cache miss and delete them
- Catch JSON parse failures, log and delete the corrupted file
- Wrap the init block's coroutine in runCatching so any future read failure
  degrades gracefully instead of crashing the process
@ParaskP7
Copy link
Copy Markdown
Contributor

ParaskP7 commented Apr 1, 2026

👋 @akirk , thanks for reporting and providing us with your fix, we'll take a look as soon as possible! 🙏

My Pocket Casts has been stuck in a crash loop.

Cc @MiSikora because of the mention of PCAndroid. 😊

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a crash loop in the experimentation cache layer by treating empty or unreadable assignments.json as a cache miss, deleting the bad file, and allowing assignments to be re-fetched instead of crashing on startup.

Changes:

  • Harden FileBasedCache.getAssignments() to delete and ignore empty (isBlank) cache files.
  • Catch JSON parsing failures when reading cached assignments, delete the corrupted file, and return null.
  • Add unit tests covering empty and corrupted cache-file scenarios.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
experimentation/src/main/java/com/automattic/android/experimentation/local/FileBasedCache.kt Adds defensive handling for empty/corrupt cache content and wraps init-time load in runCatching to avoid process crashes.
experimentation/src/test/java/com/automattic/android/experimentation/local/FileBasedCacheTest.kt Adds tests ensuring empty/corrupted cache files return null and are deleted.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

withContext(context = dispatcher) {
latestMutable = getAssignments()
runCatching { latestMutable = getAssignments() }
.onFailure { logger.e("Failed to load cached assignments: $it") }
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

runCatching failure logging interpolates the Throwable into the message but doesn’t pass it to logger.e(...). Since ExperimentLogger.e supports a throwable parameter, pass the exception so stacktraces are preserved (and avoid relying on Throwable.toString() in the message).

Suggested change
.onFailure { logger.e("Failed to load cached assignments: $it") }
.onFailure { throwable -> logger.e("Failed to load cached assignments", throwable) }

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +53
runCatching { cacheDtoJsonAdapter.fromJson(json) }
.onFailure {
logger.e("Cached assignments file is corrupted, deleting: ${file.path}")
file.delete()
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

When JSON parsing fails, the log message doesn’t include the underlying exception (it’s not passed to logger.e). Passing the Throwable will make it easier to diagnose which parsing failure occurred (EOF vs JsonDataException, etc.) while still deleting the file as a cache miss.

Copilot uses AI. Check for mistakes.
Preserves stack traces in logs when cache reads fail.
@ParaskP7
Copy link
Copy Markdown
Contributor

ParaskP7 commented Apr 2, 2026

👋 @akirk !

Thank you so much for your contribution to Tracks Android with this PR! 🥇

I have reviewed this and everything LGTM. I also triggered CI on it via a #298 PR, which I just pushed on the main repo, everything works as expected, thanks for making this A/B testing experience better for everyone! 🌟


Things Worth Noting

  1. The cacheDir follow-up you raise is worth a separate issue/PR. Moving assignments.json to cacheDir would let the OS and "Clear Cache" recover users automatically. But (I think) that's a breaking change (existing cached assignments would be lost on upgrade) so it needs its own discussion.

  2. No failFast considerationFileBasedCache has a failFast flag used in saveAssignments to throw on directory creation failure. The new getAssignments error handling ignores failFast — it always degrades gracefully. I think this is actually the right behavior (reading a corrupt cache should never crash regardless of failFast), but it's worth being aware of the asymmetry.


Overall, this is a 👍 from my side and if @MiSikora doesn't have anything more to share, let's merge this and create a new version of Tracks Android. 🚀

Copy link
Copy Markdown
Contributor

@MiSikora MiSikora left a comment

Choose a reason for hiding this comment

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

Looks good, no objections.

@ParaskP7 ParaskP7 merged commit 3893a8a into Automattic:trunk Apr 3, 2026
12 checks passed
@ParaskP7
Copy link
Copy Markdown
Contributor

ParaskP7 commented Apr 3, 2026

👋 @akirk @MiSikora and FYI that version 6.0.8 is ready for you to use.

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.

4 participants