Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2
- name: Run client game tests
run: ./gradlew :1.21.8:runProductionClientGameTest
run: ./gradlew :1.21.8-fabric:runProductionClientGameTest
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
Expand Down
169 changes: 158 additions & 11 deletions build.gradle.kts → build.fabric.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import java.io.RandomAccessFile
import java.util.concurrent.TimeUnit
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

Expand Down Expand Up @@ -82,6 +83,23 @@ configurations.named(clientGametestSourceSet.runtimeClasspathConfigurationName)
extendsFrom(configurations[sourceSets.getByName("client").runtimeClasspathConfigurationName])
}

val clienttestSourceSet = sourceSets.create("clienttest") {
compileClasspath += sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().output
compileClasspath += sourceSets.getByName("client").output
runtimeClasspath += sourceSets.getByName("client").output
kotlin.srcDir("src/clienttest/kotlin")
}

configurations.named(clienttestSourceSet.compileClasspathConfigurationName) {
extendsFrom(configurations[sourceSets.main.get().compileClasspathConfigurationName])
extendsFrom(configurations[sourceSets.getByName("client").compileClasspathConfigurationName])
}
configurations.named(clienttestSourceSet.runtimeClasspathConfigurationName) {
extendsFrom(configurations[sourceSets.main.get().runtimeClasspathConfigurationName])
extendsFrom(configurations[sourceSets.getByName("client").runtimeClasspathConfigurationName])
}

