Hi @vincent.bolta,
Thanks for the example builds!
First, we can rule out our dependency mirrors here. These builds were running on GCP US East, where we donât have any mirrors deployed. You can see it in the log: the mirror activation step ran but skipped, because thereâs no mirror deployment in that datacenter:
Switching to workflow: _SetupDependencyMirrors
+------------------------------------------------------------------------------+
| (0) Activate Gradle Mirrors |
+------------------------------------------------------------------------------+
...
Datacenter "US_EAST1" (region "us_east") has no Bitrise mirror deployment, skipping Gradle mirror activation
So for these builds the Bitrise Gradle dependency mirror was never active, and dependencies were resolved without it.
That said, your workaround is spot-on, and it points right at whatâs actually happening. Worth calling out why it works.
Robolectric doesnât resolve its android-all-instrumented jar through Gradle. It downloads it on demand, at test runtime, using its own resolver, and publishes it into your local Maven repo under ~/.m2/repository/org/robolectric. The way it publishes is the key detail: it stages the download into a temp directory and then moves it into ~/.m2. When the temp dir and ~/.m2 live on different filesystems (which is the case on the build VMs, where temp is on a tmpfs and ~/.m2 is on the disk), that âmoveâ canât be an atomic rename, so it falls back to a plain copy of a ~200 MB file. And a copy is not atomic.
Thatâs the race. As soon as two Robolectric test JVMs run concurrently and share the same ~/.m2, one can start downloading + copying the jar while another checks âdoes the jar exist yet?â, sees the half-written file, and starts reading it mid-copy. In a multi-module build like yours that concurrency comes mostly from Gradle running several modulesâ test tasks in parallel (it can also come from maxParallelForks > 1 within a single task), but either way they share one ~/.m2. Reading a partially-written jar fails at the ZIP layer, which is exactly the chain you saw:
EOFException â ClassNotFoundException â NoClassDefFoundError (Bundle)
This is a long-standing, documented Robolectric behaviour: robolectric/robolectric#2346, titled âandroid-all jar download race (tests failing when maxParallelForks > 1)â. A Robolectric maintainer confirms the exact mechanism in that thread:
Race condition while downloading android-all.jar ⌠first thread starts downloading via MavenDependencyResolver, which creates an empty or truncated file, which the second thread uses prematurely.
It lines up with everything youâve seen: itâs intermittent, it correlates with cold caches (the jar isnât there yet, so the download+copy happens during the test run), it only shows up under parallelism, and it usually self-heals on the next run, since once a complete copy has landed on disk subsequent reads get the full, valid file. The workaround called out in that same issue is exactly what youâd expect: make sure the dependency is already downloaded before the parallel test run starts.
We can see all of this in the build you shared: the mirror step skipped (US_EAST1 ⌠no Bitrise mirror deployment), and the failures are the exact ExceptionInInitializerError @ AndroidTestEnvironment â ⌠â EOFException @ RandomAccessFile chain, hitting several modulesâ test tasks (:data:bookmarks:impl, :feature:article:region, âŚ) that were running in parallel and sharing one ~/.m2.
Thatâs why caching ~/.m2/repository/org/robolectric helps: when the jar is already fully present before tests start, thereâs no concurrent download/copy for a parallel fork to read mid-write. Itâs a good mitigation and worth keeping in place (it speeds your builds up too).
One caveat though: caching only avoids the race, it doesnât remove it. Any time the jar isnât already in the cache (a cache miss, an eviction, a new or changed SDK level, a cleared or rebuilt cache) Robolectric falls back to the runtime download+copy, and the same flake can reappear. So treat the cache as a strong reduction in frequency, not a guaranteed permanent fix.
To close the race properly rather than just dodge it, the cleanest approach is to pre-fetch the jar serially in a setup step, before the parallel test run. The race only exists because the first download+copy happens concurrently with other Robolectric JVMs. If the jar is already complete on disk, every one of them just reads it.
The trick is that the warm-up itself must be serial, so no other Robolectric JVM is resolving/publishing the same missing artifact while that first download+copy completes. Run a tiny slice of your Robolectric suite on its own (a single small test class, no project-level parallelism, and maxParallelForks = 1), then let the full parallel build run as normal. For example, as a Script step before your test step:
# Warm up: run a Robolectric test serially so Robolectric downloads the required
# android-all jar(s) before the parallel test step. Note: maxParallelForks=1 only
# takes effect if your Gradle Test task is wired to read that property.
./gradlew :some:module:testDebugUnitTest --tests "*SomeFastRobolectricTest" \
--no-parallel --max-workers=1 -PmaxParallelForks=1
A few notes on the knobs:
--no-parallel / --max-workers=1 keep Gradle from running other modulesâ test tasks alongside the warm-up (the main concurrency source in your build).
maxParallelForks = 1 keeps a single test task from forking multiple test JVMs. The -PmaxParallelForks=1 above only does anything if your build script actually maps that property onto the test taskâs maxParallelForks, so otherwise hardcode it on the task for the warm-up.
One caveat for a multi-module repo like yours: a single warm-up test only fetches the android-all coordinate that that testâs config needs. If different modules pin different SDK levels (via @Config(sdk = ...)) or different Robolectric versions, they resolve different android-all artifacts, and any one thatâs still missing when the parallel run starts can re-trigger the same race. So make sure the warm-up covers every Robolectric runtime jar the full run needs: warm up one representative test per SDK level / Robolectric version in use, or run the affected Robolectric test tasks once serially before the normal parallel run.
Once all the required Robolectric runtime jar(s) are fully present in ~/.m2, your normal parallel test step should only read them, so the truncated-copy race is avoided for those artifacts.
Let us know if you still see flakiness with the cache in place, happy to dig further!