From 0658550d09f0eb8daffafe716f734c85e59797b9 Mon Sep 17 00:00:00 2001 From: hgranthorner <37941012+hgranthorner@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:07:31 -0400 Subject: [PATCH] Serialize Gradle and test runs --- .gitignore | 1 + AGENTS.md | 15 ++++++++------- gradle-lock.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ jaigradle | 22 +++++++++++++++++++--- jaitest | 12 ++++++++---- 5 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 gradle-lock.sh diff --git a/.gitignore b/.gitignore index 55f0c72..ce66b90 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .intellijPlatform .kotlin build +.gradle-test.lock diff --git a/AGENTS.md b/AGENTS.md index a0bac9f..904ce06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -277,12 +277,13 @@ The fast loop for grammar work: `ls -l build/classes/java/main/dev/hgh/jai/parser/JaiParser.class src/main/gen/dev/hgh/jai/parser/JaiParser.java`. The build wiring in `build.gradle.kts` should prevent both, but `rm -rf build` settles it. -- **Concurrent Gradle runs corrupt the test results.** If something else (an - IDE, an agent's background checker) runs `test` at the same time, one of the - two dies with `java.io.EOFException` or - `NoSuchFileException: .../in-progress-results-generic.bin`, and no XML is - written. It is infrastructure, not a test failure — `./jaitest` retries once - automatically. Do not go debugging the test that "failed". +- **Concurrent Gradle runs corrupt the test results.** `./jaigradle` and + `./jaitest` serialize project Gradle work with the atomic `.gradle-test.lock` + lock, including test-result cleanup and retries. Never run `./gradlew` + directly: it bypasses both the JDK wrapper and this lock. If an interrupted + process leaves a lock behind, confirm no Gradle run is active, then remove + `.gradle-test.lock` and rerun. The JUnit wrapper still retries once for + unexpected no-XML failures; do not debug a test until the XML exists. - **A poisoned build cache can make tests silently vanish.** Symptom: `BUILD SUCCESSFUL`, `:compileTestKotlin FROM-CACHE`, `:test NO-SOURCE`, and `build/classes/kotlin/test` is empty. The cache stored an empty output @@ -293,7 +294,7 @@ The fast loop for grammar work: ```bash ./jaigradle --stop - rm -rf build .gradle ~/.gradle/caches/build-cache-1 + rm -rf .gradle-test.lock build .gradle ~/.gradle/caches/build-cache-1 ./jaitest ``` diff --git a/gradle-lock.sh b/gradle-lock.sh new file mode 100644 index 0000000..8b6e874 --- /dev/null +++ b/gradle-lock.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Shared, process-safe lock for Gradle and IntelliJ platform test runs. +# This file is sourced by jaigradle and jaitest; it is not a standalone command. + +JAI_GRADLE_LOCK_DIR="${JAI_GRADLE_LOCK_DIR:-.gradle-test.lock}" +JAI_GRADLE_LOCK_PID_FILE="$JAI_GRADLE_LOCK_DIR/pid" + +jai_gradle_lock_acquire() { + if [[ "${JAI_GRADLE_LOCK_HELD:-0}" == "1" ]]; then + return 0 + fi + + local reported=0 + while ! mkdir "$JAI_GRADLE_LOCK_DIR" 2>/dev/null; do + if [[ -f "$JAI_GRADLE_LOCK_PID_FILE" ]]; then + local owner + owner=$(<"$JAI_GRADLE_LOCK_PID_FILE") + if [[ "$owner" =~ ^[0-9]+$ ]] && ! kill -0 "$owner" 2>/dev/null; then + rm -rf "$JAI_GRADLE_LOCK_DIR" + continue + fi + else + # A process can be killed between mkdir and writing its PID. Give that + # tiny window a chance to finish, then reclaim the incomplete lock. + sleep 1 + if [[ ! -f "$JAI_GRADLE_LOCK_PID_FILE" ]]; then + rm -rf "$JAI_GRADLE_LOCK_DIR" + continue + fi + fi + + if ((reported == 0)); then + echo "Waiting for another Gradle/test run in this worktree..." >&2 + reported=1 + fi + sleep 1 + done + + printf '%s\n' "$$" >"$JAI_GRADLE_LOCK_PID_FILE" + JAI_GRADLE_LOCK_HELD=1 + JAI_GRADLE_LOCK_OWNER_PID=$$ + export JAI_GRADLE_LOCK_HELD JAI_GRADLE_LOCK_OWNER_PID +} + +jai_gradle_lock_release() { + if [[ "${JAI_GRADLE_LOCK_OWNER_PID:-}" == "$$" ]]; then + rm -rf "$JAI_GRADLE_LOCK_DIR" + unset JAI_GRADLE_LOCK_OWNER_PID JAI_GRADLE_LOCK_HELD + fi +} diff --git a/jaigradle b/jaigradle index 34ba0e5..18f231d 100755 --- a/jaigradle +++ b/jaigradle @@ -13,11 +13,22 @@ # and throw UnsupportedClassVersionError on 17. set -euo pipefail cd "$(dirname "$0")" +source ./gradle-lock.sh -if command -v mise >/dev/null 2>&1; then - exec mise exec -- ./gradlew "$@" +# `--stop` is an explicit recovery operation; it must be able to stop a +# daemon even when a stale lock was left by an interrupted run. +if [[ "${1:-}" != "--stop" ]]; then + jai_gradle_lock_acquire + trap jai_gradle_lock_release EXIT + trap 'exit 130' INT + trap 'exit 143' TERM fi +status=0 +if command -v mise >/dev/null 2>&1; then + mise exec -- ./gradlew "$@" || status=$? +else + # Fallback: mise is not installed. Try an already-set JAVA_HOME, then any # JDK 21 that Gradle has auto-provisioned. if [ ! -x "${JAVA_HOME:-}/bin/java" ]; then @@ -35,4 +46,9 @@ if [ ! -x "${JAVA_HOME:-}/bin/java" ]; then exit 1 fi -exec ./gradlew "$@" + ./gradlew "$@" || status=$? +fi + +jai_gradle_lock_release +trap - EXIT INT TERM +exit "$status" diff --git a/jaitest b/jaitest index aaa50bf..e34cd0b 100755 --- a/jaitest +++ b/jaitest @@ -4,10 +4,9 @@ # A green `test` task does not prove tests ran (see AGENTS.md), so this always # prints tests/failures/errors per suite and dumps failure messages. # -# Two concurrent Gradle invocations (e.g. an editor/agent running tests in the -# background at the same time) fight over build/test-results and the loser dies -# with `java.io.EOFException` or a missing in-progress-results bin. That is an -# infrastructure failure, not a test failure, so it is retried once. +# A repository-wide lock serializes Gradle/test runs. Without it, an editor or +# another agent can delete build/test-results while this process is writing JUnit +# XML, producing EOFException or a missing in-progress-results bin. # # A `--tests` filter STICKS: Gradle reuses the configuration cache entry from the # previous run, so a later unfiltered `./jaitest` silently re-runs just that one @@ -18,6 +17,11 @@ set -uo pipefail cd "$(dirname "$0")" || exit 1 +source ./gradle-lock.sh +jai_gradle_lock_acquire +trap jai_gradle_lock_release EXIT +trap 'exit 130' INT +trap 'exit 143' TERM extra=() if [[ " $* " != *" --tests "* ]]; then