Skip to content

Commit bbc2a0f

Browse files
david-allisonmikehardy
authored andcommitted
build(ci): fix OOM errors on macOS
...by taking physical memory into account when deciding how many test processes to spawn. Even though `macos-14` has 3 CPUs, there is only 7GB of memory. Each test process has a max heap of 2GB, with the addition of Gradle this is enough to OOM the machine. So we limit the number of test processes based on RAM `gradleDaemonHeapBytes` is used because `Runtime.getRuntime().maxMemory()` is GC-dependent (default => 3GB; ParallelGC => 2GB) Now: * 3GB reserved for Gradle (org.gradle.jvmargs=-Xmx3072M) and the OS * 4GB remaining * Heap size: 2GB * => 2 forks Fixes 21168 Assisted-by: Claude Opus 4.8 - diagnosis & some code
1 parent 644cab5 commit bbc2a0f

1 file changed

Lines changed: 50 additions & 6 deletions

File tree

build.gradle.kts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat
88
import org.gradle.internal.jvm.Jvm
99
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
1010
import org.jlleitschuh.gradle.ktlint.KtlintExtension
11+
import java.lang.management.ManagementFactory
1112
import java.util.Properties
1213
import kotlin.math.max
1314
import kotlin.system.exitProcess
@@ -44,6 +45,12 @@ val testSummaryService = System.getenv("GITHUB_STEP_SUMMARY")?.let { path ->
4445
}
4546
}
4647

48+
/**
49+
* Per-fork JVM heap for unit tests.
50+
* See [Test.setMaxHeapSize]
51+
*/
52+
val unitTestForkMaxHeapGb = 2
53+
4754
// Here we extract per-module "best practices" settings to a single top-level evaluation
4855
subprojects {
4956
apply(plugin = "org.jlleitschuh.gradle.ktlint")
@@ -61,7 +68,7 @@ subprojects {
6168
// tell backend to avoid rollover time, and disable interval fuzzing
6269
it.environment("ANKI_TEST_MODE", "1")
6370

64-
it.maxHeapSize = "2g"
71+
it.maxHeapSize = "${unitTestForkMaxHeapGb}g"
6572
it.minHeapSize = "1g"
6673

6774
it.useJUnitPlatform()
@@ -158,6 +165,7 @@ if (jvmVersion !in jvmVersionLowerBound..jvmVersionUpperBound) {
158165
}
159166

160167
val ciBuild by extra(System.getenv("CI") == "true") // true when running on GitHub Actions
168+
val isMacOs = System.getProperty("os.name") == "Mac OS X"
161169
// allows for -Dpre-dex=false to be set
162170
val preDexEnabled by extra("true" == System.getProperty("pre-dex", "true"))
163171
// allows for universal APKs to be generated
@@ -168,12 +176,48 @@ var androidTestVariantName by extra(
168176
if (testReleaseBuild) "Release" else "Debug"
169177
)
170178

179+
private fun sysctl(key: String): Long =
180+
providers.exec {
181+
commandLine("sysctl", "-n", key)
182+
}.standardOutput.asText.get().trim().toLong()
183+
184+
/**
185+
* The Gradle daemon's `-Xmx` max heap, in bytes.
186+
*
187+
* Reads from the launch flags as `getRuntime().maxMemory()` is GC-dependent.
188+
*
189+
* @throws IllegalStateException if `-Xmx` is missing or invalid.
190+
*/
191+
private fun gradleDaemonHeapBytes(): Long {
192+
val xmx = ManagementFactory.getRuntimeMXBean().inputArguments
193+
.lastOrNull { it.startsWith("-Xmx") } // last -Xmx wins, as in the JVM
194+
?: error("Gradle daemon has no -Xmx flag")
195+
val match = Regex("-Xmx(\\d+)([MG])", RegexOption.IGNORE_CASE).matchEntire(xmx)
196+
?: error("Cannot parse Gradle daemon heap from '$xmx'; expected -Xmx<n>M or -Xmx<n>G")
197+
val size = match.groupValues[1].toLong()
198+
val byteMultiplier = when (match.groupValues[2].uppercase()) {
199+
"G" -> 1024L * 1024 * 1024
200+
else -> 1024L * 1024 // M
201+
}
202+
return size * byteMultiplier
203+
}
204+
171205
val gradleTestMaxParallelForks by extra(
172-
if (System.getProperty("os.name") == "Mac OS X") {
173-
// macOS reports hardware cores. This is accurate for CI, Intel (halved due to SMT) and Apple Silicon
174-
providers.exec {
175-
commandLine("sysctl", "-n", "hw.physicalcpu")
176-
}.standardOutput.asText.get().trim().toInt()
206+
if (isMacOs) {
207+
// macOS reports hardware cores.
208+
// This is accurate for CI, Intel (halved due to SMT) and Apple Silicon
209+
val physicalCpus = sysctl("hw.physicalcpu")
210+
211+
if (ciBuild) {
212+
// #21168: The `macos-14` CI runner has only 7GB RAM and OOMs (exit 134) so bound by RAM.
213+
// Reserve the daemon's own heap (the OS shares its slack); split the rest into forks.
214+
val forkHeapBytes = unitTestForkMaxHeapGb * 1024L * 1024 * 1024
215+
val availableBytes = sysctl("hw.memsize") - gradleDaemonHeapBytes()
216+
val memoryBoundForkProcesses = max(1L, availableBytes / forkHeapBytes)
217+
minOf(physicalCpus, memoryBoundForkProcesses).toInt()
218+
} else {
219+
physicalCpus.toInt()
220+
}
177221
} else if (ciBuild) {
178222
// GitHub Actions run on Standard_D4ads_v5 Azure Compute Units with 4 vCPUs
179223
// They appear to be 2:1 vCPU to CPU on Linux/Windows with two vCPU cores but with performance 1:1-similar

0 commit comments

Comments
 (0)