loom {
mods {
register("connectedtank-client-test") {
Expand All @@ -105,6 +123,25 @@ loom {
}
}

loom {
mods {
register("connectedtank-clienttest") {
sourceSet(clienttestSourceSet)
}
}

createRemapConfigurations(clienttestSourceSet)

runs {
register("clientTest") {
inherit(runs.getByName("client"))
source(clienttestSourceSet)
property("connectedtank.clienttest")
runDir("build/run/clientTest")
}
}
}

dependencies {
val fabricApiVersion = when (mcVersion) {
"1.21.8" -> "0.132.0+1.21.8"
Expand Down Expand Up @@ -150,10 +187,13 @@ dependencies {
modRuntimeOnly("me.shedaniel:RoughlyEnoughItems-fabric:$reiVersion")
modCompileOnly("maven.modrinth:jade:$jadeVersion")
modRuntimeOnly("maven.modrinth:jade:$jadeVersion")

"clienttestImplementation"(libs.kotlinx.coroutines.core)
}

tasks {
processResources {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
inputs.property("version", project.version)
inputs.property("minecraft_version", mcVersion)
inputs.property("java_version", 21)
Expand All @@ -172,6 +212,9 @@ tasks {
withType<JavaCompile>().configureEach {
options.release.set(21)
}
withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
val clientGametestJar = register<Jar>("clientGametestJar") {
from(clientGametestSourceSet.output)
archiveClassifier.set("client-gametest")
Expand Down Expand Up @@ -270,24 +313,93 @@ fun findXvfb(): String? {
}
}

/**
* Compile a stub libpulse-simple.so that returns dummy handles to prevent
* flite (TTS) from crashing with SIGABRT when PulseAudio daemon is unavailable.
* flite calls pa_simple_write() without null-checking the handle from pa_simple_new().
*/
fun ensurePulseStub(): File? {
val stubDir = layout.buildDirectory.dir("pulse-stub").get().asFile
val stubLib = File(stubDir, "libpulse-simple-stub.so")
if (stubLib.exists()) return stubLib

// Check if gcc is available
val hasGcc = runCatching {
ProcessBuilder("which", "gcc").redirectErrorStream(true).start().waitFor() == 0
}.getOrDefault(false)
if (!hasGcc) return null

stubDir.mkdirs()
val stubSrc = File(stubDir, "pulse_stub.c")
stubSrc.writeText(
"""
#include <stddef.h>
void *pa_simple_new(const void *s, const char *n, int d,
const char *dev, const char *sn,
const void *ss, const void *map, int *e) {
static char dummy; return &dummy;
}
int pa_simple_write(void *p, const void *data, size_t bytes, int *e) { return 0; }
int pa_simple_drain(void *p, int *e) { return 0; }
void pa_simple_free(void *p) {}
int pa_simple_read(void *p, void *data, size_t bytes, int *e) { return 0; }
size_t pa_simple_get_latency(void *p, int *e) { return 0; }
int pa_simple_flush(void *p, int *e) { return 0; }
""".trimIndent(),
)
val result = ProcessBuilder("gcc", "-shared", "-fPIC", "-o", stubLib.absolutePath, stubSrc.absolutePath)
.redirectErrorStream(true)
.start()
if (result.waitFor(10, TimeUnit.SECONDS) && result.exitValue() == 0) {
return stubLib
}
return null
}

fun startXvfb(xvfb: String): Pair<Process, String> {
for (displayNum in 99..199) {
val display = ":$displayNum"
if (File("/tmp/.X11-unix/X$displayNum").exists()) continue

val process = ProcessBuilder(xvfb, display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp")
.redirectErrorStream(true)
.start()

val socketFile = File("/tmp/.X11-unix/X$displayNum")
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) break
if (socketFile.exists()) return process to display
Thread.sleep(100)
// Use file lock to prevent race conditions when multiple Gradle subprojects start Xvfb in parallel
val lockFile = File("/tmp/.xvfb-gradle-lock-$displayNum")
val raf = try {
RandomAccessFile(lockFile, "rw")
} catch (_: Exception) {
continue
}
val lock = try {
raf.channel.tryLock()
} catch (_: Exception) {
raf.close()
continue
}
if (lock == null) {
raf.close()
continue
}

try {
// Double-check socket after acquiring lock
if (File("/tmp/.X11-unix/X$displayNum").exists()) continue

val process = ProcessBuilder(xvfb, display, "-screen", "0", "1280x1024x24", "-nolisten", "tcp")
.redirectErrorStream(true)
.start()

val socketFile = File("/tmp/.X11-unix/X$displayNum")
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
if (!process.isAlive) break
if (socketFile.exists()) return process to display
Thread.sleep(100)
}

if (process.isAlive) process.destroyForcibly()
if (process.isAlive) process.destroyForcibly()
} finally {
lock.release()
raf.close()
}
}
error("Failed to start Xvfb: no available display number in :99..:199")
}
Expand Down Expand Up @@ -329,5 +441,40 @@ tasks.named<JavaExec>("runClientGameTest") {

logger.lifecycle("Started Xvfb on display $display (pid: ${process.pid()})")
environment("DISPLAY", display)
environment("PULSE_SERVER", "/dev/null")
environment("ALSOFT_DRIVERS", "null")
ensurePulseStub()?.let { stub ->
environment("LD_PRELOAD", stub.absolutePath)
logger.lifecycle("Using PulseAudio stub: ${stub.absolutePath}")
}
}
}

tasks.named<JavaExec>("runClientTest") {
notCompatibleWithConfigurationCache("Manages Xvfb process lifecycle at execution time")
finalizedBy(cleanupXvfbTask)

doFirst {
if (!needsXvfb()) return@doFirst

val xvfb = findXvfb() ?: error(
"No usable DISPLAY found and Xvfb is not installed. " +
"Install Xvfb or run with a display server (e.g., xvfb-run ./gradlew runClientTest)",
)

val (process, display) = startXvfb(xvfb)
xvfbState.set(process)
val shutdownHook = Thread { if (process.isAlive) process.destroyForcibly() }
Runtime.getRuntime().addShutdownHook(shutdownHook)
xvfbShutdownHook.set(shutdownHook)

logger.lifecycle("Started Xvfb on display $display (pid: ${process.pid()})")
environment("DISPLAY", display)
environment("PULSE_SERVER", "/dev/null")
environment("ALSOFT_DRIVERS", "null")
ensurePulseStub()?.let { stub ->
environment("LD_PRELOAD", stub.absolutePath)
logger.lifecycle("Using PulseAudio stub: ${stub.absolutePath}")
}
}
}
Loading
Loading