diff --git a/.gitattributes b/.gitattributes index ee6636b2a..4a8dc3e7d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,9 @@ **/src/generated/**/*.json text eol=lf docs/architecture/evidence/*.json text eol=lf +# Canonical replay fixtures are compared byte-for-byte with the LF-only codec. +platform/minecraft/src/test/resources/ca/teamdman/sfm/client/history/replay/*.json text eol=lf + # This checked-in parser fixture deliberately exercises CRLF coordinates while # keeping the repository blob normalized for whitespace review. platform/cli/sfm-propagate-changes/tests/java_analysis/scenarios/definition_at_position_unicode_crlf/source/**/*.java text eol=crlf diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 000000000..521e2db06 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,93 @@ +# SFM 1.19.2 verification + +`workflows/ci.yml` verifies the checked-out 1.19.2 source with the repository's +Rust toolchain. Its three jobs run independently: + +| Job | Passing evidence | +| --- | --- | +| Container software graphics and isolation | Xvfb/Mesa llvmpipe with the asserted container restrictions | +| Compile, test and package | Current-source CLI, all Java source sets, JUnit success and a distributable mod JAR | +| Isolated Minecraft client | A fresh offline puppet run with three title captures and eight world captures | + +The delivery output is a downloadable Actions artifact named +`sfm-1.19.2-`. Build, graphics and game evidence have separate artifacts, +including diagnostics from failed jobs. Retention is 14 days. The workflow uses +read-only repository permissions and needs no mod publishing or Discord secrets. + +`workflows/windows-ci.yml` runs the same canonical compile, JUnit and JAR checks +on Windows. Its mod artifact is named `sfm-1.19.2-windows-`. +`workflows/vox-diagnostic.yml` is a separate dependency investigation, triggered +only by its diagnostic branch or a manual run. It does not supply artifacts to +the SFM build. + +## Observed experiment results + +The [final Linux/Docker PR run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35481820090) +passed all three jobs at source `d7e22e73e`, tested as PR merge revision +`77e42edb0469ae9ca71b19b5b677c12cf245e79e`. Linux passed 2,075 Java tests with +zero failures, one Windows-only skip and six existing opt-in tests aborted by +their prerequisites. Its packaged mod contains the new Vox JAR with the exact +expected hash. The fresh offline container completed both puppets and all 11 +PNG files decoded successfully. The [Windows PR run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35481820091) +also passed compilation, 2,076 Java tests and packaging, with zero failures and +the same six opt-in tests aborted by their prerequisites. Both platforms embed +identical Vox JAR bytes; the complete mod archives are not byte-identical. + +| Evidence | Result | +| --- | --- | +| [First feature-branch run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35460447959) | Push trigger, Linux CLI and restricted software graphics passed; dependency preparation failed | +| [Second Linux/Docker run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35461041230) | UTF-8 container compilation fixed; both builds stopped in the pinned Vox Java test; graphics passed again | +| [Focused Vox diagnostic](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35461627486) | Captured `message for unknown channel 1:1` before the connection closes | +| [Vox candidate comparison](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35462115154) | Candidate passed the original test and 19 reduced cases; unchanged baseline also passed on this run, confirming the full-test failure is timing-sensitive | +| [Reduced Linux reproduction](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35462297128) | Identical credit passes before sender Close and reproduces the exact unknown-channel failure after Close in unchanged pinned code | +| [First Windows build](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35461257226) | Vox suite and deterministic JAR passed; a global Java-options banner incorrectly failed the dependency check | +| [Third Linux/Docker run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35462847488) | Linux compiled SFM then found Windows-specific JUnit fixtures; Docker built the mod and completed both puppets, but the verifier read CLI progress instead of the raw game log | +| [Second Windows run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35462847520) | Mod compilation passed; two canonical replay tests found Git's CRLF conversion of their byte-exact JSON fixture | +| [Final Linux/Docker PR run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35481820090) | All jobs passed: native compilation/JUnit/package, software graphics and the fresh restricted offline game | +| [Final Windows PR run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35481820091) | Compilation, all enabled Java tests, packaging and dependency checks passed; verified mod uploaded | + +With the user's authorization, SFM now pins `org.facet:vox-java:0.10.0-rc.5` +to Facet revision `4a079ac1c8a8bb8a914811ef55945bc1d9a9fef3`, published on +`teamy/vox-java-late-credit` in [Facet PR #2](https://github.com/TeamDman/facet/pull/2). +Its full canonical package recipe passed locally, including 19 regression cases, +with content hash `blake3:2be34a7d38bbd4a455d2a933c856c9462630f47a`. +All other project dependencies remain unchanged. Source tests and artifact hashes +are enforced. The [diagnostic](../containers/sfm/vox-diagnostic/README.md) +retains the original pinned reproduction and candidate comparison. + +## Worktrees and feature branches + +A worktree is a local checkout. GitHub receives its branch and commits through +an ordinary push; the local directory does not affect Actions. + +This experiment triggers on pushes to `1.19.2` and +`ci/1.19.2-container-puppet`, and on pull requests targeting `1.19.2`. The +initial experiment triggered successfully at `f7dc28338` before any merge into +the default branch. GitHub's PR checkout is a merge revision; the workflow +creates a local `sfm-ci-checkout` branch at that same revision so the SFM CLI's +worktree selector can find it. + +Use a push for the first experiment. The manual workflow button depends on +workflow discovery on the default branch; a feature-only workflow does not need +that button to receive push events. +[GitHub workflow events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows), +[manual runs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow). + +```bash +git push origin ci/1.19.2-container-puppet +gh run list --repo TeamDman/SuperFactoryManager --branch ci/1.19.2-container-puppet +gh run view --repo TeamDman/SuperFactoryManager +gh run download --repo TeamDman/SuperFactoryManager --dir build/ci-download +``` + +## Reproduce the container experiment + +See [the container guide](../containers/sfm/README.md) for exact Docker commands, +artifact checks and the Discord/Kubernetes deployment boundaries. The first +graphics run established software OpenGL 4.5 under the restrictions; the final +PR run also verified the complete game with networking disabled. + +Implementation progress and observed blockers are recorded in +[the experiment plan](../docs/tasks/ci%20and%20container%20puppet%20experiment%20plan.md). +Only 1.19.2 is in this workflow's acceptance scope. Add version-specific +validation before propagating the workflow to other Minecraft branches. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..63cc56c1e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,247 @@ +name: SFM 1.19.2 verification + +on: + push: + branches: + - '1.19.2' + - 'ci/1.19.2-container-puppet' + pull_request: + branches: + - '1.19.2' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sfm-1.19.2-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + graphics-probe: + name: Container software graphics and isolation + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out the event revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build the graphics probe without Minecraft dependencies + timeout-minutes: 12 + run: | + mkdir -p build/graphics-probe + docker build --progress plain --target graphics-probe \ + --file containers/sfm/Dockerfile --tag sfm-graphics:local . \ + 2>&1 | tee build/graphics-probe/image-build.log + + - name: Test fixture evidence validation + run: | + python3 -B -m unittest discover -s containers/sfm -p test_verify.py -v + bash containers/sfm/test_smoke.sh + bash containers/sfm/test_junit_diagnostics.sh + + - name: Verify software OpenGL inside the restricted container + timeout-minutes: 3 + run: | + container=$(docker create --network none --read-only --user 10001:10001 \ + --cap-drop ALL --security-opt no-new-privileges:true \ + --pids-limit 128 --memory 1g --memory-swap 1g --cpus 2 --init \ + --tmpfs /tmp:rw,exec,nosuid,nodev,size=128m,mode=1777 \ + --tmpfs /home/sfm:rw,nosuid,nodev,size=32m,uid=10001,gid=10001,mode=700 \ + sfm-graphics:local) + cleanup() { + status=$? + trap - EXIT + docker logs "$container" > build/graphics-probe/console.log 2>&1 || true + docker inspect "$container" > build/graphics-probe/docker-inspect.json || true + docker rm -f -v "$container" >/dev/null || true + exit "$status" + } + trap cleanup EXIT + timeout --signal=TERM --kill-after=10s 2m docker start --attach "$container" + test "$(docker inspect --format '{{.State.ExitCode}}' "$container")" = 0 + + - name: Upload graphics and isolation evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sfm-graphics-evidence-${{ github.sha }} + path: build/graphics-probe/ + if-no-files-found: warn + retention-days: 14 + + build: + name: Compile, test and package (Linux) + runs-on: ubuntu-24.04 + timeout-minutes: 140 + env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: '1' + RUSTUP_TOOLCHAIN: '1.96.0' + steps: + - name: Check out the event revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Select the checked-out revision for the SFM CLI + run: | + # PR checkouts contain GitHub's merge revision. Keep that exact tree; + # the CLI discovers named worktrees and skips a detached HEAD. + git switch --create sfm-ci-checkout + mkdir -p build/ci + echo "SFM_PROPAGATE_CHANGES_HOME=$RUNNER_TEMP/sfm-home" >> "$GITHUB_ENV" + echo "SFM_PROPAGATE_CHANGES_CACHE=$RUNNER_TEMP/sfm-cache" >> "$GITHUB_ENV" + git rev-parse HEAD > build/ci/source-revision.txt + grep -Eq '^minecraft_version[[:space:]]*=[[:space:]]*1\.19\.2[[:space:]]*$' \ + platform/minecraft/gradle.properties + + - name: Set up Java 17 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + + - name: Install the compiler for the frozen Vox artifact + timeout-minutes: 10 + run: | + # Vox's locked manifest embeds this exact JDK vendor/version. The mod + # and game continue to use current Java 17 via explicit --java-home. + bash containers/sfm/install-jbr.sh "$RUNNER_TEMP/vox-build-jdk" + echo "SFM_VOX_BUILD_JDK=$RUNNER_TEMP/vox-build-jdk" >> "$GITHUB_ENV" + + - name: Set up the Rust compiler + timeout-minutes: 10 + run: | + rustup toolchain install 1.96.0 --profile minimal + rustc --version + cargo --version + + - name: Build the CLI from locked sources + timeout-minutes: 25 + run: | + cargo build --locked --release \ + --manifest-path platform/cli/sfm-propagate-changes/Cargo.toml \ + 2>&1 | tee build/ci/cargo-build.log + cli="$GITHUB_WORKSPACE/platform/cli/sfm-propagate-changes/target/release/sfm-propagate-changes" + "$cli" --version | tee build/ci/cli-version.txt + sha256sum "$cli" > build/ci/cli-sha256.txt + "$cli" repo-root set "$GITHUB_WORKSPACE" + echo "SFM_CI_CLI=$cli" >> "$GITHUB_ENV" + + - name: Test portable dependency paths on Linux + timeout-minutes: 15 + run: | + cargo test --locked --release --lib \ + --manifest-path platform/cli/sfm-propagate-changes/Cargo.toml \ + json_path::tests 2>&1 | tee build/ci/portable-path-tests.log + + - name: Compile all Java source sets + timeout-minutes: 35 + run: | + JAVA_HOME="$SFM_VOX_BUILD_JDK" \ + "$SFM_CI_CLI" --log-filter info --log-file build/ci/compile.ndjson \ + run compile --branch sfm-ci-checkout --java-home "$JAVA_HOME" \ + --require-portable-artifacts --plan-json build/ci/compile-plan.json \ + 2>&1 | tee build/ci/compile.log + + - name: Run Java unit tests + timeout-minutes: 15 + run: | + diagnostics_pid='' + stop_diagnostics() { + local status=$? + trap - EXIT + if [[ -n "$diagnostics_pid" ]]; then + if jobs -pr | grep -Fxq -- "$diagnostics_pid"; then + kill "$diagnostics_pid" 2>/dev/null || true + fi + wait "$diagnostics_pid" 2>/dev/null || true + fi + exit "$status" + } + trap stop_diagnostics EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + bash containers/sfm/junit-diagnostics.sh build/ci/junit-diagnostics \ + > build/ci/junit-diagnostics-watchdog.log 2>&1 & + diagnostics_pid=$! + "$SFM_CI_CLI" --log-filter info --log-file build/ci/test.ndjson \ + test run --branch sfm-ci-checkout --java-home "$JAVA_HOME" \ + --require-portable-artifacts \ + 2>&1 | tee build/ci/test.log + + - name: Package the distributable mod + timeout-minutes: 15 + run: | + "$SFM_CI_CLI" --log-filter info --log-file build/ci/package.ndjson \ + jar build --branch sfm-ci-checkout --java-home "$JAVA_HOME" \ + --require-portable-artifacts --plan-json build/ci/package-plan.json \ + 2>&1 | tee build/ci/package.log + + - name: Verify frozen dependency declarations + if: always() + run: | + git diff --exit-code -- '**/Cargo.toml' '**/Cargo.lock' \ + platform/minecraft/sfm-toolchain.lock.json + + - name: Upload the verified mod + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sfm-1.19.2-${{ github.sha }} + path: platform/minecraft/build/libs/*-rust.jar + if-no-files-found: error + retention-days: 14 + + - name: Upload build and test diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sfm-build-diagnostics-${{ github.sha }} + path: | + build/ci/ + platform/minecraft/build/sfm-toolchain/**/*.log + platform/minecraft/build/sfm-toolchain/**/*.args + platform/minecraft/build/sfm-toolchain/run/**/*.json + if-no-files-found: warn + retention-days: 14 + + container-smoke: + name: Isolated Minecraft client (Mesa and Xvfb) + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - name: Check out the event revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build the prepared game image + timeout-minutes: 70 + run: | + mkdir -p build/container-ci + docker build --progress plain \ + --build-arg "SFM_SOURCE_REVISION=$(git rev-parse HEAD)" \ + --file containers/sfm/Dockerfile --tag sfm-ci:local . \ + 2>&1 | tee build/container-ci/image-build.log + + - name: Capture screenshots in an offline restricted container + timeout-minutes: 40 + run: bash containers/sfm/smoke.sh sfm-ci:local build/container-smoke + + - name: Upload container evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sfm-container-evidence-${{ github.sha }} + path: | + build/container-ci/ + build/container-smoke/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/vox-diagnostic.yml b/.github/workflows/vox-diagnostic.yml new file mode 100644 index 000000000..e929f9997 --- /dev/null +++ b/.github/workflows/vox-diagnostic.yml @@ -0,0 +1,152 @@ +name: Pinned Vox Linux diagnostic + +on: + push: + branches: + - 'ci/1.19.2-vox-diagnostic' + workflow_dispatch: + inputs: + mode: + description: Reduced baseline reproduction or full original/candidate comparison + type: choice + default: reduced + options: + - reduced + - full + +permissions: + contents: read + +concurrency: + group: vox-diagnostic-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + reduced-baseline: + name: Reproduce exact baseline late-credit failure and closest passing input + if: github.event_name == 'push' || inputs.mode == 'reduced' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + steps: + - name: Check out the diagnostic revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the exact Vox compiler + timeout-minutes: 10 + run: | + mkdir -p build/vox-reduced-baseline + git rev-parse HEAD > build/vox-reduced-baseline/sfm-revision.txt + bash containers/sfm/install-jbr.sh "$RUNNER_TEMP/vox-diagnostic-jdk" \ + 2>&1 | tee build/vox-reduced-baseline/jdk-install.log + echo "SFM_DIAGNOSTIC_JAVA_HOME=$RUNNER_TEMP/vox-diagnostic-jdk" >> "$GITHUB_ENV" + + - name: Compile unchanged baseline and require the two expected outcomes + timeout-minutes: 15 + run: | + python3 containers/sfm/vox-diagnostic.py \ + --workspace "$GITHUB_WORKSPACE" \ + --scratch "$RUNNER_TEMP/vox-reduced-source" \ + --artifacts "$GITHUB_WORKSPACE/build/vox-reduced-baseline" \ + --java-home "$SFM_DIAGNOSTIC_JAVA_HOME" \ + --reduced-only + + - name: Preserve passing input and exact expected-failure evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vox-reduced-baseline-${{ github.sha }} + path: build/vox-reduced-baseline/ + if-no-files-found: error + retention-days: 14 + + original-test: + name: Unchanged pinned VoxRuntimeTest with driver diagnostics + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'full' + runs-on: ubuntu-24.04 + timeout-minutes: 25 + env: + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + steps: + - name: Check out the diagnostic revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the exact Vox compiler + timeout-minutes: 10 + run: | + mkdir -p build/vox-diagnostic + git rev-parse HEAD > build/vox-diagnostic/sfm-revision.txt + bash containers/sfm/install-jbr.sh "$RUNNER_TEMP/vox-diagnostic-jdk" \ + 2>&1 | tee build/vox-diagnostic/jdk-install.log + echo "SFM_DIAGNOSTIC_JAVA_HOME=$RUNNER_TEMP/vox-diagnostic-jdk" >> "$GITHUB_ENV" + + - name: Compile recorded baseline sources and run the original test once + timeout-minutes: 10 + run: | + python3 containers/sfm/vox-diagnostic.py \ + --workspace "$GITHUB_WORKSPACE" \ + --scratch "$RUNNER_TEMP/vox-diagnostic-source" \ + --artifacts "$GITHUB_WORKSPACE/build/vox-diagnostic" \ + --java-home "$SFM_DIAGNOSTIC_JAVA_HOME" + + - name: Upload diagnostic evidence even when the test fails + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vox-original-linux-diagnostic-${{ github.sha }} + path: build/vox-diagnostic/ + if-no-files-found: error + retention-days: 14 + + candidate-test: + name: Review candidate in disposable pinned source only + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'full' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + steps: + - name: Check out the diagnostic revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install the exact Vox compiler + timeout-minutes: 10 + run: | + mkdir -p build/vox-candidate-diagnostic + git rev-parse HEAD > build/vox-candidate-diagnostic/sfm-revision.txt + bash containers/sfm/install-jbr.sh "$RUNNER_TEMP/vox-diagnostic-jdk" \ + 2>&1 | tee build/vox-candidate-diagnostic/jdk-install.log + echo "SFM_DIAGNOSTIC_JAVA_HOME=$RUNNER_TEMP/vox-diagnostic-jdk" >> "$GITHUB_ENV" + + - name: Test candidate once and run retained regression variants + timeout-minutes: 15 + run: | + python3 containers/sfm/vox-diagnostic.py \ + --workspace "$GITHUB_WORKSPACE" \ + --scratch "$RUNNER_TEMP/vox-candidate-source" \ + --artifacts "$GITHUB_WORKSPACE/build/vox-candidate-diagnostic" \ + --java-home "$SFM_DIAGNOSTIC_JAVA_HOME" \ + --candidate + + - name: Upload candidate evidence even when a test fails + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vox-candidate-linux-diagnostic-${{ github.sha }} + path: build/vox-candidate-diagnostic/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml new file mode 100644 index 000000000..786e95ec7 --- /dev/null +++ b/.github/workflows/windows-ci.yml @@ -0,0 +1,194 @@ +name: SFM 1.19.2 Windows verification + +on: + push: + branches: + - '1.19.2' + - 'ci/1.19.2-container-puppet' + - 'ci/1.19.2-windows-build' + pull_request: + branches: + - '1.19.2' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sfm-windows-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: pwsh + +jobs: + build: + name: Compile, test and package (Windows) + runs-on: windows-2025 + timeout-minutes: 150 + env: + CARGO_TERM_COLOR: always + CARGO_BUILD_JOBS: '2' + RUST_BACKTRACE: '1' + RUSTUP_TOOLCHAIN: '1.96.0' + # Keep UTF-8 source decoding scoped to javac. A JVM-wide option prints a + # banner to jdeps stderr, which the frozen Vox recipe treats as failure. + JDK_JAVAC_OPTIONS: '-encoding UTF-8' + steps: + - name: Check out the event revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Select the checked-out revision for the SFM CLI + run: | + # Preserve the exact event tree, including GitHub's PR merge revision. + git switch --create sfm-ci-checkout + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + git config core.longpaths true + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + New-Item -ItemType Directory -Force build/windows-ci | Out-Null + "SFM_PROPAGATE_CHANGES_HOME=$(Join-Path $env:RUNNER_TEMP 'sfm-home')" >> $env:GITHUB_ENV + "SFM_PROPAGATE_CHANGES_CACHE=$(Join-Path $env:RUNNER_TEMP 'sfm-cache')" >> $env:GITHUB_ENV + "SFM_SOURCE_BUILD_ROOT=$(Join-Path $env:RUNNER_TEMP 'sfm')" >> $env:GITHUB_ENV + git rev-parse HEAD | Set-Content build/windows-ci/source-revision.txt + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if (-not (Select-String -Path platform/minecraft/gradle.properties -Pattern '^minecraft_version\s*=\s*1\.19\.2\s*$' -Quiet)) { + throw 'This workflow verifies the Minecraft 1.19.2 source tree.' + } + + - name: Install the compiler for the frozen Vox artifact + id: vox-java + timeout-minutes: 12 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: jetbrains + java-version: '17.0.6+829.9' + java-package: jdk + architecture: x64 + set-default: false + + - name: Set up current Java 17 for the mod + id: mod-java + timeout-minutes: 10 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: temurin + java-version: '17' + architecture: x64 + + - name: Verify both Java toolchains + env: + SFM_MOD_JAVA_HOME: ${{ steps.mod-java.outputs.path }} + SFM_VOX_JAVA_HOME: ${{ steps.vox-java.outputs.path }} + run: | + & "$env:SFM_MOD_JAVA_HOME/bin/java.exe" -version 2>&1 | Tee-Object build/windows-ci/mod-java-version.txt + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $voxVersion = & "$env:SFM_VOX_JAVA_HOME/bin/java.exe" -version 2>&1 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $voxVersion | Tee-Object build/windows-ci/vox-java-version.txt + if (($voxVersion -join "`n") -notmatch 'JBR-17\.0\.6\+10-829\.9') { + throw 'The frozen Vox artifact requires JetBrains JDK 17.0.6+10-829.9.' + } + + - name: Set up the Rust compiler + timeout-minutes: 10 + run: | + rustup toolchain install 1.96.0 --profile minimal + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + rustc --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Build the CLI from locked sources + timeout-minutes: 30 + run: | + cargo build --locked --release --manifest-path platform/cli/sfm-propagate-changes/Cargo.toml 2>&1 | + Tee-Object build/windows-ci/cargo-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $cli = Join-Path $env:GITHUB_WORKSPACE 'platform/cli/sfm-propagate-changes/target/release/sfm-propagate-changes.exe' + & $cli --version | Tee-Object build/windows-ci/cli-version.txt + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Get-FileHash -Algorithm SHA256 -LiteralPath $cli | Format-List | Out-File build/windows-ci/cli-sha256.txt + & $cli repo-root set $env:GITHUB_WORKSPACE + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "SFM_CI_CLI=$cli" >> $env:GITHUB_ENV + + - name: Test portable dependency paths on Windows + timeout-minutes: 15 + run: | + cargo test --locked --release --lib --manifest-path platform/cli/sfm-propagate-changes/Cargo.toml json_path::tests 2>&1 | + Tee-Object build/windows-ci/portable-path-tests.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Compile all Java source sets + timeout-minutes: 40 + env: + # Only the pinned source-build recipe reads this old JAVA_HOME. + # The SFM compiler below explicitly uses current Java 17. + JAVA_HOME: ${{ steps.vox-java.outputs.path }} + SFM_MOD_JAVA_HOME: ${{ steps.mod-java.outputs.path }} + run: | + & $env:SFM_CI_CLI --log-filter info --log-file build/windows-ci/compile.ndjson ` + run compile --branch sfm-ci-checkout --java-home $env:SFM_MOD_JAVA_HOME ` + --require-portable-artifacts --plan-json build/windows-ci/compile-plan.json 2>&1 | + Tee-Object build/windows-ci/compile.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Run Java unit tests + timeout-minutes: 15 + run: | + & $env:SFM_CI_CLI --log-filter info --log-file build/windows-ci/test.ndjson ` + test run --branch sfm-ci-checkout --java-home $env:JAVA_HOME ` + --require-portable-artifacts 2>&1 | Tee-Object build/windows-ci/test.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Package and inspect the distributable mod + timeout-minutes: 15 + run: | + & $env:SFM_CI_CLI --log-filter info --log-file build/windows-ci/package.ndjson ` + jar build --branch sfm-ci-checkout --java-home $env:JAVA_HOME ` + --require-portable-artifacts --plan-json build/windows-ci/package-plan.json 2>&1 | + Tee-Object build/windows-ci/package.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $jars = @(Get-ChildItem platform/minecraft/build/libs -Filter '*-rust.jar' -File) + if ($jars.Count -ne 1 -or $jars[0].Length -eq 0) { + throw 'Expected exactly one nonempty distributable mod JAR.' + } + $archive = [System.IO.Compression.ZipFile]::OpenRead($jars[0].FullName) + try { + foreach ($entry in @('META-INF/mods.toml', 'ca/teamdman/sfm/SFM.class', 'sfm.mixins.json', 'sfm.refmap.json', 'META-INF/jarjar/metadata.json')) { + if ($null -eq $archive.GetEntry($entry)) { throw "Packaged mod is missing $entry" } + } + } finally { $archive.Dispose() } + Get-FileHash -Algorithm SHA256 -LiteralPath $jars[0].FullName | Format-List | + Out-File build/windows-ci/mod-sha256.txt + + - name: Verify frozen dependency declarations + if: always() + run: | + git diff --exit-code -- '**/Cargo.toml' '**/Cargo.lock' platform/minecraft/sfm-toolchain.lock.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload the verified mod + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sfm-1.19.2-windows-${{ github.sha }} + path: platform/minecraft/build/libs/*-rust.jar + if-no-files-found: error + retention-days: 14 + + - name: Upload build and test diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sfm-windows-diagnostics-${{ github.sha }} + path: | + build/windows-ci/ + platform/minecraft/build/sfm-toolchain/**/*.log + platform/minecraft/build/sfm-toolchain/**/*.args + platform/minecraft/build/sfm-toolchain/run/**/*.json + if-no-files-found: warn + retention-days: 14 diff --git a/containers/.gitattributes b/containers/.gitattributes new file mode 100644 index 000000000..c19ef2ea5 --- /dev/null +++ b/containers/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +*.py text eol=lf +Dockerfile text eol=lf +*.dockerignore text eol=lf diff --git a/containers/sfm/Dockerfile b/containers/sfm/Dockerfile new file mode 100644 index 000000000..678174f32 --- /dev/null +++ b/containers/sfm/Dockerfile @@ -0,0 +1,76 @@ +# syntax=docker/dockerfile:1 +# Private experiment image: contains development Minecraft/Forge dependencies. +FROM rust:1.96.0-bookworm AS graphics-probe + +RUN apt-get update && apt-get install -y --no-install-recommends \ + openjdk-17-jdk-headless xvfb xauth mesa-utils libgl1-mesa-dri libglx-mesa0 \ + libxrandr2 libxinerama1 libxcursor1 libxi6 libxxf86vm1 libasound2 \ + fonts-dejavu-core python3 ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && ln -s "$(dirname "$(dirname "$(readlink -f "$(command -v javac)")")")" /opt/java \ + && useradd --uid 10001 --create-home --shell /bin/bash sfm \ + && mkdir /workspace && chown sfm:sfm /workspace + +ENV JAVA_HOME=/opt/java \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + HOME=/home/sfm \ + CARGO_HOME=/workspace/.cargo \ + SFM_PROPAGATE_CHANGES_HOME=/workspace/.sfm-config \ + SFM_PROPAGATE_CHANGES_CACHE=/workspace/.sfm-cache \ + LIBGL_ALWAYS_SOFTWARE=true \ + GALLIUM_DRIVER=llvmpipe \ + LP_NUM_THREADS=2 \ + ALSOFT_DRIVERS=null \ + CARGO_BUILD_JOBS=2 + +COPY containers/sfm/graphics.sh /opt/sfm-container/graphics.sh +RUN chmod 755 /opt/sfm-container/graphics.sh +USER 10001:10001 +ENTRYPOINT ["/opt/sfm-container/graphics.sh"] + +FROM graphics-probe AS runtime +USER root + +# Only the frozen Vox source-build needs this historical compiler. Keep the +# current Debian JDK at /opt/java for SFM compilation and the game runtime. +COPY containers/sfm/install-jbr.sh /opt/sfm-container/install-jbr.sh +RUN bash /opt/sfm-container/install-jbr.sh /opt/vox-build-jdk + +WORKDIR /workspace +COPY --chown=sfm:sfm platform/cli/sfm-propagate-changes/ platform/cli/sfm-propagate-changes/ +COPY --chown=sfm:sfm platform/cli/sfm/ platform/cli/sfm/ +COPY --chown=sfm:sfm platform/minecraft/ platform/minecraft/ +COPY --chown=sfm:sfm .gitignore .gitignore +COPY containers/sfm/run.sh containers/sfm/verify.py /opt/sfm-container/ +RUN chmod 755 /opt/sfm-container/run.sh + +ARG SFM_SOURCE_REVISION +LABEL org.opencontainers.image.revision=$SFM_SOURCE_REVISION +USER 10001:10001 +# A standalone Git repo avoids references to the host's worktree administrative files. +# The actual input revision is retained separately from this synthetic local commit. +RUN test -n "$SFM_SOURCE_REVISION" \ + && printf '%s\n' "$SFM_SOURCE_REVISION" > /workspace/source-revision.txt \ + && git init -b ci-container \ + && git add . \ + && git -c user.name='SFM container fixture' -c user.email='fixture@example.invalid' commit -m 'Container source snapshot' \ + && SFM_PROPAGATE_CHANGES_INSTALL_GIT_REVISION="$SFM_SOURCE_REVISION" \ + cargo build --locked --release --manifest-path platform/cli/sfm-propagate-changes/Cargo.toml \ + && mkdir /workspace/bin \ + && cp platform/cli/sfm-propagate-changes/target/release/sfm-propagate-changes /workspace/bin/ \ + && rm -rf platform/cli/sfm-propagate-changes/target + +ENV PATH=/workspace/bin:/usr/local/cargo/bin:/opt/java/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +RUN sfm-propagate-changes repo-root set /workspace \ + && JAVA_HOME=/opt/vox-build-jdk sfm-propagate-changes jar build \ + --branch ci-container --require-portable-artifacts --java-home /opt/java \ + && /opt/sfm-container/run.sh prepare \ + && rm -rf /workspace/container-artifacts \ + /workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview \ + /workspace/platform/minecraft/runGameTestPreview + +# Runtime Cargo can only reuse the dependencies fetched during preparation. +ENV CARGO_NET_OFFLINE=true +ENTRYPOINT ["/opt/sfm-container/run.sh"] +CMD ["verify"] diff --git a/containers/sfm/Dockerfile.dockerignore b/containers/sfm/Dockerfile.dockerignore new file mode 100644 index 000000000..1d4a66ddd --- /dev/null +++ b/containers/sfm/Dockerfile.dockerignore @@ -0,0 +1,17 @@ +.git +**/.git +**/target +**/build +**/.gradle +**/.idea +**/.vscode +**/*.log +**/.env +**/.env.* +**/*.pem +**/*.key +platform/minecraft/run* +platform/minecraft/*.hprof +platform/minecraft/hs_err_pid* +platform/cli/sfm-propagate-changes/.sfm-* +build diff --git a/containers/sfm/README.md b/containers/sfm/README.md new file mode 100644 index 000000000..3ef2ed7a7 --- /dev/null +++ b/containers/sfm/README.md @@ -0,0 +1,223 @@ +# Minecraft client container experiment + +Run the graphics probe first, then the complete SFM puppet fixture. These are Linux +containers. Docker Desktop must use its Linux backend on Windows; no host display, +GPU device, Minecraft account, or Discord token is passed into either fixture. + +## Verified result + +[PR run 35481820090](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35481820090) +passed on Linux x86-64 at source `d7e22e73e` (tested merge `77e42edb0469ae9ca71b19b5b677c12cf245e79e`). +The cold prepared-image build took 19 minutes 12 seconds. The two fresh offline +client launches together took 3 minutes 42 seconds inside the container; image +creation/copying and evidence collection add wrapper time. These are single-run +measurements, not latency guarantees or memory-use measurements. + +The raw game logs report both puppet completions and +`move_1_stack_direct passed!`. All three title and eight orbit PNGs passed the +manifest checks and decoded during artifact inspection. Runtime inspection +confirmed the restrictions below, exit 0 and no OOM kill. + +Local Podman 6.0.2 on a rootless WSL machine also passed at source +`01b2391247f8e67df2c94984b8e0f9acdfbc3931` on 2026-09-23. A default-network +container resolved DNS and fetched HTTPS; `--network none` blocked DNS and +direct-IP egress. The isolated graphics probe reported Mesa llvmpipe and OpenGL +4.5. The disposable offline game worker passed both puppets, produced three +title and eight orbit PNGs, and logged `move_1_stack_direct passed!`. Its +verification receipt passed, its process exited 0 without an OOM kill, and +Podman removed the container and anonymous workspace volume. The settled title +and final orbit screenshots were also visually inspected. + +The first orbit image catches incomplete geometry while later views show the +complete fixture. A help worker needs a visual-readiness check before selecting +an image to return. This experiment proves game execution and screenshot capture; +the future bot still needs an external Vox-control smoke test and broker wiring. + +## Run the independent graphics probe + +From the repository root, in Bash with a running Docker daemon: + +```bash +docker build --target graphics-probe -f containers/sfm/Dockerfile -t sfm-graphics:local . +docker run --rm --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges:true --pids-limit 128 --memory 1g --cpus 2 \ + --tmpfs /tmp:rw,exec,nosuid,nodev,size=128m,mode=1777 \ + --tmpfs /home/sfm:rw,nosuid,nodev,size=16m,uid=10001,gid=10001,mode=700 \ + sfm-graphics:local +``` + +With Podman, use its explicit ignore file and empty temporary mounts: + +```bash +podman build --ignorefile containers/sfm/Dockerfile.dockerignore \ + --target graphics-probe -f containers/sfm/Dockerfile -t sfm-graphics:local . +podman run --rm --network none --read-only --read-only-tmpfs=false \ + --cap-drop ALL --security-opt no-new-privileges:true \ + --pids-limit 128 --memory 1g --cpus 2 \ + --tmpfs /dev/shm:rw,nosuid,nodev,noexec,size=256m,mode=1777,notmpcopyup \ + --tmpfs /tmp:rw,exec,nosuid,nodev,size=128m,mode=1777,notmpcopyup \ + --tmpfs /home/sfm:rw,nosuid,nodev,size=16m,mode=1777,notmpcopyup \ + sfm-graphics:local +``` + +Success prints `GRAPHICS_PROBE_PASSED renderer=llvmpipe`, the OpenGL versions, +and the asserted isolation settings. This proves the software graphics stack; +it does not prove Minecraft or SFM launches. + +Xvfb supplies an in-memory X display. Mesa's llvmpipe renders OpenGL on the CPU. +The fixture sets `LIBGL_ALWAYS_SOFTWARE=true` and `GALLIUM_DRIVER=llvmpipe`, then +checks the actual renderer reported by `glxinfo`. +[Xvfb manual](https://xorg.freedesktop.org/archive/X11R7.0/doc/html/Xvfb.1.html), +[Mesa llvmpipe](https://docs.mesa3d.org/drivers/llvmpipe.html), +[Mesa environment variables](https://docs.mesa3d.org/envvars.html). + +## Run SFM and capture the world + +```bash +docker build --build-arg SFM_SOURCE_REVISION="$(git rev-parse HEAD)" \ + -f containers/sfm/Dockerfile -t sfm-ci:local . +bash containers/sfm/smoke.sh sfm-ci:local build/container-smoke +``` + +Docker is the default engine. To use a running Linux Podman backend, build with +the explicit ignore file and select Podman for the same runtime checks: + +```bash +podman build --ignorefile containers/sfm/Dockerfile.dockerignore \ + --build-arg SFM_SOURCE_REVISION="$(git rev-parse HEAD)" \ + -f containers/sfm/Dockerfile -t sfm-ci:local . +SFM_CONTAINER_ENGINE=podman bash containers/sfm/smoke.sh sfm-ci:local build/container-smoke-podman +``` + +Podman mode disables its automatic writable temporary mounts and explicitly adds +the bounded shared-memory mount. Its 128 MiB home tmpfs uses sticky permissions +(`mode=1777`) because the remote mount parser rejects `uid`/`gid` options; +Docker retains its UID-owned `mode=700` home. Network, privilege, resource, +timeout, and completion requirements stay enabled. The selected backend must support those +restrictions. [Podman build ignore files](https://docs.podman.io/en/latest/markdown/podman-build.1.html#containerignore-dockerignore), +[Podman read-only mounts](https://docs.podman.io/en/latest/markdown/podman-create.1.html#read-only-tmpfs). + +Podman uses `notmpcopyup` for `/tmp`, `/home/sfm`, and `/dev/shm` so those bounded +temporary mounts start empty. Its default copy-up filled the 512 MiB `/tmp` mount +with source-build leftovers from the image, causing `crun: write: No space left +on device` before startup despite ample free host disk space. +[Podman tmpfs copy-up](https://docs.podman.io/en/latest/markdown/podman-create.1.html#tmpfs-fs). + +The image prepares public dependencies and builds with the canonical Rust +`sfm-propagate-changes` tool. It runs `title_screen_capture` and +`game_test_orbit_capture` for `sfm:move_1_stack_direct` during preparation, then +removes those screenshots. Each puppet launches a fresh client under the same +Xvfb display: the title fixture needs the startup loading overlay before any +world is entered. The smoke script repeats both launches with networking +disabled and requires an independent successful completion marker, three title +captures, and eight world captures. Each manifest-referenced PNG must exist with the +reported dimensions. Gradle is not used. + +The image has a synthetic `ci-container` Git branch because a host worktree's +`.git` pointer cannot be used inside a container. The supplied source revision is +recorded in the image label and `source-revision.txt`. The snapshot contains the +build context, including uncommitted changes if invoked locally; the revision +alone does not attest a clean local checkout. CI supplies its checked-out revision. + +The first build downloads and compiles the complete toolchain and can take tens +of minutes. A cached repeat should be much shorter; the smoke wrapper imposes a +35-minute wall-clock limit for the two client launches. Software rendering performance remains a measured +property of the runner. The image currently keeps Rust and Cargo caches because +the canonical puppet launcher builds the checkout-local `sfm` control CLI on +every invocation. + +The evidence validator's own regressions can be run without starting Minecraft: + +```bash +python3 -B -m unittest discover -s containers/sfm -p test_verify.py -v +bash containers/sfm/test_smoke.sh +``` + +Initial dependency acquisition runs without global Java option variables. Puppet +commands scope `JDK_JAVA_OPTIONS=-Xmx3g -XX:ActiveProcessorCount=4` to the Java +launcher, preserving a 3 GiB game heap and four JVM processors. This leaves +`jdeps`, `javac`, and `jar` free of the `JAVA_TOOL_OPTIONS` startup banner that +the pinned source recipe would otherwise mistake for unresolved dependencies. +The Java launcher still records its own options notice in the game logs. +[Java launcher options](https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html#using-the-jdk_java_options-launcher-environment-variable). + +The smoke script requires a new or empty output directory and refuses to merge with or delete earlier results. +Evidence is copied to `build/container-smoke` even when the game fails: + +- `glxinfo.txt` and `isolation.txt`: actual renderer and runtime assertions. +- `title_screen_capture/` and `game_test_orbit_capture/`: separate CLI + `console.log`, raw JVM `game-console.log`, `game-logs/`, `exit-code.txt`, and + `previews/` with the existing SFM HTML preview, manifest, and screenshots. + Completion is verified against the raw JVM log; the CLI reports only progress + when its output is piped. +- `docker.log` (or `podman.log`): container launch and failure diagnostics. +- `verification.json`, `exit-code.txt`, and `source-revision.txt`: result and input. +- `docker-inspect.json` (or `podman-inspect.json`): container configuration and final process status. + +Game-instance descriptors and the home directory are excluded because they can +contain authentication tokens. A failed image build has no runtime container to +inspect; failed fixture preparation prints bounded log tails and the capture +inventory into its build log. A downloaded dependency that +cannot reproduce the locked hash must fail rather than use a host-only cache. + +## Isolation boundary + +The smoke script runs as UID 10001 with no Linux capabilities, no privilege +escalation, the engine's seccomp filter, no external network interfaces, a read-only +root filesystem, and explicit CPU, memory, PID, and wall-time limits. It exposes +no ports and mounts neither host directories nor the Docker socket. A fresh +anonymous volume receives the image's prepared workspace; it is removed with the +container. Only bounded temporary directories and that workspace are writable. +`/tmp` permits execution because LWJGL extracts native libraries there. + +This is useful isolation for the repository's controlled game fixture. The +anonymous workspace volume does not have a disk quota. A production worker needs +bounded persistent storage and stronger separation before accepting arbitrary +mods or executable uploads. Containers rely on the host kernel; +[Docker's security model](https://docs.docker.com/engine/security/) explains the +remaining boundary. Kubernetes recommends a VM or userspace-kernel sandbox for +untrusted code in shared clusters. +[Kubernetes workload sandboxing](https://kubernetes.io/docs/concepts/security/multi-tenancy/#sandboxing-containers). + +## Fit for a Discord help worker + +1. Keep the Discord bot and its token in a separate controller. Accept a bounded + help request, choose a trusted mod image and fixture, and enqueue a session. +2. Start one disposable game worker per request. Pass only the requested SFM + program or a validated world input; do not turn chat text into shell commands, + arbitrary CLI arguments, container options, or image names. +3. Run the existing control CLI inside the worker. The game's + `SFMClientControlServer` binds a random `127.0.0.1` port and uses a per-instance + token. The CLI must share the game network namespace and descriptor directory. + Publishing a container port alone does not make that loopback service usable. +4. Return only selected logs and screenshots to the controller, with size and + retention limits. Destroy the game process, world, writable caches, and control + descriptors after completion or timeout. + +The repository already has authenticated Vox control in `platform/cli/sfm` and +the reusable orbit fixture under +`platform/minecraft/src/gametest/java/ca/teamdman/sfm/gametest/puppet/definition/`. +Those are the integration points. This experiment does not create a Discord +application, install a bot, or expose its game-control service externally. + +## Kubernetes mapping + +The same Xvfb/Mesa stack runs within a pod and needs no GPU resource request. +Prepare an image before job submission; a worker should start offline with all +required artifacts present. + +| Docker experiment | Kubernetes worker equivalent | +| --- | --- | +| Nonroot, dropped capabilities, no privilege escalation | `runAsUser: 10001`, `runAsNonRoot: true`, drop `ALL`, `allowPrivilegeEscalation: false`, `seccompProfile.type: RuntimeDefault` | +| Read-only root plus disposable workspace | `readOnlyRootFilesystem: true`; `emptyDir` with size limit plus ephemeral-storage requests/limits; populate it from the image in an init container | +| CPU, memory, PID, and wall-time limits | Container resource requests/limits, node pod-PID limit, Job `activeDeadlineSeconds`, bounded queue/concurrency | +| No external networking | Enforced default-deny ingress and egress NetworkPolicies; controller transfers input/output through a narrow broker | +| No host authority or durable credentials | No `hostPath`, host network, privileged container, or Docker socket; `automountServiceAccountToken: false` | + +Enforce the +[Restricted Pod Security Standard](https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted) +and use a sandboxed `RuntimeClass` for untrusted workloads. A namespace alone does +not provide this separation. NetworkPolicies require a network plugin that +enforces them, and storage limits need node-level monitoring and eviction behavior +to be validated. The experiment's Docker volume copy-up is Docker-specific; +Kubernetes `emptyDir` starts empty, so an init container must seed it explicitly. diff --git a/containers/sfm/graphics.sh b/containers/sfm/graphics.sh new file mode 100644 index 000000000..8576b78a9 --- /dev/null +++ b/containers/sfm/graphics.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +python3 - <<'PY' +import os +from pathlib import Path +assert os.getuid() == 10001 +status = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines()) +assert int(status['CapEff'].strip(), 16) == 0 +assert status['NoNewPrivs'].strip() == '1' +assert status['Seccomp'].strip() == '2' +assert {p.name for p in Path('/sys/class/net').iterdir()} == {'lo'} +mounts = [line.split() for line in Path('/proc/mounts').read_text().splitlines()] +assert 'ro' in next(fields for fields in mounts if fields[1] == '/')[3].split(',') +print('Isolation: nonroot, no capabilities, no new privileges, seccomp, no network, read-only root') +PY +xvfb-run --auto-servernum --server-args='-screen 0 1280x720x24 -nolisten tcp' \ + bash -euo pipefail -c 'glxinfo -B | tee /tmp/glxinfo.txt; grep -qi llvmpipe /tmp/glxinfo.txt' +echo 'GRAPHICS_PROBE_PASSED renderer=llvmpipe' diff --git a/containers/sfm/install-jbr.sh b/containers/sfm/install-jbr.sh new file mode 100644 index 000000000..c1744fd79 --- /dev/null +++ b/containers/sfm/install-jbr.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Experimental build compiler for the frozen Vox JAR. Its manifest embeds the +# JDK vendor/version, so an arbitrary Java 17 cannot reproduce the locked hash. +# Keep this old compiler separate from the JDK used to run the game. +set -euo pipefail + +if [[ $# -ne 1 || "$1" != /* ]]; then + echo 'Usage: install-jbr.sh /absolute/path/to/new-jdk-directory' >&2 + exit 2 +fi +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + echo 'This compiler archive supports Linux x86_64 only.' >&2 + exit 2 +fi + +destination=$(realpath --canonicalize-missing -- "$1") +if [[ -e "$destination" || -L "$destination" ]]; then + echo "Refusing to overwrite existing JDK destination: $destination" >&2 + exit 2 +fi + +# Official release and checksum: +# https://github.com/JetBrains/JetBrainsRuntime/releases/tag/jbr-release-17.0.6b829.9 +# https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-17.0.6-linux-x64-b829.9.tar.gz.checksum +url=https://cache-redirector.jetbrains.com/intellij-jbr/jbrsdk-17.0.6-linux-x64-b829.9.tar.gz +sha512=0fd8056a31115dbe177418e7884ad546a56908fa8241c749784971a6769044a9b72e3acfca8b621a45c6c8a74c92ed630659220f65248aaab083af7f036e9bb4 + +parent=$(dirname -- "$destination") +mkdir -p -- "$parent" +staging=$(mktemp -d -- "$parent/.sfm-jbr.XXXXXX") +trap 'rm -rf -- "$staging"' EXIT + +curl --fail --location --silent --show-error --retry 3 \ + --connect-timeout 20 --max-time 300 "$url" --output "$staging/jbr.tar.gz" +printf '%s %s\n' "$sha512" "$staging/jbr.tar.gz" | sha512sum --check --status +mkdir "$staging/sdk" +tar --extract --gzip --file "$staging/jbr.tar.gz" \ + --directory "$staging/sdk" --strip-components=1 + +version=$("$staging/sdk/bin/java" -version 2>&1) +grep -Fq 'JBR-17.0.6+10-829.9' <<< "$version" +"$staging/sdk/bin/javac" -version 2>&1 | grep -Fxq 'javac 17.0.6' +"$staging/sdk/bin/jar" --version 2>&1 | grep -Fxq 'jar 17.0.6' +printf '%s\n' "$sha512" > "$staging/sdk/.sfm-ci-archive.sha512" +mv --no-target-directory -- "$staging/sdk" "$destination" +printf '%s\n' "$version" >&2 +# Only the installed path is written to stdout, for command substitution. +printf '%s\n' "$destination" diff --git a/containers/sfm/junit-diagnostics.sh b/containers/sfm/junit-diagnostics.sh new file mode 100644 index 000000000..c3ff3b901 --- /dev/null +++ b/containers/sfm/junit-diagnostics.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Capture a stalled JUnit JVM without changing the test command or its result. +set -uo pipefail + +output_dir=${1:?usage: junit-diagnostics.sh OUTPUT_DIRECTORY} +interval=${SFM_JUNIT_DIAGNOSTIC_INTERVAL_SECONDS:-120} +snapshots=${SFM_JUNIT_DIAGNOSTIC_SNAPSHOTS:-6} +collector_pid=$BASHPID +active_child= + +cleanup() { + local status=$? key value + trap - EXIT INT TERM + if [[ -n "$active_child" ]]; then + # A completed/reaped child PID must never select an unrelated process. + if [[ -r "/proc/$active_child/status" ]]; then + while read -r key value; do + if [[ "$key" == 'PPid:' && "$value" == "$collector_pid" ]]; then + kill "$active_child" 2>/dev/null || true + break + fi + done < "/proc/$active_child/status" + fi + wait "$active_child" 2>/dev/null || true + fi + exit "$status" +} +trap cleanup EXIT +trap 'exit 0' INT TERM + +if [[ ! "$interval" =~ ^[0-9]+([.][0-9]+)?$ || ! "$snapshots" =~ ^[1-6]$ ]]; then + printf 'Invalid diagnostic interval or snapshot count\n' >&2 + exit 2 +fi +mkdir -p "$output_dir" || exit 1 +jcmd=${JAVA_HOME:?JAVA_HOME is required}/bin/jcmd +workspace=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} +junit_arg="@${workspace%/}/platform/minecraft/build/sfm-toolchain/run/runTest/junit.java.args" + +run_bounded() { + local limit=$1 status + shift + timeout --kill-after=2s "$limit" "$@" & + active_child=$! + wait "$active_child" + status=$? + active_child= + return "$status" +} + +for ((index = 1; index <= snapshots; index++)); do + sleep "$interval" & + active_child=$! + wait "$active_child" || true + active_child= + + printf -v prefix '%s/snapshot-%02d' "$output_dir" "$index" + { + printf 'snapshot=%s\nutc=%s\nexpected_argument=%s\n' \ + "$index" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$junit_arg" + ps -eo pid,ppid,etimes,stat,comm + } > "$prefix-metadata.log" 2>&1 + + run_bounded 10s "$jcmd" -l > "$prefix-jcmd-list.log" 2>&1 + printf 'jcmd_list_exit=%s\n' "$?" >> "$prefix-metadata.log" + while read -r pid main_class _remainder; do + [[ "$pid" =~ ^[0-9]+$ && "$main_class" == 'dev.teamdman.sfm.toolchain.SfmJUnitRunner' ]] || continue + # Match a complete NUL-delimited JVM argument, not a path substring. + [[ -r "/proc/$pid/cmdline" ]] || continue + grep -zFxq -- "$junit_arg" "/proc/$pid/cmdline" || continue + run_bounded 20s "$jcmd" "$pid" Thread.print -l \ + > "$prefix-pid-$pid-threads.log" 2>&1 + printf 'thread_dump_pid=%s exit=%s\n' "$pid" "$?" >> "$prefix-metadata.log" + done < "$prefix-jcmd-list.log" +done diff --git a/containers/sfm/run.sh b/containers/sfm/run.sh new file mode 100644 index 000000000..0ba2fbf11 --- /dev/null +++ b/containers/sfm/run.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode=${1:-verify} +if [[ $# -gt 1 || ( "$mode" != prepare && "$mode" != verify ) ]]; then + echo 'Usage: run.sh [prepare|verify]' >&2 + exit 2 +fi + +cd /workspace +artifacts=/workspace/container-artifacts +# These paths are disposable, container-owned outputs; never accept them as user input. +rm -rf "$artifacts" \ + /workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview \ + /workspace/platform/minecraft/runGameTestPreview/screenshots +mkdir -p "$artifacts" +cp source-revision.txt "$artifacts/" + +collect_artifacts() { + local status=$? + trap - EXIT + printf '%s\n' "$status" > "$artifacts/exit-code.txt" + if [[ "$status" != 0 ]]; then + # A failed Docker RUN has no container to copy from. Keep bounded, + # relevant evidence in the BuildKit log as well as in runtime artifacts. + python3 - "$artifacts" <<'PY' || true +from collections import deque +import json +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +print('SFM container fixture failed; captured evidence follows:', flush=True) +for puppet in ('title_screen_capture', 'game_test_orbit_capture'): + run = root / puppet + if not run.is_dir(): + continue + for name in ('exit-code.txt', 'console.log', 'game-console.log', 'game-logs/latest.log'): + path = run / name + print(f'--- {puppet}/{name} (last 80 lines) ---', flush=True) + if path.is_file(): + with path.open(encoding='utf-8', errors='replace') as stream: + print(''.join(deque(stream, maxlen=80)), flush=True) + else: + print('(not produced)', flush=True) + manifest = run / 'previews/preview-manifest.json' + print(f'--- {puppet}/previews/preview-manifest.json capture inventory ---', flush=True) + if manifest.is_file(): + try: + for capture in json.loads(manifest.read_text(encoding='utf-8')).get('captures', []): + # Print metadata only; do not follow arbitrary manifest paths. + print(json.dumps({key: capture.get(key) for key in + ('puppet', 'capture', 'path', 'width', 'height')}), flush=True) + except (ValueError, TypeError, AttributeError) as error: + print(f'Could not summarize manifest: {error}', flush=True) + else: + print('(not produced)', flush=True) +PY + fi + exit "$status" +} +trap collect_artifacts EXIT + +if [[ "$mode" == verify ]]; then + python3 - <<'PY' | tee "$artifacts/isolation.txt" +import os +from pathlib import Path +assert os.getuid() == 10001, 'fixture must run as UID 10001' +status = dict(line.split(':', 1) for line in Path('/proc/self/status').read_text().splitlines()) +assert int(status['CapEff'].strip(), 16) == 0, 'effective capabilities must be empty' +assert status['NoNewPrivs'].strip() == '1', 'no-new-privileges must be enabled' +assert status['Seccomp'].strip() == '2', 'seccomp filtering must be enabled' +interfaces = {p.name for p in Path('/sys/class/net').iterdir()} +assert interfaces == {'lo'}, f'expected network=none, found {interfaces}' +mounts = [line.split() for line in Path('/proc/mounts').read_text().splitlines()] +root = next(fields for fields in mounts if fields[1] == '/') +assert 'ro' in root[3].split(','), 'root filesystem must be read-only' +assert not Path('/var/run/docker.sock').exists(), 'Docker socket must not be mounted' +print('uid=10001 capabilities=none no_new_privileges=1 seccomp=filter network=loopback-only root=read-only') +PY +fi + +# Each puppet gets a fresh client. The title fixture requires the startup loading +# overlay, which is not guaranteed when returning to the title after a world. +# Keep one X server alive throughout; no host display/GPU. +xvfb-run --auto-servernum --server-args='-screen 0 1280x720x24 -nolisten tcp' \ + bash -euo pipefail <<'BASH' +glxinfo -B | tee /workspace/container-artifacts/glxinfo.txt +grep -qi llvmpipe /workspace/container-artifacts/glxinfo.txt + +previews=/workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview +game_dir=/workspace/platform/minecraft/runGameTestPreview +game_console=/workspace/platform/minecraft/build/sfm-toolchain/run/runGameTestPreview/console.log +current_artifacts= +snapshot_current() { + if [[ -z "$current_artifacts" ]]; then return; fi + # With non-TTY stdout the canonical CLI records raw JVM output here, + # separately from its own console. Preserve it before the next launch. + if [[ -f "$game_console" ]]; then + cp "$game_console" "$current_artifacts/game-console.log" + fi + if [[ -d "$previews" ]]; then + mkdir -p "$current_artifacts/previews" + cp -a "$previews/." "$current_artifacts/previews/" + fi + # Never copy the home directory or instance descriptors containing RPC tokens. + if [[ -d "$game_dir/logs" ]]; then + mkdir -p "$current_artifacts/game-logs" + cp -a "$game_dir/logs/." "$current_artifacts/game-logs/" + fi +} +trap snapshot_current EXIT + +for puppet in title_screen_capture game_test_orbit_capture; do + current_artifacts=/workspace/container-artifacts/$puppet + mkdir -p "$current_artifacts" + # Both the manifest and screenshots must belong to this invocation. + rm -rf "$previews" "$game_dir" + rm -f "$game_console" + options=(--branch ci-container --java-home /opt/java --width 1280 --height 720 + --variant preferred --require-portable-artifacts) + if [[ "$puppet" == game_test_orbit_capture ]]; then + options+=(--game-test sfm:move_1_stack_direct) + fi + status=0 + # Scope limits to Java launchers during puppet preparation/runtime. The + # source-build recipe treats any jdeps output as unresolved dependencies; + # JAVA_TOOL_OPTIONS would make jdeps emit an unrelated startup banner. + JDK_JAVA_OPTIONS='-Xmx3g -XX:ActiveProcessorCount=4' \ + sfm-propagate-changes puppet run "$puppet" "${options[@]}" \ + 2>&1 | tee "$current_artifacts/console.log" || status=$? + printf '%s\n' "$status" > "$current_artifacts/exit-code.txt" + snapshot_current + current_artifacts= + if [[ "$status" != 0 ]]; then exit "$status"; fi +done +BASH + +python3 /opt/sfm-container/verify.py "$artifacts" | tee "$artifacts/verification.json" diff --git a/containers/sfm/smoke.sh b/containers/sfm/smoke.sh new file mode 100644 index 000000000..4a2063f77 --- /dev/null +++ b/containers/sfm/smoke.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +image=${1:-sfm-ci:local} +artifacts=${2:-build/container-smoke} +engine=${SFM_CONTAINER_ENGINE:-docker} +if [[ $# -gt 2 ]]; then + echo 'Usage: smoke.sh [image] [artifact-directory]' >&2 + exit 2 +fi +case "$engine" in + docker|podman) ;; + *) + echo 'SFM_CONTAINER_ENGINE must be docker or podman.' >&2 + exit 2 + ;; +esac +mkdir -p -- "$artifacts" +# Reusing output would merge a failed run with an older successful receipt. +shopt -s nullglob dotglob +existing_artifacts=("$artifacts"/*) +shopt -u nullglob dotglob +if (( ${#existing_artifacts[@]} != 0 )); then + echo "Artifact directory is not empty: $artifacts. Choose a fresh or empty directory." >&2 + exit 2 +fi +artifact_copy_destination=$artifacts +if [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then + # Keep Linux mount/container paths intact for native Windows engine CLIs, + # while making the one host-side copy destination an explicit native path. + export MSYS_NO_PATHCONV=1 + artifact_copy_destination=$(cygpath -am "$artifacts") +fi +container= + +cleanup() { + local status=$? + trap - EXIT + if [[ -n "$container" ]]; then + "$engine" logs "$container" > "$artifacts/$engine.log" 2>&1 || true + "$engine" inspect "$container" > "$artifacts/$engine-inspect.json" || true + "$engine" cp "$container:/workspace/container-artifacts/." "$artifact_copy_destination/" || true + "$engine" rm -f -v "$container" >/dev/null || true + fi + exit "$status" +} +trap cleanup EXIT + +# Anonymous volume copy-up preserves the prepared caches without mounting any host files. +shared_memory=(--shm-size 256m) +temporary_tmpfs=/tmp:rw,exec,nosuid,nodev,size=512m,mode=1777 +home_tmpfs=/home/sfm:rw,nosuid,nodev,size=128m,uid=10001,gid=10001,mode=700 +if [[ "$engine" == podman ]]; then + # Podman's read-only mode otherwise adds writable /run and /var/tmp mounts. + # Restore only the same bounded shared-memory mount used by Docker. + shared_memory=(--read-only-tmpfs=false + --tmpfs /dev/shm:rw,nosuid,nodev,noexec,size=256m,mode=1777,notmpcopyup) + # Podman's remote tmpfs parser rejects uid/gid mount options. This private + # container has one application UID; sticky permissions keep HOME writable. + home_tmpfs=/home/sfm:rw,nosuid,nodev,size=128m,mode=1777,notmpcopyup + # Build-time /tmp contains source-build output. Podman's default copy-up + # would fill the bounded tmpfs before the worker even starts. + temporary_tmpfs=/tmp:rw,exec,nosuid,nodev,size=512m,mode=1777,notmpcopyup +fi +container=$("$engine" create --network none --read-only --user 10001:10001 \ + --cap-drop ALL --security-opt no-new-privileges:true \ + --pids-limit 512 --memory 8g --memory-swap 8g --cpus 4 \ + "${shared_memory[@]}" --init \ + --tmpfs "$temporary_tmpfs" \ + --tmpfs "$home_tmpfs" \ + --mount type=volume,destination=/workspace \ + "$image" verify) + +# Enforce wall time outside the game JVM. Cleanup kills and removes this container and its volume. +timeout --signal=TERM --kill-after=30s 35m "$engine" start --attach "$container" +status=$("$engine" inspect --format '{{.State.ExitCode}}' "$container") +if [[ "$status" != 0 ]]; then + echo "Container fixture failed with exit code $status" >&2 + exit 1 +fi +"$engine" cp "$container:/workspace/container-artifacts/." "$artifact_copy_destination/" +echo "Offline container fixture passed. Artifacts: $artifacts" diff --git a/containers/sfm/test_junit_diagnostics.sh b/containers/sfm/test_junit_diagnostics.sh new file mode 100644 index 000000000..9fd138c4b --- /dev/null +++ b/containers/sfm/test_junit_diagnostics.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +if [[ $(uname -s) != Linux || ! -d /proc/self ]]; then + echo 'These watchdog tests require Linux /proc.' >&2 + exit 2 +fi + +scratch=$(mktemp -d) +fixture_pids=() +watchdog_pid= +watchdog_children=() + +process_running() { + local pid=$1 key value + [[ -r /proc/$pid/status ]] || return 1 + while read -r key value; do + if [[ $key == State: ]]; then + [[ $value != Z* && $value != X* ]] + return + fi + done < "/proc/$pid/status" + return 1 +} + +cleanup() { + local pid attempt + trap - EXIT + # Every PID here was started by this test or observed beneath its watchdog. + for pid in "${watchdog_pid:-}" "${watchdog_children[@]}" "${fixture_pids[@]}"; do + [[ -n $pid ]] || continue + if process_running "$pid"; then kill -TERM "$pid" 2>/dev/null || true; fi + done + for pid in "${watchdog_pid:-}" "${watchdog_children[@]}" "${fixture_pids[@]}"; do + [[ -n $pid ]] || continue + for ((attempt = 0; attempt < 50; attempt++)); do + if ! process_running "$pid"; then break; fi + sleep 0.02 + done + if process_running "$pid"; then kill -KILL "$pid" 2>/dev/null || true; fi + done + for pid in "${watchdog_pid:-}" "${fixture_pids[@]}"; do + [[ -n $pid ]] || continue + wait "$pid" 2>/dev/null || true + done + rm -rf -- "$scratch" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + if [[ -f $scratch/watchdog.log ]]; then cat "$scratch/watchdog.log" >&2; fi + exit 1 +} + +wait_until() { + local attempt + for ((attempt = 0; attempt < 250; attempt++)); do + if "$@"; then return 0; fi + sleep 0.02 + done + fail "timed out waiting for $*" +} + +process_stopped() { ! process_running "$1"; } +file_nonempty() { [[ -s $1 ]]; } + +mkdir -p "$scratch/java/bin" "$scratch/workspace with spaces" +mkfifo "$scratch/fixture-input" +workspace="$scratch/workspace with spaces" +argument="@$workspace/platform/minecraft/build/sfm-toolchain/run/runTest/junit.java.args" + +start_fixture() { + # A Bash builtin blocks on our FIFO, so fixtures create no child processes. + bash -c 'trap "exit 0" TERM INT; while :; do read -r -t 1 -u 3 line || :; done' \ + junit-fixture "$1" 3<> "$scratch/fixture-input" & + fixture_pids+=("$!") +} +start_fixture "$argument" +selected_pid=${fixture_pids[0]} +start_fixture "prefix$argument" +prefix_pid=${fixture_pids[1]} +start_fixture "$argument.suffix" +suffix_pid=${fixture_pids[2]} +start_fixture "$argument" +wrong_class_pid=${fixture_pids[3]} + +cat > "$scratch/java/bin/jcmd" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$STUB_CALL_LOG" +if [[ $1 == -l ]]; then + printf '%s dev.teamdman.sfm.toolchain.SfmJUnitRunner\n' \ + "$SELECTED_PID" "$PREFIX_PID" "$SUFFIX_PID" + printf '%s unrelated.Main\n' "$WRONG_CLASS_PID" + printf 'jcmd list stderr evidence\n' >&2 + if [[ $STUB_MODE == list-fails ]]; then exit 73; fi +elif [[ $# == 3 && $1 == "$SELECTED_PID" && $2 == Thread.print && $3 == -l ]]; then + if [[ $STUB_MODE == thread-blocks ]]; then + printf '%s\n' "$$" > "$STUB_BLOCKED_PID_FILE" + exec sleep 60 + fi + printf 'thread dump evidence for %s\n' "$1" + printf 'jcmd thread stderr evidence\n' >&2 + if [[ $STUB_MODE == thread-fails ]]; then exit 73; fi +else + echo "Unexpected jcmd target or command: $*" >&2 + exit 91 +fi +SH +chmod +x "$scratch/java/bin/jcmd" + +start_watchdog() { + local mode=$1 interval=$2 snapshots=$3 + output_dir="$scratch/$mode-output" + : > "$scratch/jcmd-calls" + rm -f -- "$scratch/blocked-pid" + watchdog_children=() + env JAVA_HOME="$scratch/java" GITHUB_WORKSPACE="$workspace/" \ + SFM_JUNIT_DIAGNOSTIC_INTERVAL_SECONDS="$interval" \ + SFM_JUNIT_DIAGNOSTIC_SNAPSHOTS="$snapshots" \ + SELECTED_PID="$selected_pid" PREFIX_PID="$prefix_pid" SUFFIX_PID="$suffix_pid" \ + WRONG_CLASS_PID="$wrong_class_pid" STUB_MODE="$mode" \ + STUB_CALL_LOG="$scratch/jcmd-calls" STUB_BLOCKED_PID_FILE="$scratch/blocked-pid" \ + bash "$script_dir/junit-diagnostics.sh" "$output_dir" \ + > "$scratch/watchdog.log" 2>&1 & + watchdog_pid=$! +} + +finish_watchdog() { + local status=0 + wait_until process_stopped "$watchdog_pid" + wait "$watchdog_pid" || status=$? + watchdog_pid= + [[ $status == 0 ]] || fail "watchdog exited $status" +} + +assert_fixtures_alive() { + local pid + for pid in "${fixture_pids[@]}"; do + process_running "$pid" || fail "watchdog stopped fixture $pid" + done +} + +start_watchdog success 0.01 2 +finish_watchdog +[[ $(grep -Fxc -- '-l' "$scratch/jcmd-calls") == 2 ]] || fail 'snapshot list count' +[[ $(grep -Fxc -- "$selected_pid Thread.print -l" "$scratch/jcmd-calls") == 2 ]] || fail 'selected dump count' +[[ $(wc -l < "$scratch/jcmd-calls") == 4 ]] || fail 'attached to an unrelated process' +for snapshot in 01 02; do + [[ -s $output_dir/snapshot-$snapshot-metadata.log ]] || fail 'missing metadata' + grep -Fq 'jcmd list stderr evidence' "$output_dir/snapshot-$snapshot-jcmd-list.log" + grep -Fq "thread dump evidence for $selected_pid" "$output_dir/snapshot-$snapshot-pid-$selected_pid-threads.log" + grep -Fq 'jcmd thread stderr evidence' "$output_dir/snapshot-$snapshot-pid-$selected_pid-threads.log" +done +[[ $(find "$output_dir" -type f | wc -l) == 6 ]] || fail 'snapshot limit or unexpected target evidence' +assert_fixtures_alive + +for mode in list-fails thread-fails; do + start_watchdog "$mode" 0.01 2 + finish_watchdog + [[ $(grep -Fxc -- '-l' "$scratch/jcmd-calls") == 2 ]] || fail 'failure prevented next snapshot' + for snapshot in 01 02; do + if [[ $mode == list-fails ]]; then + grep -Fq 'jcmd_list_exit=73' "$output_dir/snapshot-$snapshot-metadata.log" + grep -Fq 'jcmd list stderr evidence' "$output_dir/snapshot-$snapshot-jcmd-list.log" + else + grep -Fq "thread_dump_pid=$selected_pid exit=73" "$output_dir/snapshot-$snapshot-metadata.log" + grep -Fq 'jcmd thread stderr evidence' "$output_dir/snapshot-$snapshot-pid-$selected_pid-threads.log" + fi + done +done +assert_fixtures_alive + +children_of() { + local pid=$1 children= + if [[ -r /proc/$pid/task/$pid/children ]]; then + read -r children < "/proc/$pid/task/$pid/children" || true + printf '%s\n' "$children" + fi +} + +find_sleep_child() { + local pid name + for pid in $(children_of "$watchdog_pid"); do + if [[ -r /proc/$pid/comm ]]; then + read -r name < "/proc/$pid/comm" || true + if [[ $name == sleep ]]; then + watchdog_children=("$pid") + return 0 + fi + fi + done + return 1 +} + +terminate_watchdog() { + local pid status=0 + kill -TERM "$watchdog_pid" + wait_until process_stopped "$watchdog_pid" + wait "$watchdog_pid" || status=$? + watchdog_pid= + [[ $status == 0 || $status == 143 ]] || fail "TERM exit status $status" + for pid in "${watchdog_children[@]}"; do + [[ ! -e /proc/$pid ]] || fail "watchdog left child $pid running or unreaped" + done + watchdog_children=() + assert_fixtures_alive +} + +start_watchdog idle 60 2 +wait_until find_sleep_child +terminate_watchdog + +start_watchdog thread-blocks 0.01 2 +wait_until file_nonempty "$scratch/blocked-pid" +read -r blocked_pid < "$scratch/blocked-pid" +read -r -a watchdog_children <<< "$(children_of "$watchdog_pid")" +[[ ${#watchdog_children[@]} == 1 ]] || fail 'expected one active timeout child' +read -r timeout_name < "/proc/${watchdog_children[0]}/comm" +[[ $timeout_name == timeout ]] || fail 'jcmd is not bounded by timeout' +[[ " $(children_of "${watchdog_children[0]}") " == *" $blocked_pid "* ]] || fail 'jcmd is not a timeout child' +watchdog_children+=("$blocked_pid") +terminate_watchdog + +# Execute the checked-in workflow block with only its canonical CLI stubbed. +workflow_dir="$scratch/workflow" +mkdir -p "$workflow_dir/containers/sfm" "$workflow_dir/build/ci" +awk ' + { sub(/\r$/, "") } + /^ - name: Run Java unit tests$/ { step = 1; next } + step && /^ run: \|$/ { body = 1; next } + body && /^ / { sub(/^ /, ""); print; next } + body && /^[[:space:]]*$/ { print; next } + body { exit } +' "$script_dir/../../.github/workflows/ci.yml" > "$workflow_dir/step.sh" +[[ -s $workflow_dir/step.sh ]] || fail 'could not locate the workflow unit-test block' +cat > "$workflow_dir/containers/sfm/junit-diagnostics.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$WORKFLOW_WATCHDOG_PID_FILE" +exec bash "$DIAGNOSTICS_SCRIPT" "$@" +SH +cat > "$scratch/cli" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +# Let the real watchdog enter its first sleep before completing the CLI stub. +for ((attempt = 0; attempt < 250; attempt++)); do + if [[ -s $WORKFLOW_WATCHDOG_PID_FILE ]]; then + read -r collector < "$WORKFLOW_WATCHDOG_PID_FILE" + children= + if [[ -r /proc/$collector/task/$collector/children ]]; then + read -r children < "/proc/$collector/task/$collector/children" || true + fi + for child in $children; do + if [[ -r /proc/$child/comm ]]; then + read -r name < "/proc/$child/comm" + if [[ $name == sleep ]]; then + printf '%s\n' "$child" > "$WORKFLOW_CHILD_PID_FILE" + printf 'canonical CLI fixture exit=%s\n' "$STUB_CLI_EXIT" + exit "$STUB_CLI_EXIT" + fi + fi + done + fi + sleep 0.02 +done +echo 'CLI stub never observed the diagnostic sleep' >&2 +exit 92 +SH +chmod +x "$scratch/cli" + +for expected_status in 0 37; do + rm -f -- "$scratch/workflow-watchdog-pid" "$scratch/workflow-child-pid" + ( + cd "$workflow_dir" + exec env JAVA_HOME="$scratch/java" GITHUB_WORKSPACE="$workspace" \ + SFM_CI_CLI="$scratch/cli" STUB_CLI_EXIT="$expected_status" \ + SFM_JUNIT_DIAGNOSTIC_INTERVAL_SECONDS=60 \ + DIAGNOSTICS_SCRIPT="$script_dir/junit-diagnostics.sh" \ + WORKFLOW_WATCHDOG_PID_FILE="$scratch/workflow-watchdog-pid" \ + WORKFLOW_CHILD_PID_FILE="$scratch/workflow-child-pid" \ + bash --noprofile --norc -eo pipefail "$workflow_dir/step.sh" + ) > "$scratch/watchdog.log" 2>&1 & + watchdog_pid=$! + wait_until file_nonempty "$scratch/workflow-child-pid" + read -r collector < "$scratch/workflow-watchdog-pid" + read -r child < "$scratch/workflow-child-pid" + watchdog_children=("$collector" "$child") + wait_until process_stopped "$watchdog_pid" + status=0 + wait "$watchdog_pid" || status=$? + watchdog_pid= + [[ $status == "$expected_status" ]] || fail "workflow changed CLI exit $expected_status to $status" + for pid in "${watchdog_children[@]}"; do + [[ ! -e /proc/$pid ]] || fail "workflow left diagnostic process $pid running or unreaped" + done + watchdog_children=() + grep -Fq "canonical CLI fixture exit=$expected_status" "$workflow_dir/build/ci/test.log" +done +assert_fixtures_alive + +echo 'PASS: exact runner selection, capped evidence, best-effort failures, TERM cleanup, and CI exit preservation.' diff --git a/containers/sfm/test_smoke.sh b/containers/sfm/test_smoke.sh new file mode 100644 index 000000000..838f2b617 --- /dev/null +++ b/containers/sfm/test_smoke.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +scratch=$(mktemp -d) +trap 'rm -rf -- "$scratch"' EXIT +mkdir -p "$scratch/bin" "$scratch/stale" "$scratch/hidden" "$scratch/empty" + +# Stop at creation by default. The lifecycle mode also checks engine routing, +# isolation flags, evidence collection, and cleanup without any real engine. +cat > "$scratch/bin/docker" <<'SH' +#!/usr/bin/env bash +printf '%s %s\n' "${0##*/}" "$*" >> "$ENGINE_CALL_LOG" +if [[ "$STUB_MODE" == create-fails ]]; then exit 73; fi +case "$1" in + create) printf 'fixture-container\n' ;; + start) exit "${STUB_START_STATUS:-0}" ;; + inspect) + if [[ "${2:-}" == --format ]]; then printf '0\n'; else printf '{}\n'; fi + ;; + logs) printf 'fixture log\n' ;; + cp|rm) ;; + *) exit 64 ;; +esac +SH +cp "$scratch/bin/docker" "$scratch/bin/podman" +chmod +x "$scratch/bin/docker" "$scratch/bin/podman" + +printf '{"passed":true}\n' > "$scratch/stale/verification.json" +printf 'previous screenshot bytes\n' > "$scratch/stale/screenshot.png" +printf 'preserve hidden results\n' > "$scratch/hidden/.receipt" + +run_smoke() { + local destination=$1 + local expected=$2 + local engine=${3:-} + local mode=${4:-create-fails} + local start_status=${5:-0} + local status=0 + local selection=(env -u SFM_CONTAINER_ENGINE) + if [[ -n "$engine" ]]; then selection+=("SFM_CONTAINER_ENGINE=$engine"); fi + "${selection[@]}" PATH="$scratch/bin:$PATH" ENGINE_CALL_LOG="$scratch/engine-calls" \ + STUB_MODE="$mode" STUB_START_STATUS="$start_status" \ + bash "$script_dir/smoke.sh" sfm-test:fixture "$destination" \ + > "$scratch/output.log" 2>&1 || status=$? + if [[ "$status" != "$expected" ]]; then + cat "$scratch/output.log" >&2 + echo "Expected exit $expected, got $status for $destination" >&2 + exit 1 + fi +} + +for destination in "$scratch/stale" "$scratch/hidden"; do + run_smoke "$destination" 2 + grep -q 'Choose a fresh or empty directory' "$scratch/output.log" + [[ ! -e "$scratch/engine-calls" ]] +done +run_smoke "$scratch/stale" 2 podman +[[ ! -e "$scratch/engine-calls" ]] +cmp "$scratch/stale/verification.json" <(printf '{"passed":true}\n') +cmp "$scratch/stale/screenshot.png" <(printf 'previous screenshot bytes\n') +cmp "$scratch/hidden/.receipt" <(printf 'preserve hidden results\n') +[[ $(find "$scratch/stale" -mindepth 1 -maxdepth 1 | wc -l) -eq 2 ]] +[[ $(find "$scratch/hidden" -mindepth 1 -maxdepth 1 | wc -l) -eq 1 ]] + +for destination in "$scratch/empty" "$scratch/new"; do + run_smoke "$destination" 73 + [[ -d "$destination" ]] +done +[[ $(wc -l < "$scratch/engine-calls") -eq 2 ]] +[[ $(grep -c '^docker create ' "$scratch/engine-calls") -eq 2 ]] + +for engine in docker podman; do + : > "$scratch/engine-calls" + selection=$engine + if [[ "$engine" == docker ]]; then selection=; fi + destination="$scratch/$engine-lifecycle" + run_smoke "$destination" 0 "$selection" lifecycle + [[ -f "$destination/$engine.log" && -f "$destination/$engine-inspect.json" ]] + [[ $(grep -c "^$engine " "$scratch/engine-calls") -eq 8 ]] + [[ $(wc -l < "$scratch/engine-calls") -eq 8 ]] + for arguments in '--network none' '--read-only --user 10001:10001' \ + '--cap-drop ALL' '--security-opt no-new-privileges:true' \ + '--pids-limit 512 --memory 8g --memory-swap 8g --cpus 4' \ + '--mount type=volume,destination=/workspace'; do + grep "^$engine create " "$scratch/engine-calls" | grep -Fq -- "$arguments" + done + if [[ "$engine" == podman ]]; then + grep -Fq -- '--read-only-tmpfs=false' "$scratch/engine-calls" + grep -Fq -- '--tmpfs /dev/shm:rw,nosuid,nodev,noexec,size=256m,mode=1777,notmpcopyup' "$scratch/engine-calls" + grep -Fq -- '--tmpfs /home/sfm:rw,nosuid,nodev,size=128m,mode=1777,notmpcopyup' "$scratch/engine-calls" + grep -Fq -- '--tmpfs /tmp:rw,exec,nosuid,nodev,size=512m,mode=1777,notmpcopyup' "$scratch/engine-calls" + ! grep -Fq -- 'uid=10001,gid=10001' "$scratch/engine-calls" + else + grep -Fq -- '--shm-size 256m' "$scratch/engine-calls" + grep -Fq -- '--tmpfs /home/sfm:rw,nosuid,nodev,size=128m,uid=10001,gid=10001,mode=700' "$scratch/engine-calls" + grep -Fq -- '--tmpfs /tmp:rw,exec,nosuid,nodev,size=512m,mode=1777' "$scratch/engine-calls" + ! grep -Fq -- '--read-only-tmpfs' "$scratch/engine-calls" + ! grep -Fq -- 'notmpcopyup' "$scratch/engine-calls" + fi + grep -Fxq "$engine start --attach fixture-container" "$scratch/engine-calls" + copy_destination=$destination + if [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* ]]; then + copy_destination=$(cygpath -am "$destination") + fi + [[ $(grep -Fxc "$engine cp fixture-container:/workspace/container-artifacts/. $copy_destination/" \ + "$scratch/engine-calls") -eq 2 ]] + grep -Fxq "$engine rm -f -v fixture-container" "$scratch/engine-calls" +done + +: > "$scratch/engine-calls" +run_smoke "$scratch/podman-failure" 42 podman lifecycle 42 +grep -Fxq 'podman rm -f -v fixture-container' "$scratch/engine-calls" +[[ -f "$scratch/podman-failure/podman.log" ]] +! grep -q '^docker ' "$scratch/engine-calls" + +: > "$scratch/engine-calls" +run_smoke "$scratch/invalid-engine" 2 'podman --privileged' +grep -q 'SFM_CONTAINER_ENGINE must be docker or podman' "$scratch/output.log" +[[ ! -s "$scratch/engine-calls" && ! -e "$scratch/invalid-engine" ]] +echo 'PASS: fresh outputs, Docker default, Podman isolation/routing/cleanup, and invalid-engine rejection.' diff --git a/containers/sfm/test_verify.py b/containers/sfm/test_verify.py new file mode 100644 index 000000000..fde7dfa58 --- /dev/null +++ b/containers/sfm/test_verify.py @@ -0,0 +1,131 @@ +"""Offline regressions for the evidence contract; never starts Minecraft.""" +import json +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest +import zlib + + +# Reduced from the hosted run that exposed the wrapper/JVM log distinction. +CLI_LOG = """[ci-container rust] Minecraft JVM output written to /workspace/platform/minecraft/build/sfm-toolchain/run/runGameTestPreview/console.log +[ci-container rust] Validated game puppet preview completion. + retained_viewport=false +""" +# Marker emitted by SFMGamePuppetHarness and consumed by the canonical CLI. +GAME_LOG = "[Render thread/INFO] SFM_GAME_PUPPET_COMPLETE failed=0 total=1\n" +PUPPETS = { + "title_screen_capture": ["loading-overlay", "title-screen-fading-in", "title-screen-settled"], + "game_test_orbit_capture": [f"orbit-{i:02d}" for i in range(8)], +} + + +def png_chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + + +def fixture_png(): + return (b"\x89PNG\r\n\x1a\n" + + png_chunk(b"IHDR", struct.pack(">IIBBBBB", 320, 240, 8, 2, 0, 0, 0)) + + png_chunk(b"IDAT", zlib.compress((b"\0" + b"\x80" * (320 * 3)) * 240)) + + png_chunk(b"IEND", b"")) + + +class VerifyEvidenceTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="sfm-container-evidence-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + for puppet, captures in PUPPETS.items(): + run = self.root / puppet + previews = run / "previews" + previews.mkdir(parents=True) + (run / "exit-code.txt").write_text("0\n", encoding="utf-8") + (run / "console.log").write_text(CLI_LOG, encoding="utf-8") + (run / "game-console.log").write_text(GAME_LOG, encoding="utf-8") + manifest = {"captures": []} + for name in captures: + path = f"{name}.png" + (previews / path).write_bytes(fixture_png()) + manifest["captures"].append({"puppet": puppet, "capture": name, + "path": path, "width": 320, "height": 240}) + (previews / "preview-manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + self.title = self.root / "title_screen_capture" + + def verify(self, expected_success): + result = subprocess.run([sys.executable, str(Path(__file__).with_name("verify.py")), + str(self.root)], capture_output=True, text=True, check=False) + self.assertEqual(result.returncode == 0, expected_success, result.stdout + result.stderr) + return result + + def test_non_tty_wrapper_without_marker_uses_jvm_evidence(self): + result = self.verify(True) + self.assertEqual(json.loads(result.stdout)["runs"], [ + {"puppet": "title_screen_capture", "screenshots": 3}, + {"puppet": "game_test_orbit_capture", "screenshots": 8}, + ]) + + def test_wrapper_marker_cannot_replace_missing_jvm_marker(self): + (self.title / "console.log").write_text(GAME_LOG, encoding="utf-8") + (self.title / "game-console.log").write_text(CLI_LOG, encoding="utf-8") + self.verify(False) + + def test_missing_jvm_log_fails(self): + (self.title / "game-console.log").unlink() + self.verify(False) + + def test_duplicate_completion_fails(self): + (self.title / "game-console.log").write_text(GAME_LOG * 2, encoding="utf-8") + self.verify(False) + + def test_failed_puppet_marker_fails(self): + (self.title / "game-console.log").write_text( + GAME_LOG + "SFM_GAME_PUPPET_FAILED puppet=title_screen_capture\n", encoding="utf-8") + self.verify(False) + + def test_failed_or_multiple_puppet_completion_fails(self): + for marker in ("SFM_GAME_PUPPET_COMPLETE failed=1 total=1\n", + "SFM_GAME_PUPPET_COMPLETE failed=0 total=2\n", + "SFM_GAME_PUPPET_COMPLETE_PENDING failed=0 total=1\n"): + with self.subTest(marker=marker): + (self.title / "game-console.log").write_text(marker, encoding="utf-8") + self.verify(False) + + def test_nonzero_process_exit_fails_with_good_artifacts(self): + (self.title / "exit-code.txt").write_text("1\n", encoding="utf-8") + self.verify(False) + + def test_missing_referenced_png_fails(self): + (self.title / "previews/loading-overlay.png").unlink() + self.verify(False) + + def test_incomplete_manifest_fails(self): + path = self.title / "previews/preview-manifest.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + manifest["captures"].pop() + path.write_text(json.dumps(manifest), encoding="utf-8") + self.verify(False) + + def test_extra_capture_fails(self): + path = self.title / "previews/preview-manifest.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + extra = dict(manifest["captures"][0], capture="unexpected", path="unexpected.png") + manifest["captures"].append(extra) + (path.parent / extra["path"]).write_bytes(fixture_png()) + path.write_text(json.dumps(manifest), encoding="utf-8") + self.verify(False) + + def test_duplicate_capture_name_with_distinct_path_fails(self): + path = self.title / "previews/preview-manifest.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + duplicate = dict(manifest["captures"][0], path="duplicate.png") + manifest["captures"].append(duplicate) + (path.parent / duplicate["path"]).write_bytes(fixture_png()) + path.write_text(json.dumps(manifest), encoding="utf-8") + self.verify(False) + + +if __name__ == "__main__": + unittest.main() diff --git a/containers/sfm/verify.py b/containers/sfm/verify.py new file mode 100644 index 000000000..486d3940c --- /dev/null +++ b/containers/sfm/verify.py @@ -0,0 +1,56 @@ +"""Verify independent, fresh game-puppet runs using only Python's stdlib.""" +import json +from pathlib import Path +import re +import struct +import sys + +artifacts = Path(sys.argv[1]).resolve() +expected = { + "title_screen_capture": { + "loading-overlay", "title-screen-fading-in", "title-screen-settled" + }, + "game_test_orbit_capture": {f"orbit-{index:02d}" for index in range(8)}, +} +results = [] +for puppet, expected_captures in expected.items(): + run = artifacts / puppet + if (run / "exit-code.txt").read_text().strip() != "0": + raise SystemExit(f"Puppet process failed: {puppet}") + # CLI stdout reports build/launch progress. The raw JVM markers are in the + # canonical run log, which run.sh snapshots before each subsequent launch. + log = (run / "game-console.log").read_text(encoding="utf-8", errors="replace") + completions = re.findall(r"SFM_GAME_PUPPET_COMPLETE(?=\s)[^\r\n]*", log) + if (len(completions) != 1 + or not re.fullmatch(r"SFM_GAME_PUPPET_COMPLETE failed=0 total=1\s*", completions[0]) + or "SFM_GAME_PUPPET_FAILED" in log): + raise SystemExit(f"Missing single-puppet successful completion marker: {puppet}") + root = (run / "previews").resolve() + manifest = json.loads((root / "preview-manifest.json").read_text(encoding="utf-8")) + captures = manifest.get("captures", []) + observed_puppets = {capture["puppet"].removeprefix("sfm:") for capture in captures} + if observed_puppets != {puppet}: + raise SystemExit(f"Unexpected screenshot set for {puppet}: {observed_puppets}") + capture_names = {capture["capture"] for capture in captures} + if capture_names != expected_captures or len(captures) != len(expected_captures): + raise SystemExit(f"Expected exactly {len(expected_captures)} distinct captures for {puppet}; " + f"found {len(captures)} entries with names {sorted(capture_names)}") + seen = set() + for capture in captures: + path = (root / capture["path"]).resolve() + if not path.is_relative_to(root) or path in seen: + raise SystemExit(f"Unsafe or duplicate screenshot reference: {capture['path']}") + seen.add(path) + if not path.is_file(): + raise SystemExit(f"Missing referenced screenshot: {capture['path']}") + with path.open("rb") as stream: + header = stream.read(24) + if header[:8] != b"\x89PNG\r\n\x1a\n" or len(header) != 24: + raise SystemExit(f"Invalid PNG: {path.relative_to(root)}") + width, height = struct.unpack(">II", header[16:24]) + if (width, height) != (capture["width"], capture["height"]): + raise SystemExit(f"Screenshot dimensions disagree with manifest: {capture['path']}") + if width < 320 or height < 240: + raise SystemExit(f"Unexpected screenshot dimensions: {width}x{height}") + results.append({"puppet": puppet, "screenshots": len(seen)}) +print(json.dumps({"passed": True, "runs": results}, indent=2)) diff --git a/containers/sfm/vox-diagnostic.py b/containers/sfm/vox-diagnostic.py new file mode 100644 index 000000000..eaaa58874 --- /dev/null +++ b/containers/sfm/vox-diagnostic.py @@ -0,0 +1,197 @@ +"""Replay the recorded original Vox Java failure without rebuilding Rust or SFM.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys + + +# This review baseline deliberately remains independent of SFM's current lock. +# SFM now consumes the repaired revision, but replay needs the original inputs. +BASELINE_REMOTE = "https://github.com/TeamDman/facet" +BASELINE_REVISION = "f2afdece6c79e64085d2f8c047e22fe16b2c8c54" +BASELINE_ARTIFACT_HASH = "blake3:4d1e88353f941be926fdf84f1dd8da9bd594b60f" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("workspace", "scratch", "artifacts", "java-home"): + parser.add_argument("--" + name, type=Path, required=True) + parser.add_argument("--candidate", action="store_true", + help="Apply the review candidate only to the disposable original baseline") + parser.add_argument("--reduced-only", action="store_true", + help="Reproduce baseline active/closed credit ordering without the full test") + args = parser.parse_args() + if args.candidate and args.reduced_only: + parser.error("--reduced-only records the unchanged baseline; omit --candidate") + workspace = args.workspace.resolve() + scratch = args.scratch.resolve() + artifacts = args.artifacts.resolve() + java_home = args.java_home.resolve() + artifacts.mkdir(parents=True, exist_ok=True) + # A new disposable directory prevents this diagnostic from reusing stale classes. + scratch.mkdir(parents=True, exist_ok=False) + receipt = {"test_runs": 0, "outcome": "setup", "commands": [], + "candidate": args.candidate, "reduced_only": args.reduced_only} + + def save_receipt(): + (artifacts / "receipt.json").write_text( + json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + + def run(command, log_name, timeout, *, cwd=scratch, env=None): + receipt["commands"].append([str(value) for value in command]) + save_receipt() + print(f"Running {log_name}", flush=True) + with (artifacts / log_name).open("wb") as log: + result = subprocess.run(command, cwd=cwd, env=env, stdout=log, + stderr=subprocess.STDOUT, timeout=timeout, check=False) + print(f"{log_name}: exit {result.returncode}", flush=True) + return result.returncode + + def required(command, log_name, timeout, **kwargs): + code = run(command, log_name, timeout, **kwargs) + if code: + raise RuntimeError(f"{log_name} failed with exit {code}") + + try: + lock_path = workspace / "platform/minecraft/sfm-toolchain.lock.json" + lock_bytes = lock_path.read_bytes() + lock = json.loads(lock_bytes) + candidates = [artifact for artifact in lock["artifacts"] + if artifact.get("owner") == {"dependency_id": "vox-java", "component_id": "main"}] + if len(candidates) != 1: + raise RuntimeError("Expected exactly one locked vox-java/main artifact") + artifact = candidates[0] + revision = BASELINE_REVISION + remote = BASELINE_REMOTE + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise RuntimeError("Recorded Vox baseline must name an immutable Git commit") + receipt.update({"source_revision": revision, "source_remote": remote, + "recorded_baseline_artifact_hash": BASELINE_ARTIFACT_HASH, + "current_sfm_locked_source_revision": artifact["source_git"]["commit"], + "current_sfm_locked_artifact_hash": artifact["hash"], + "sfm_lock_sha256": hashlib.sha256(lock_bytes).hexdigest(), + "locale": {key: os.environ.get(key) for key in ("LANG", "LC_ALL")}}) + # The SFM lock above is evidence only. Fetch the immutable review baseline + # into fresh scratch, never a developer checkout or the SFM build cache. + required(["git", "init", "source"], "git-init.log", 15) + checkout = scratch / "source" + required(["git", "remote", "add", "origin", remote], "git-remote.log", 15, cwd=checkout) + required(["git", "-c", "credential.helper=", "fetch", "--depth=1", "origin", revision], + "git-fetch.log", 300, cwd=checkout) + required(["git", "-c", "core.hooksPath=/dev/null", "checkout", "--detach", "FETCH_HEAD"], + "git-checkout.log", 30, cwd=checkout) + actual = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=checkout, text=True).strip() + if actual != revision: + raise RuntimeError("Fetched source revision differs from the recorded baseline") + + if args.candidate: + patch = workspace / "containers/sfm/vox-diagnostic/credit-candidate.patch" + receipt["candidate_patch_sha256"] = hashlib.sha256(patch.read_bytes()).hexdigest() + required(["git", "apply", "--check", str(patch)], "patch-check.log", 15, cwd=checkout) + required(["git", "apply", str(patch)], "patch-apply.log", 15, cwd=checkout) + changed = subprocess.check_output(["git", "diff", "--name-only"], + cwd=checkout, text=True).splitlines() + if changed != ["vox/java/runtime/src/main/java/org/facet/vox/VoxConnection.java"]: + raise RuntimeError("Candidate must change only the disposable VoxConnection.java") + required(["git", "diff", "--exit-code", "--", + "vox/java/runtime/src/test/java/org/facet/vox/VoxRuntimeTest.java"], + "original-test-unchanged.log", 15, cwd=checkout) + + roots = ("phon/java/runtime/src/main/java", "phon/java/runtime/src/test/java", + "vox/java/runtime/src/main/java", "vox/java/runtime/src/test/java", + "vox/java/generated/src/main/java", "vox/java/subject/src/main/java") + sources = sorted(path for root in roots for path in (checkout / root).rglob("*.java")) + if not sources: + raise RuntimeError("No pinned Java sources found") + classes = scratch / "classes" + classes.mkdir() + argfile = artifacts / "javac.args" + arguments = ["--release", "17", "-Xlint:all", "-Werror", "-d", str(classes)] + arguments.extend(str(path) for path in sources) + argfile.write_text("\n".join('"' + value.replace("\\", "/") + '"' + for value in arguments) + "\n", encoding="utf-8") + receipt["source_count"] = len(sources) + receipt["source_sha256"] = { + str(path.relative_to(checkout)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in sources} + environment = os.environ.copy() + environment["JAVA_HOME"] = str(java_home) + java = java_home / "bin/java" + javac = java_home / "bin/javac" + required([str(java), "-XshowSettings:properties", "-version"], "java-settings.log", 15, + env=environment) + required([str(javac), "@" + str(argfile)], "javac.log", 180, env=environment) + if args.candidate or args.reduced_only: + probe = workspace / "containers/sfm/vox-diagnostic/VoxMissingCreditProbe.java" + receipt["probe_sha256"] = hashlib.sha256(probe.read_bytes()).hexdigest() + required([str(javac), "--release", "17", "-Xlint:all", "-Werror", "-cp", str(classes), + "-d", str(classes), str(probe)], "probe-javac.log", 30, env=environment) + environment["VOX_DLOG"] = "1" + if args.reduced_only: + # Both inputs use the unchanged baseline driver. The only difference + # is that one sends the same credit after the local sender Close. + receipt["variant_exit_codes"] = {} + for variant in ("active_credit_passes", "late_credit_after_close_passes"): + variant_code = run([str(java), "-ea", + "-Xlog:exceptions=info:file=" + + str(artifacts / (variant + "-jvm-exceptions.log")), + "-cp", str(classes), + "org.facet.vox.VoxMissingCreditProbe", variant], + "variant-" + variant + ".log", 15, env=environment) + receipt["variant_exit_codes"][variant] = variant_code + active_code = receipt["variant_exit_codes"]["active_credit_passes"] + closed_code = receipt["variant_exit_codes"]["late_credit_after_close_passes"] + failure_log = (artifacts / "variant-late_credit_after_close_passes.log").read_text( + encoding="utf-8", errors="replace") + expected_error = "org.facet.vox.VoxException: message for unknown channel 1:1" + receipt["expected_failure"] = expected_error + reproduced = (active_code == 0 and closed_code == 1 + and expected_error in failure_log + and "VoxConnection.processInboundChannel(" in failure_log) + receipt["outcome"] = ("baseline-failure-reproduced" if reproduced + else "baseline-reproduction-mismatch") + print(receipt["outcome"], flush=True) + return 0 if reproduced else 1 + receipt["test_runs"] = 1 + code = run([str(java), "-ea", + "-Xlog:exceptions=info:file=" + str(artifacts / "jvm-exceptions.log"), + "-cp", str(classes), "org.facet.vox.VoxRuntimeTest"], + "original-test.log", 60, env=environment) + receipt.update({"test_exit_code": code, "outcome": "passed" if code == 0 else "failed"}) + if args.candidate: + variants = ( + "active_credit_passes", "late_credit_after_close_passes", + "missing_credit_passes", "late_credit_has_no_history_window", + "credit_on_zero_channel_fails", + "credit_on_control_lane_fails", "credit_on_unopened_lane_fails", + "credit_on_opening_lane_fails", "credit_after_local_lane_close_passes", + "zero_credit_on_active_fails", "zero_credit_on_missing_fails", + "zero_credit_on_closed_fails", "overflow_credit_on_missing_fails", + "missing_credit_field_fails", "wrong_type_credit_fails", + "credit_to_active_receiver_fails", "item_to_closed_sender_fails", + "close_to_missing_channel_fails", "reset_to_closed_sender_fails_unchanged", + ) + receipt["variant_exit_codes"] = {} + for variant in variants: + variant_code = run([str(java), "-ea", "-cp", str(classes), + "org.facet.vox.VoxMissingCreditProbe", variant], + "variant-" + variant + ".log", 15, env=environment) + receipt["variant_exit_codes"][variant] = variant_code + if any(receipt["variant_exit_codes"].values()): + receipt["outcome"] = "failed" + return 1 + return code + except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as failure: + receipt.update({"outcome": "diagnostic-error", "error": str(failure)}) + print(str(failure), file=sys.stderr) + return 1 + finally: + save_receipt() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/containers/sfm/vox-diagnostic/README.md b/containers/sfm/vox-diagnostic/README.md new file mode 100644 index 000000000..8d9e9b7bb --- /dev/null +++ b/containers/sfm/vox-diagnostic/README.md @@ -0,0 +1,101 @@ +# Vox credit retirement diagnostic + +This review experiment always fetches the recorded original Facet baseline, +`f2afdece6c79e64085d2f8c047e22fe16b2c8c54`, independently of SFM's current +toolchain lock. That immutable revision contained the failing Vox Java runtime; +its recorded packaged artifact hash was +`blake3:4d1e88353f941be926fdf84f1dd8da9bd594b60f`. + +SFM now pins the repaired revision, so selecting diagnostic source from the +current lock would no longer reproduce the baseline failure and would apply the +candidate patch twice. The helper records both its fixed baseline and the current +SFM lock identity in the receipt, while the fetched Git commit must match the +baseline exactly. + +The workflow applies `credit-candidate.patch` only inside a fresh temporary +baseline checkout. It compiles diagnostic classes there and produces logs and +receipts. It never supplies classes or JARs to SFM builds, writes to their cache, +or changes a dependency declaration, lockfile, or published artifact. + +## Observed failure and reduction + +Two native Linux builds failed in the unchanged `VoxRuntimeTest` after its first +40-item transfer, with the next call observing a closed lane. A diagnostic run of +the same pinned Java sources with JBR 17.0.6 exposed the earlier driver exception: +`message for unknown channel 1:1`, followed by connection shutdown. With logging, +the original test failed while awaiting the first response instead of on its next +call. That is a timing difference within the same first transfer. + +A later hosted comparison passed both the unchanged original and candidate tests. +The original full-test failure is therefore timing-dependent; a single passing +baseline run does not invalidate the preserved failures or prove the race is gone. + +The exception trace does not contain the channel message body. Late receiver +credit is inferred from the sender/receiver roles and first-transfer test sequence, +and tested directly with the deterministic driver probe. The closest passing +baseline delivers the same credit before local sender Close. Delivering it after +Close fails with the exact unknown-channel exception. + +## Proposed behavior + +The pinned reference implementation and specification are at Facet revision +`f2afdece6c79e64085d2f8c047e22fe16b2c8c54`: + +- `vox/rust/vox-core/src/driver.rs`, `handle_channel` / `GrantCredit`: + add permits only if the channel's credit semaphore still exists; otherwise + the credit has no effect. +- `vox/docs/content/spec/rpc.md`, `rpc.flow-control.credit.grant`: + the receiver may grant credit after the channel exists. +- The same specification's `rpc.channel.close` forbids subsequent sender Items. + It does not introduce an acknowledgement or fence for credit already travelling + in the opposite direction. + +The patch moves the existing Java credit validation before channel lookup, then +discards valid credit for a nonzero channel ID when no active channel remains on +an accepted inbound or open outbound lane. Java's existing handling of known +locally closed lanes stays in place. Unknown lanes, lane zero, and outbound lanes +still awaiting acceptance continue to reject credit. This lane gate preserves +Java's existing strictness; the Rust connection layer also filters inactive lanes +before its driver receives channel messages. + +Active channels still enforce direction and add credit normally. There is no retained history, expiry, +new bound, or allocation per discarded credit. The existing Java positive +`int` credit range is preserved; this proposal does not expand it to all `u32`. +For an absent nonzero channel on an accepted lane, valid credit is discarded +without reconstructing that old channel's allocation or parity. No credit is +saved for a future channel and no channel is created, matching the reference +driver's handling of missing channel credit. + +Missing-channel Item, Close, and Reset behavior stays unchanged. Rust's Reset +handling consults terminal-channel state, so treating every unknown Reset as a +no-op would need a separate review. + +## Validation contract + +Pushes to the diagnostic branch and the default manual mode (`reduced`) compile +fresh, untouched original baseline sources and run only two deterministic probe inputs once: +credit before local Close must pass; the same credit after Close must fail with +`message for unknown channel 1:1` from `VoxConnection.processInboundChannel`. +The receipt records both real exit codes and `baseline-failure-reproduced` only +when both expected outcomes and the exact exception match. An unexpected pass, +different failure, timeout, or build error fails the diagnostic job. This job +proves the reduced baseline bug; its success does not mean the runtime is fixed. +It does not run or retry the full original test. + +The explicit manual `full` mode runs the full comparison once. The original job +uses untouched original baseline sources and remains failed when that test fails. The +candidate job applies only the proposed `VoxConnection.java` change, checks that +the original test source is unchanged, and runs the same test plus each retained +probe variant once. There are no retries or allowed failures. + +The 19 variants cover active/closed/missing sender credit, delay beyond the old +bounded-history proposal, malformed or out-of-range credit, active wrong-direction +credit, invalid lane and zero-channel IDs, a not-yet-accepted lane, known local lane +closure, and unchanged Item/Close/Reset errors. The probes invoke the actual pinned +private driver seam with real registered ServiceLane state, real wire types, and generated channel metadata. +Malformed-value variants deliberately bypass decoding to test driver validation. + +Artifacts include exact source/patch/probe hashes, source and test counts, command +arguments, compiler output, original test output, JVM exceptions, and each probe's +output and exit status. A candidate success is evidence for review, not a dependency +update or a claim that SFM CI is fixed. diff --git a/containers/sfm/vox-diagnostic/VoxMissingCreditProbe.java b/containers/sfm/vox-diagnostic/VoxMissingCreditProbe.java new file mode 100644 index 000000000..c08ad8095 --- /dev/null +++ b/containers/sfm/vox-diagnostic/VoxMissingCreditProbe.java @@ -0,0 +1,204 @@ +package org.facet.vox; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.Socket; +import java.util.List; +import java.util.Map; +import org.facet.phon.Value; +import org.facet.vox.generated.JavaFixtureServiceDescriptor; +import org.facet.vox.tcp.StreamFraming; +import org.facet.vox.tcp.WireConstants; + +/** Review-only deterministic sequence using the pinned runtime's actual driver and wire types. */ +public final class VoxMissingCreditProbe { + public static void main(String[] args) throws Exception { + try (Fixture fixture = new Fixture()) { + switch (args[0]) { + case "active_credit_passes" -> { + fixture.bind(1); + fixture.inbound(fixture.codec.channelGrant(1, 1)); + } + case "late_credit_after_close_passes" -> { + fixture.bindAndClose(1); + fixture.inbound(fixture.codec.channelGrant(1, 1)); + } + case "missing_credit_passes" -> + fixture.inbound(fixture.codec.channelGrant(99, 1)); + case "credit_on_zero_channel_fails" -> + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelGrant(0, 1)), + "message for unknown channel 1:0"); + case "credit_on_control_lane_fails" -> + expectVoxFailure(() -> fixture.inboundOnLane(0, + fixture.codec.channelGrant(1, 1)), "message for unknown channel 0:1"); + case "credit_on_unopened_lane_fails" -> + expectVoxFailure(() -> fixture.inboundOnLane(99, + fixture.codec.channelGrant(1, 1)), "message for unknown channel 99:1"); + case "credit_on_opening_lane_fails" -> { + fixture.registerLane(2, LaneState.OPENING); + expectVoxFailure(() -> fixture.inboundOnLane(2, + fixture.codec.channelGrant(1, 1)), "message for unknown channel 2:1"); + } + case "credit_after_local_lane_close_passes" -> { + fixture.closeLane(); + fixture.inbound(fixture.codec.channelGrant(99, 1)); + } + case "late_credit_has_no_history_window" -> { + fixture.bindAndClose(1); + for (long id = 3; id < 100; id += 2) fixture.bindAndClose(id); + fixture.inbound(fixture.codec.channelGrant(1, 1)); + } + case "zero_credit_on_active_fails" -> { + fixture.bind(1); + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelGrant(1, 0)), + "invalid channel credit 0"); + } + case "zero_credit_on_missing_fails" -> + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelGrant(99, 0)), + "invalid channel credit 0"); + case "zero_credit_on_closed_fails" -> { + fixture.bindAndClose(1); + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelGrant(1, 0)), + "invalid channel credit 0"); + } + case "overflow_credit_on_missing_fails" -> + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelGrant(99, -1)), + "invalid channel credit 4294967295"); + case "missing_credit_field_fails" -> + expectVoxFailure(() -> fixture.malformedGrant(Value.map(Map.of())), + "missing wire field additional"); + case "wrong_type_credit_fails" -> + expectVoxFailure(() -> fixture.malformedGrant( + Value.map(Map.of("additional", Value.string("1")))), + "expected unsigned wire integer"); + case "credit_to_active_receiver_fails" -> { + fixture.bindReceiver(1); + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelGrant(1, 1)), + "peer granted credit to locally-receiving channel"); + } + case "item_to_closed_sender_fails" -> { + fixture.bindAndClose(1); + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelItem(1, new byte[0])), + "message for unknown channel 1:1"); + } + case "close_to_missing_channel_fails" -> + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelClose(99)), + "message for unknown channel 1:99"); + case "reset_to_closed_sender_fails_unchanged" -> { + fixture.bindAndClose(1); + expectVoxFailure(() -> fixture.inbound(fixture.codec.channelReset(1)), + "message for unknown channel 1:1"); + } + default -> throw new IllegalArgumentException(args[0]); + } + System.out.println("PASS " + args[0]); + } + } + + @FunctionalInterface private interface Checked { void run() throws Exception; } + private static void expectVoxFailure(Checked action, String message) throws Exception { + try { action.run(); } catch (VoxException expected) { + if (!message.equals(expected.getMessage())) { + throw new AssertionError("Expected '" + message + "', got '" + + expected.getMessage() + "'", expected); + } + return; + } + throw new AssertionError("Expected VoxException: " + message); + } + + private static Object invoke(Method method, Object owner, Object... args) throws Exception { + try { return method.invoke(owner, args); } + catch (InvocationTargetException failure) { + if (failure.getCause() instanceof Exception cause) throw cause; + throw failure; + } + } + + private static Method method(String name, Class... parameters) throws Exception { + Method method = VoxConnection.class.getDeclaredMethod(name, parameters); + method.setAccessible(true); + return method; + } + + private static final class Fixture implements AutoCloseable { + final ConnectionOptions options = ConnectionOptions.builder().maxPendingRequests(2).build(); + final VoxConnection connection; + final WireCodec codec = new WireCodec(options); + final StreamFraming framing; + final Method bind = method("bindInboundChannel", String.class, long.class, long.class, + MethodDescriptor.class, ChannelDescriptor.class, int.class); + final Method inbound = method("processInboundChannel", long.class, Value.class); + final Method command; + final Constructor close; + + Fixture() throws Exception { + connection = VoxConnection.accept(new Socket(), new ServiceRegistry(), options); + registerLane(1, LaneState.OPEN); + framing = new StreamFraming(new ByteArrayInputStream(new byte[] { + 'V', 'O', 'X', 'L', (byte) WireConstants.LINK_VERSION, 0 + }), new ByteArrayOutputStream(), options.maxFrameBytes()); + framing.exchangeLinkPrologue(); + Class driverCommand = Class.forName("org.facet.vox.VoxConnection$DriverCommand"); + command = method("processCommand", driverCommand, StreamFraming.class, WireCodec.class); + close = Class.forName("org.facet.vox.VoxConnection$ChannelCloseCommand") + .getDeclaredConstructor(long.class, long.class); + close.setAccessible(true); + } + + void bind(long id) throws Exception { + MethodDescriptor generate = JavaFixtureServiceDescriptor.GENERATE; + invoke(bind, connection, "1:" + id, 1L, id, + generate, generate.channels().get(0), 3); + } + + void bindReceiver(long id) throws Exception { + MethodDescriptor generate = JavaFixtureServiceDescriptor.GENERATE; + ChannelDescriptor sender = generate.channels().get(0); + ChannelDescriptor receiver = new ChannelDescriptor(sender.argumentIndex(), + ChannelDescriptor.Direction.RX, sender.role(), sender.elementAdapter()); + invoke(bind, connection, "1:" + id, 1L, id, generate, receiver, 3); + } + + void bindAndClose(long id) throws Exception { + bind(id); + invoke(command, connection, close.newInstance(1L, id), framing, codec); + } + + void inbound(Value message) throws Exception { + inboundOnLane(1, message); + } + + void inboundOnLane(long laneId, Value message) throws Exception { + invoke(inbound, connection, laneId, WireCodec.variantPayload(message)); + } + + @SuppressWarnings("unchecked") + void registerLane(long id, LaneState state) throws Exception { + Field field = VoxConnection.class.getDeclaredField("lanes"); + field.setAccessible(true); + List lanes = (List) field.get(connection); + lanes.add(new ServiceLane(id, JavaFixtureServiceDescriptor.INSTANCE, connection, + options, LaneOptions.defaults(), state, 1, 64, 3)); + } + + void closeLane() throws Exception { + Constructor constructor = Class.forName("org.facet.vox.VoxConnection$CloseLaneCommand") + .getDeclaredConstructor(long.class); + constructor.setAccessible(true); + invoke(command, connection, constructor.newInstance(1L), framing, codec); + } + + void malformedGrant(Value payload) throws Exception { + // Deliberately bypass the codec to cover validation at the driver seam too. + invoke(inbound, connection, 1L, Value.map(Map.of("id", Value.unsigned(99), + "body", Value.enumValue("GrantCredit", payload)))); + } + + @Override public void close() { connection.close(); } + } +} diff --git a/containers/sfm/vox-diagnostic/credit-candidate.patch b/containers/sfm/vox-diagnostic/credit-candidate.patch new file mode 100644 index 000000000..45bc18cdd --- /dev/null +++ b/containers/sfm/vox-diagnostic/credit-candidate.patch @@ -0,0 +1,50 @@ +--- a/vox/java/runtime/src/main/java/org/facet/vox/VoxConnection.java ++++ b/vox/java/runtime/src/main/java/org/facet/vox/VoxConnection.java +@@ -925,7 +925,19 @@ + long channelId = WireCodec.unsignedLong(WireCodec.required(channel, "id")); + Value body = WireCodec.required(channel, "body"); + String variant = WireCodec.variant(body); ++ long additional = 0; ++ if ("GrantCredit".equals(variant)) { ++ additional = WireCodec.unsignedLong( ++ WireCodec.required(WireCodec.variantPayload(body), "additional")); ++ if (additional == 0 || additional > Integer.MAX_VALUE) { ++ throw new VoxException("invalid channel credit " + additional); ++ } ++ } + ActiveChannel active = activeChannels.get(channelKey(laneId, channelId)); ++ // Receiver credit can cross a local Close or request termination. Like ++ // the Rust driver, discard valid credit when no sender remains to use it. ++ if (active == null && channelId != 0 && "GrantCredit".equals(variant) ++ && (inboundLanes.containsKey(laneId) || hasOpenOutboundLane(laneId))) return; + if (active == null && locallyClosedLanes.contains(laneId)) return; + if (active == null) { + throw new VoxException("message for unknown channel " +@@ -973,11 +985,6 @@ + if (active.sender == null) { + throw new VoxException("peer granted credit to locally-receiving channel"); + } +- long additional = WireCodec.unsignedLong( +- WireCodec.required(WireCodec.variantPayload(body), "additional")); +- if (additional == 0 || additional > Integer.MAX_VALUE) { +- throw new VoxException("invalid channel credit " + additional); +- } + active.sender.grant((int) additional); + } + default -> throw new VoxException("unsupported channel message " + variant); +@@ -1124,6 +1131,15 @@ + clearLaneBindings(laneId); + } + ++ private boolean hasOpenOutboundLane(long laneId) { ++ synchronized (lanes) { ++ for (ServiceLane lane : lanes) { ++ if (lane.id() == laneId) return lane.state() == LaneState.OPEN; ++ } ++ return false; ++ } ++ } ++ + private boolean hasOutboundLane(long laneId) { + synchronized (lanes) { + for (ServiceLane lane : lanes) if (lane.id() == laneId) return true; diff --git a/docs/tasks/ci and container puppet experiment plan.md b/docs/tasks/ci and container puppet experiment plan.md new file mode 100644 index 000000000..624027849 --- /dev/null +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -0,0 +1,462 @@ +# CI and container puppet experiment + +**Plan status:** CI and offline capture experiment complete; external control smoke planned +**Primary implementation root:** branch `ci/1.19.2-container-puppet`, based on `707f53f4a` +**Last updated:** 2026-09-23 +**Intent audit:** Passed against U1-U9, including the authorized Vox update and local Podman capture question + +## How to update this plan + +Use `[ ]` for not started, `[~]` for in progress, `[x]` for complete, and `[!]` +for an external blocker with evidence and an unblock condition. Update each +task's completion notes with its status. The independent tracks are workflow +implementation, container implementation, and integration/validation. + +## Authoritative user guidance and traceability + +| ID | Guidance | Coverage and required evidence | +| --- | --- | --- | +| U1 | Make CI/CD verify Super Factory Manager builds properly for 1.19.2. | Task 2: use the canonical Rust CLI to compile, test and package a mod JAR on GitHub. Artifact delivery is this experiment's CD boundary; release publishing is a later decision. | +| U2 | The 1.19.2 checkout is busy with other development; experiment in a worktree and establish Actions behavior on a non-default branch. | Tasks 1 and 2: separate branch/worktree; push-triggered workflow run tied to its commit. | +| U3 | Docker may be installed but not running; experiment with Docker. | Tasks 1 and 3: inspect actual runtime availability and execute Linux Docker on a hosted runner if local prerequisites are absent. | +| U4 | A future Discord help-channel bot should run isolated game instances rather than an unprotected host process. | Tasks 3 and 4: restricted disposable worker, bounded lifetime/resources and broker separation. Do not infer authorization to connect or message Discord. | +| U5 | Determine how graphics work in Docker or potentially Kubernetes. | Tasks 3 and 4: Xvfb/Mesa experiment with screenshot evidence; document Kubernetes translation and its untested status. | +| U6 | Existing game puppet manipulation and screenshot capture is likely the fixture. | Task 3: use the existing puppet; require its result and screenshot, not merely a successful process start. | +| U7 | The user identified the existing 1.19.2 source checkout. | Task 1: confirmed `TeamDman/SuperFactoryManager`, branch `1.19.2`. Refer to this machine-varying path as `` in public notes. | +| U8 | Continue on GitHub Actions if useful; Podman may be started locally. Update SFM to the new Vox build and publish it to the appropriate Teamy branch. | Task 2: publish a narrow Java-only Facet fix, pin exact source/hash, retain independent hosted verification and preserve busy integration work. | +| U9 | Verify whether local Podman can run the game and produce puppet-captured images. | Task 3: inspect the local Podman smoke receipt, raw game logs, PNGs, graphics diagnostics and runtime isolation. | + +## Intent audit evidence + +- Extraction: reread the initial request and follow-ups as U1-U9, including the explicit local Podman capture question and authorization for the new Vox Java build. The separate coordination request asks for a bounded external-control next slice and an integration-needs report. +- Traceability: U1-U8 have hosted workflow, artifact, local or container evidence in Tasks 1-4; U9 has a saved local Podman receipt, raw game logs and 11 PNGs in Task 3. Task 5 scopes the next authenticated control smoke without claiming it is implemented. +- Adversarial omission: the local smoke used an earlier source revision; current-head hosted checks are separate evidence. Neither a local control client, Discord broker nor Kubernetes job has been exercised. The busy checkout remains outside this implementation worktree. +- Source limitation: the busy checkout's exact JBRSDK17 pin is known from a separate workstream; this branch's hosted jobs select Java explicitly and do not validate that no-override pin. + +## Foundation and constraints + +The original checkout was clean at `707f53f4a`; its origin and remote default +branch are `TeamDman/SuperFactoryManager` and `1.19.2`. There is no checked-in +Actions workflow at the base. GitHub Actions is enabled. The isolated worktree +uses branch `ci/1.19.2-container-puppet`. + +The canonical `1.19.2` checkout has since advanced and contains active work, +including an exact Windows-x64 JBRSDK17 17.0.14 b1367.22 pin. Do not copy its +uncommitted files or merge this experiment into it as part of the control smoke. +The feature branch's Linux, Windows and container workflows pass explicit +`--java-home` paths; their green results do not validate that canonical +no-override Java selection path. Reconcile the pin only at an authorized +integration boundary. + +`docs/AGENTS.md` requires the Rust `sfm-propagate-changes` tool rather than +Gradle. Its commands own compilation, JUnit, packaging, game launches and +puppets. Minecraft targets Java 17. Follow +`docs/tasks/goal execution and testing readiness guidelines.md`. + +Project dependencies and lockfiles stay frozen except for the explicitly +authorized Vox Java fix. Deterministic restoration of pinned inputs is allowed. +Container base images and OS graphics/build +packages are new infrastructure inputs for this experiment; they do not change +the mod's dependency graph. No credentials, developer caches, Docker socket, +host display, or user home should be exposed to a game worker. + +The concrete user checkout path reveals machine storage layout and is not +needed by CI. Public notes preserve its role with the placeholder above; no +publication confirmation is needed for that redacted form. + +## [x] 1. Establish an isolated experiment and runtime availability + +**Completion notes:** Created a worktree on `ci/1.19.2-container-puppet` from +`707f53f4a`. GitHub authentication works outside the sandbox. Docker was not +found via PATH, standard install directories, indexed file search, or installed +application records. WSL reports uninstalled. Podman CLI 6.0.2 is installed but +has no machine or connection; its server connection fails. Therefore use +GitHub-hosted Linux Docker for the experiment without requiring a host reboot +or changing OS virtualization configuration. + +**Later local update (2026-09-23):** Podman was installed and its WSL machine +started. A locally built image completed the restricted game/puppet smoke in +Task 3. The original runtime survey above is retained as historical context, +not the current machine state. + +**Validation:** `git status --short --branch`, `git worktree list`, `gh auth +status`, `gh workflow list`, `wsl --status`, `podman version`, `podman machine +list`, and `podman system connection list`. + +## [x] 2. Build and deliver a mod artifact from the feature branch + +**Portability repair checkpoint:** The earlier native Linux and Windows runs compiled all +SFM Java source sets. Linux then exposed Windows-only paths in ten test classes; +Windows exposed CRLF conversion of canonical replay JSON. Test fixtures now use +native absolute paths/URIs, and the JSON fixtures explicitly use LF. The one +native Windows case-insensitive containment test is scoped to Windows. No +production path validation or assertion was weakened. + +The Vox Java fix is published at +`4a079ac1c8a8bb8a914811ef55945bc1d9a9fef3` on +`TeamDman/facet` / `teamy/vox-java-late-credit` ([PR #2](https://github.com/TeamDman/facet/pull/2)). +The user-suggested `teamy-main` belongs to the older Roam repository and does not +contain this Java runtime. The new branch starts at SFM's exact previous pin and +leaves `teamy/terminal-selection-paste` unchanged. The canonical locked +`vox-xtask package-java` recipe passed with the historical Java compiler, full +Java suite, 19 deterministic regressions, repeat-JAR equality and dependency +checks. The resulting artifact hash is +`blake3:2be34a7d38bbd4a455d2a933c856c9462630f47a`. +Only the Vox artifact hash, derived expected hash, source commit, branch and +portable source-root reference changed in SFM's lock. Rust pins and all other +dependencies remain unchanged. The final hosted results below verify this pin. + +**Hosted acceptance update:** [PR #617](https://github.com/TeamDman/SuperFactoryManager/pull/617) +tests source `d7e22e73e` as merge revision +`77e42edb0469ae9ca71b19b5b677c12cf245e79e`. +[Linux run 35481820090](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35481820090) +passed all three jobs. Native JUnit reports 2,075 passed, zero failed, one +Windows-only skip and six existing opt-in tests aborted by missing helper/fixture +prerequisites. The mod JAR is 8,920,959 bytes, SHA-256 +`cb1ec405414992f3f37731dd3b8ddb9fcb142dc8aa96007d991b436208d14e5d`. +Its embedded Vox JAR independently hashes to the approved new value above. +[Windows run 35481820091](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35481820091) +also passed, reporting 2,076 passed, zero failed/skipped and the same six opt-in +aborts. Its mod JAR is 8,921,315 bytes, SHA-256 +`7b576c99b416ba1f40e63a70de116bf15f07ccd6a5768e2628a9824bf4a21491`. +Both archives contain the required mod/mixin/refmap/jarjar entries and identical +Vox JAR bytes. The complete mod archives are not byte-identical across platforms; +this experiment does not claim cross-platform mod archive reproducibility. +Windows completed in 33m17s; Linux native build completed in 14m28s. + +**Completion notes:** Initial experiment commit `f7dc28338` pushed successfully. +GitHub started [run 35460447959](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35460447959) +from the feature branch without a default-branch merge. The workflow passed +actionlint 1.7.12. This proves the branch trigger, not yet the build. Its Linux CLI +compiled successfully; Java acquisition then failed inside pinned Vox's +`VoxRuntimeTest.generatedChannelRoundTripHonorsCreditAndCancellation` with +`lane is not open: CLOSED`. The initial Docker build independently stopped when +`javac` treated UTF-8 Phon test sources as US-ASCII. Neither failure was bypassed. +Commit `bd6aff529` adds UTF-8 locale after `10967aefc` introduced a checksum-pinned +JBR 17.0.6 build compiler and independent puppet JVMs. Current Java 17 remains +the explicit mod compiler/game runtime. [Run 35461041230](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35461041230) +tests these fixes; the intervening superseded run was cancelled by concurrency. + +That second run finished: graphics and the Linux path tests passed. The UTF-8 +container failure was resolved, but both the native and container source builds +failed in the same pinned Vox Java test. The historical compiler alone does not +resolve it; artifact hash reproducibility has not yet been reached. No mod JAR +was produced. An independent [Windows run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35461257226) +uses the same source and explicit separation between the historical dependency +compiler and current Java 17 mod runtime. Its dedicated branch avoids cancelling +other experiments when the diagnostic changes. + +The Windows run finished with a distinct infrastructure failure. The CLI and all +five path tests passed. Vox's canonical recipe passed its Java suite, duplicate +JAR byte comparison and Java smoke test, then rejected the startup banner emitted +by global `JAVA_TOOL_OPTIONS` as an unresolved dependency. Its `jdeps` check +requires empty stdout and stderr. The fix scopes UTF-8 to `JDK_JAVAC_OPTIONS` +instead; the container's JVM tuning must likewise avoid affecting `jdeps` during +preparation. These environment fixes do not change the dependency graph. A fresh +build attempt will validate both fixes and the next actual layer. + +The [focused Linux diagnostic](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35461627486) +compiled the unchanged 181 pinned Java source files and ran the original test +once. JVM exception logging captured `message for unknown channel 1:1` in +`VoxConnection.processInboundChannel` before connection shutdown. The first +40-item transfer is affected. Late receiver credit is a hypothesis supported by +the transfer roles and deterministic driver probe; the original trace does not +record the message body. A review-only patch and probe are being tested in +disposable checkouts through a separate workflow. They are not SFM build inputs. + +The [candidate comparison](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35462115154) +at `e8318d2f0` passed on Linux: the candidate ran the unchanged original test once +and all 19 deterministic variants passed. The unchanged baseline also passed +once in this comparison, so the original source-test failure is timing-sensitive, +not guaranteed on every run. Local reduced sequences distinguish active sender +credit (passes both) from identical credit after sender Close (unknown-channel +failure in the baseline, passes with the candidate). The candidate follows the +pinned Rust driver's absent-credit behavior while preserving Java's accepted-lane, +message-direction and numeric validation. No source tests are retried to obtain +a green result, and no candidate JAR is supplied to SFM. + +The [reduced Linux run](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35462297128) +at `06e8fe9ce` compiled untouched pinned source and the probe in a fresh directory. +It ran only the closest passing and failing inputs: active credit exited 0; +identical credit after local Close exited 1 with the exact unknown-channel error +from `processInboundChannel`. Its receipt is `baseline-failure-reproduced`, with +zero full original-test runs. This positive diagnostic result records a confirmed +bug reproduction; it is not a passing dependency build. + +The user subsequently authorized publishing the Vox Java fix and updating SFM to +that build. The mutable boundary is limited to Vox Java source/test changes in +`TeamDman/facet` and its source commit, branch, cache identity and exact artifact +hash/derived checks in `platform/minecraft/sfm-toolchain.lock.json`. Other project +dependencies and Cargo declarations/locks remain frozen. The user suggested +`teamy-main`; inspection found that branch in the older `TeamDman/roam` repository, +which has no Java runtime. SFM actually pins Facet's +`teamy/terminal-selection-paste` at `f2afdece6`. A separate +`teamy/vox-java-late-credit` worktree starts at that exact published commit so +the fix cannot absorb or overwrite unrelated newer work. + +The next hosted runs at `859b6cbf4` passed dependency preparation. Windows compiled +SFM, then two canonical replay JSON tests failed because checkout converted their +LF fixture to CRLF. A narrow `.gitattributes` rule preserves the canonical bytes. +Docker compiled and packaged the mod JAR and executed both real graphical +puppets during preparation. Its final verifier looked in the wrapper console +instead of the authoritative child-process log. The raw completion marker must +be checked in that child log, with fresh copies per puppet. Offline restricted +execution is still a separate, pending check. + +**Current-head acceptance (2026-09-23):** Five subsequent commits added a local +Podman smoke path, tightened CI worker termination and JUnit stall diagnostics, +and fixed client-gate/explorer completion races. At `5dbbaeae9`, [Linux PR +run 35910210143](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35910210143) +and [Linux push run 35910203076](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35910203076) +each passed native build/test/package, graphics isolation, and the full offline +game. Their JUnit reports each show 2,081 passed, zero failed, one skipped and +six optional aborts. [Windows PR run 35910210189](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35910210189) +and [Windows push run 35910203095](https://github.com/TeamDman/SuperFactoryManager/actions/runs/35910203095) +each passed build/test/package with 2,082 passed, zero failed, zero skipped and +six optional aborts. Both full-game jobs uploaded 11 verified PNGs. All eight +checks on [draft PR #617](https://github.com/TeamDman/SuperFactoryManager/pull/617) +are green; the PR remains open and unmerged. [Facet draft PR +#2](https://github.com/TeamDman/facet/pull/2) likewise remains open and unmerged. + +**Acceptance complete:** Both operating systems independently rebuilt the exact +published Vox pin, passed canonical JUnit and packaged the mod. No source-test +bypass, arbitrary cached JAR or hash relaxation was used. + +**Work:** Add a push/PR workflow with least permissions, explicit 1.19.2 scope, +fresh-checkout tooling, bounded jobs, preserved failure diagnostics, and mod +artifacts. Confirm non-default branch behavior with a real run. + +**Validation:** Push the experimental branch; inspect Actions job results, +canonical compile/JUnit/JAR output, and artifact contents. Use a local branch +at the exact event commit so CLI worktree selection also works for PR checkouts. + +**Completion criteria:** A recorded remote commit/run verifies current source +and produces the mod JAR, or a reproducible upstream blocker is precisely +recorded without claiming a passing build. + +## [x] 3. Run a graphical puppet inside a restricted Docker worker + +**Accepted result:** Run `35481820090` built the prepared image in 19m12s, then +completed both fresh offline clients in 3m42s inside the container. Raw JVM logs +contain one successful completion per puppet and the passed +`move_1_stack_direct` GameTest. All 11 PNGs satisfy the strict manifest verifier +and were separately decoded during artifact inspection. The source receipt is +the exact tested merge revision above. Docker inspection confirms UID 10001, +network `none`, no bind mounts, only the anonymous `/workspace` volume, read-only +root, dropped `ALL` capabilities, no-new-privileges, 8 GiB memory/swap cap, +four CPUs and 512 PIDs. In-container assertions confirm seccomp filtering; +the process exited 0 without an OOM kill. Cleanup removes the worker and volume. + +The first orbit image has incomplete geometry; later views show the full SFM +fixture. This is an observed capture-readiness limitation for the future bot, +not evidence of a production screenshot-quality guarantee. An external Vox +control smoke test and the Discord broker remain future integration work. + +**Local Podman acceptance (2026-09-23):** After the user's Podman installation, +the local smoke built an image from source `01b239124` and ran both puppets in +separate restricted containers. The saved receipt at +`build/podman-smoke-20260923-02/verification.json` reports success and three +title plus eight orbit PNGs. Raw game logs show +`SFM_GAME_PUPPET_COMPLETE failed=0 total=1` for each puppet; the orbit log also +shows `move_1_stack_direct passed! (910ms)`. The 11 PNGs exist on disk, and a +later orbit frame visibly contains the full fixture. `glxinfo.txt` reports +Mesa llvmpipe OpenGL 4.5. `podman-inspect.json` reports exit 0, no OOM kill, +network `none`, read-only root, UID 10001, 8 GiB memory, four CPUs, 512 PIDs +and no-new-privileges; `isolation.txt` confirms no capabilities and seccomp +filtering. The disposable containers and volumes were removed after the run. +This proves local Podman can run the game and produce puppet screenshots at +that recorded source revision; the later head is validated by hosted Docker, +not by this local receipt. + +**Local networking check:** A disposable Podman container on its default +network fetched `https://example.com` with HTTP 200. With `--network none`, DNS +resolution timed out and a direct-IP connection to `1.1.1.1:80` failed +immediately. These are observed live probes, not a separate saved artifact; +the game worker's saved inspection independently records `NetworkMode:none`. + +**Earlier checkpoint:** Run `35462847488` built the distributable mod and ran +both real puppets during Docker image preparation, generating three title and +eight world captures. The final check failed because it read the CLI progress +log instead of the raw JVM console. `run.sh` now preserves the fresh per-puppet +JVM log; the verifier requires exactly one successful completion and exactly +three title/eight world captures, and rejects failure markers. Eleven focused +verifier regressions pass, including misleading wrapper output, duplicate +completion, extra/duplicate captures, missing images and failing process exits. +The host wrapper refuses nonempty artifact destinations without deleting their +contents; a stub-Docker regression checks both rejection and fresh destinations. +These evidence tests run in the workflow alongside the graphics probe. +The accepted final run repeated both puppets after disabling networking and +applying all runtime restrictions; preparation screenshots alone were insufficient. + +**Completion notes:** `containers/sfm/` contains a two-stage image, independent +graphics probe, offline runtime wrapper and screenshot verifier. Static Bash +syntax checks pass. The independent graphics job passed in run `35460447959`: +Mesa 22.3.6 reports llvmpipe (LLVM 15.0.6), OpenGL core 4.5, and no hardware +acceleration. Container inspection confirms exit 0, UID 10001, network `none`, +read-only root, dropped `ALL` capabilities, no-new-privileges, no mounts, 1 GiB +memory and 128 PIDs. The in-container assertions also verified seccomp filtering. +The canonical path decoder needed a portability fix: +`JsonPath` now accepts Windows separators on Unix and writes forward slashes, +without changing the lockfile. Five regression tests cover cached artifacts, +source-build outputs and optional paths; all five passed via `cargo test --locked +json_path::tests --lib`. The title and orbit fixtures now run in separate fresh +clients because discovery sorts puppets alphabetically and the title capture +requires the initial loading overlay. The second run passed the graphics +restrictions again but stopped at Vox source preparation. The third run advanced +through both real game launches as recorded in the current checkpoint above. + +Required `check-all.ps1` results: dependency policy, formatting, all-feature +Clippy with denied warnings, and build pass. Outside the sandbox, 739 unit tests +pass with four ignored, nine Java integration siblings pass, and the release +review integration suites pass 12 and 40 tests. The Java analysis snapshot suite +fails because the selected JDK source content/hash differs from its recorded JDK +fixtures (for example, `String.java` has 4660 lines instead of 4656). An installed +JBR 17.0.6 matches the expected complete source archive and all three checked +source files, but the suite explicitly selects branch `1.19.2` and reads that +busy checkout's saved JBR 17.0.14 plan. Its provider ignores `JAVA_HOME` and offers +no task-local override. The busy plan and snapshots were not changed. An initial +sandbox-only inability to launch `rg` was resolved +by the normal-user rerun. Doc tests report zero cases. + +**Work:** Build the canonical Linux tool and prewarm pinned game inputs. +Run the client using Xvfb and software OpenGL. Use a non-root worker with +capabilities removed, no host bind mounts, no network at execution time, +bounded memory/CPU/PIDs/time, and disposable writable state. Copy only selected +evidence out after execution. + +**Validation:** Require renderer diagnostics, puppet success evidence and PNG +output from the actual game. Verify runtime settings with container inspection. + +**Completion criteria:** A real container run proves game/puppet/screenshot +operation under the stated restrictions, or records the first actual failing +layer without substituting a desktop-only test. + +## [x] 4. Review isolation and provide reproducible handoff + +**Completion notes:** The container guide records exact commands, measured +results, runtime restrictions and production limitations. Read-only reviews +found and fixed stale host artifact merging, extra/duplicate screenshot entries +and the diagnostic's accidental dependence on the newly updated SFM pin. The +recorded original diagnostic remains independently reproducible. [SFM PR #617](https://github.com/TeamDman/SuperFactoryManager/pull/617) +and [Facet PR #2](https://github.com/TeamDman/facet/pull/2) are drafts for review; +neither was merged into a busy integration branch. Discord and Kubernetes were +not deployed. Future worker work includes external Vox-control verification, +capture readiness, broker/input/output boundaries, storage quotas and stronger +sandboxing for arbitrary executable inputs. + +The 2026-09-23 handoff distinguishes a completed offline screenshot fixture +from a live control session. The existing CLI discovers per-instance descriptors +under its local application-data directory and authenticates to the game's +random loopback control port. A companion CLI must share the game's network +namespace and descriptor directory; a published host port cannot reach that +loopback listener by itself. The current smoke wrapper launches puppets +synchronously and exits, so it does not yet provide a bounded readiness window +for an external control command. + +**Work:** Document exact tested commands, evidence and limitations. Describe a +Discord broker/job boundary and Kubernetes translation, with ephemeral jobs, +resource limits, private loopback puppet control, separate bot credentials, +restricted security context and enforced network policy. Distinguish ordinary +container isolation from a hostile-code sandbox. + +**Validation:** Review workflow/container diffs against the observed results; +check no project dependencies or busy-checkout files changed; verify tools and +process state. Do not publish release artifacts or deploy Discord/Kubernetes. + +**Completion criteria:** A fresh operator can repeat the verified experiment +and identify the remaining production decisions. + +## [ ] 5. Exercise authenticated control inside one disposable game worker + +**Scope:** One local Podman control smoke using the existing `sfm` CLI and one +trusted offline game image. This is the next bounded container/help-bot slice, +not Discord deployment. Use a single worker and keep the CLI in that worker's +network namespace with access to the same ephemeral instance-descriptor +directory. Preserve `--network none`, nonroot execution, read-only root, +resource/time bounds and no host credentials or game-control port publication. + +**Work:** First establish a deterministic point where the game is live and the +control descriptor has appeared; the current synchronous puppet wrapper may +need a narrow readiness barrier or test-only live window. Then invoke `sfm +instance list` and one allowlisted client action through the authenticated +loopback channel while the game runs. Wait for a visually ready capture rather +than treating the first orbit frame as a finished result. Return only a bounded +redacted receipt and selected screenshot; remove the container, writable state +and descriptor on exit or timeout. Do not broaden the control API unless this +smoke demonstrates a specific gap. + +**Validation:** First repeat the existing baseline from Git Bash with +`SFM_CONTAINER_ENGINE=podman bash containers/sfm/smoke.sh sfm-ci:local +` after +building the current image per `containers/sfm/README.md`. For the new smoke, +require one live descriptor and responsive authenticated discovery, a successful +allowlisted control result, a nonempty late/ready screenshot, and inspection of +the same isolation settings. Verify a missing/invalid descriptor or token fails +without exposing credentials in logs. Once the local version passes, run it in +hosted Docker CI at an exact source revision; record both receipts separately. + +**Completion criteria:** A reproducible command and evidence show that the +external CLI can control a running offline game and select a ready capture +inside the disposable worker. A precise first failing layer is acceptable +evidence if the existing game or CLI lifecycle needs a separate API change. + +## Risks and acceptance boundaries + +| Risk | Guardrail | +| --- | --- | +| Machine-local dependency hides a CI failure | Fresh Linux checkout and pinned acquisition; no local artifact fallback. | +| Launch exit code hides puppet failure | Assert result JSON and nonempty screenshot evidence. | +| Game can execute terminal commands | No broker credentials or host access; finite disposable worker; stronger VM boundary for hostile workloads. | +| Cold build exhausts runner or time | Stage caches and record per-layer diagnostics with bounded jobs. | +| Branch workflow or credential permissions prevent remote execution | Record exact GitHub error; complete concrete local files before requesting any required account action. | +| A green explicit-Java CI run is mistaken for canonical JBR pin validation | Keep the Java-selection paths distinct; reconcile the canonical lock at authorized integration and test its no-override path there. | +| The live control CLI cannot see the game's loopback port or descriptor | Run it in the same worker network namespace and ephemeral descriptor directory; prove authenticated discovery before any bot wiring. | +| The first captured frame is visually incomplete | Check capture readiness and select a later verified frame before returning help output. | + +## Operational readiness + +- Target: `ci/1.19.2-container-puppet`, base `707f53f4a`; current verified + implementation head `5dbbaeae9` (before this plan update). +- Tooling source changes: portable serialized-path conversion in `jar_build/json_path.rs`. +- Installer: `platform/cli/sfm-propagate-changes/install.ps1` completed successfully with locked offline acquisition. Installed command reports `10967aefc`; SHA-256 `95095EB678494595B6B40C7E37A1B155F2AB17EA713931235A111035ED82D5EF`. Its source subtree `d3785ff480719c67f1574efa5bfede644e653d93` is identical at `d7e22e73e`. User install required: no. CI builds its own executable from each event revision. +- Dependency posture: mutable only for the explicitly authorized Vox Java update + described above; all other project dependencies remain frozen. +- New developer/reference clones: none. +- Process preflight: the local Podman smoke launched two containerized clients + and completed cleanup. The next control smoke will launch one disposable + containerized game worker. +- Tool freshness was rechecked after diagnostic commit `e8318d2f0`: the installed + version/hash and current Rust subtree still match the values above. +- Dependency declarations and lockfiles: only the five Vox source/hash fields + described in Task 2 changed. The canonical source build generated the new JAR; + the busy checkout's artifact cache was not overwritten. +- Original checkout: clean at `707f53f4a` during the initial survey, now busy + with separate canonical work including a validated JBRSDK17 pin; no files + from it were copied into this isolated branch. +- Cache rehydration: hosted runners acquired checked-in locked dependencies; + diagnostics materialized only the exact pinned Facet commit in disposable + source directories. Their candidate source is never a mod-build input. +- Process state: local Minecraft ran only inside disposable Podman containers. + The containers and anonymous volumes were removed after the smoke; a later + `podman ps -a` showed no retained containers. Hosted jobs also finished and + cleaned up their workers. +- Exact manual graphics check: from the worktree root on a Linux Docker host, + use the two commands under `containers/sfm/README.md` / "Run the independent + graphics probe". Expect `GRAPHICS_PROBE_PASSED renderer=llvmpipe`. +- Exact full fixture commands: `docker build --build-arg + SFM_SOURCE_REVISION="$(git rev-parse HEAD)" -f containers/sfm/Dockerfile + -t sfm-ci:local .`, then `bash containers/sfm/smoke.sh sfm-ci:local + build/container-smoke`. Use a fresh artifact directory on each run. Expect + `passed: true`, three title captures, eight orbit captures, two successful + raw JVM completion markers, and the stated restrictions in Docker inspection. +- Exact local Podman fixture commands from Git Bash: `podman build --ignorefile + containers/sfm/Dockerfile.dockerignore --build-arg + SFM_SOURCE_REVISION="$(git rev-parse HEAD)" -f containers/sfm/Dockerfile + -t sfm-ci:local .`, then `SFM_CONTAINER_ENGINE=podman bash + containers/sfm/smoke.sh sfm-ci:local build/container-smoke-podman` with a + fresh destination. This uses the explicit empty `notmpcopyup` temporary + mounts documented in `containers/sfm/README.md`. +- Runtime scope: GitHub-hosted Linux Docker and local Podman both ran the game + and captured screenshots. The local receipt covers `01b239124`; the hosted + green checks cover `5dbbaeae9`. Authenticated external control, Discord and + Kubernetes remain untested integration work, not deployed services. diff --git a/platform/cli/sfm-propagate-changes/src/jar_build/json_path.rs b/platform/cli/sfm-propagate-changes/src/jar_build/json_path.rs index 71b5a98ae..b81e80129 100644 --- a/platform/cli/sfm-propagate-changes/src/jar_build/json_path.rs +++ b/platform/cli/sfm-propagate-changes/src/jar_build/json_path.rs @@ -9,7 +9,8 @@ impl TryFrom for PathBuf { type Error = String; fn try_from(value: JsonPath) -> Result { - Ok(PathBuf::from(value.0)) + // Lockfiles written on Windows must still resolve as path components on Unix. + Ok(PathBuf::from(value.0.replace('\\', "/"))) } } @@ -19,7 +20,7 @@ impl TryFrom<&PathBuf> for JsonPath { fn try_from(value: &PathBuf) -> Result { value .to_str() - .map(|path| JsonPath(path.to_string())) + .map(|path| JsonPath(path.replace('\\', "/"))) .ok_or_else(|| format!("Path is not valid Unicode: {}", value.display())) } } @@ -32,7 +33,10 @@ impl TryFrom for Option { type Error = String; fn try_from(value: JsonOptionalPath) -> Result { - Ok(value.0.map(PathBuf::from)) + value + .0 + .map(|path| PathBuf::try_from(JsonPath(path))) + .transpose() } } @@ -47,3 +51,62 @@ impl TryFrom<&Option> for JsonOptionalPath { .map(|path| JsonOptionalPath(path.map(|path| path.0))) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn windows_cache_path_resolves_beneath_portable_cache_root() { + let input = + r#""$sfm-cache\\maven\\org\\facet\\vox-java\\0.10.0-rc.5\\vox-java-0.10.0-rc.5.jar""#; + let encoded: JsonPath = facet_json::from_str(input).unwrap(); + let path = PathBuf::try_from(encoded).unwrap(); + let relative = path.strip_prefix("$sfm-cache").unwrap(); + assert_eq!( + Path::new("cache").join(relative), + Path::new("cache/maven/org/facet/vox-java/0.10.0-rc.5/vox-java-0.10.0-rc.5.jar") + ); + } + + #[test] + fn source_build_output_resolves_under_checkout_with_either_separator() { + for input in [ + r"vox\java\target\vox-java-0.10.0-rc.5.jar", + "vox/java/target/vox-java-0.10.0-rc.5.jar", + ] { + let path = PathBuf::try_from(JsonPath(input.to_string())).unwrap(); + assert_eq!( + Path::new("checkout").join(path), + Path::new("checkout/vox/java/target/vox-java-0.10.0-rc.5.jar") + ); + } + } + + #[test] + fn serialized_paths_use_forward_slashes() { + let path = PathBuf::from(r"$sfm-cache\maven\example.jar"); + let encoded = JsonPath::try_from(&path).unwrap(); + assert_eq!(encoded.0, "$sfm-cache/maven/example.jar"); + } + + #[test] + fn optional_paths_share_portable_path_handling() { + let encoded = JsonOptionalPath(Some(r"run\screenshots\capture.png".to_string())); + let path = Option::::try_from(encoded).unwrap(); + assert_eq!(path, Some(PathBuf::from("run/screenshots/capture.png"))); + let encoded = + JsonOptionalPath::try_from(&Some(PathBuf::from(r"run\screenshots\capture.png"))) + .unwrap(); + assert_eq!(encoded.0.as_deref(), Some("run/screenshots/capture.png")); + } + + #[test] + fn absent_optional_path_round_trips() { + let encoded: JsonOptionalPath = facet_json::from_str("null").unwrap(); + let path = Option::::try_from(encoded).unwrap(); + assert!(path.is_none()); + assert!(JsonOptionalPath::try_from(&path).unwrap().0.is_none()); + } +} diff --git a/platform/minecraft/sfm-toolchain.lock.json b/platform/minecraft/sfm-toolchain.lock.json index a69a5fe00..4a30ab349 100644 --- a/platform/minecraft/sfm-toolchain.lock.json +++ b/platform/minecraft/sfm-toolchain.lock.json @@ -1961,7 +1961,7 @@ "derived_checks": { "artifact_id": "org-facet-vox-java-0-10-0-rc-5-ff6894ba", "resolved_coordinate": "org.facet:vox-java:0.10.0-rc.5", - "expected_hash": "blake3:4d1e88353f941be926fdf84f1dd8da9bd594b60f", + "expected_hash": "blake3:2be34a7d38bbd4a455d2a933c856c9462630f47a", "cache_path": "$sfm-cache\\maven\\org\\facet\\vox-java\\0.10.0-rc.5\\vox-java-0.10.0-rc.5.jar" }, "source_providers": [] @@ -3469,13 +3469,13 @@ "coordinate": "org.facet:vox-java:0.10.0-rc.5", "repository_id": null, "url": null, - "hash": "blake3:4d1e88353f941be926fdf84f1dd8da9bd594b60f", + "hash": "blake3:2be34a7d38bbd4a455d2a933c856c9462630f47a", "cache_path": "$sfm-cache\\maven\\org\\facet\\vox-java\\0.10.0-rc.5\\vox-java-0.10.0-rc.5.jar", "provenance": "source-build", "source_git": { - "root": "$sfm-cache\\source-builds\\facet-f2afdece6c79e64085d2f8c047e22fe16b2c8c54", - "commit": "f2afdece6c79e64085d2f8c047e22fe16b2c8c54", - "branch": "teamy/terminal-selection-paste", + "root": "$sfm-cache\\source-builds\\facet-4a079ac1c8a8bb8a914811ef55945bc1d9a9fef3", + "commit": "4a079ac1c8a8bb8a914811ef55945bc1d9a9fef3", + "branch": "teamy/vox-java-late-credit", "dirty": false, "remote_url": "https://github.com/TeamDman/facet" }, diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerLoader.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerLoader.java index 265c941d6..866dd9bf1 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerLoader.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerLoader.java @@ -136,6 +136,9 @@ public final class LoadHandle { private final RequestEvidence evidence; private final SFMExplorerCancellationToken cancellation; private final CompletableFuture completion; + // Guarded by this handle. Claim the result before releasing the monitor, + // but notify callbacks outside it: callbacks may acquire a session lock. + private boolean completionClaimed; private LoadHandle( SFMChildRelationRepository.RefreshTicket ticket, @@ -157,18 +160,25 @@ public CompletableFuture completion() { return completion; } - public synchronized boolean cancel() { - if (completion.isDone()) return false; - boolean tokenChanged = cancellation.cancel(); - boolean relationChanged = relations.cancel(ticket); - completion.complete(new LoadResult( - evidence, - LoadDisposition.CANCELLED, - relations.snapshot(), - Optional.empty(), - Optional.of("request cancelled") - )); - return tokenChanged || relationChanged; + public boolean cancel() { + LoadResult result; + boolean changed; + synchronized (this) { + if (completionClaimed || completion.isDone()) return false; + boolean tokenChanged = cancellation.cancel(); + boolean relationChanged = relations.cancel(ticket); + result = new LoadResult( + evidence, + LoadDisposition.CANCELLED, + relations.snapshot(), + Optional.empty(), + Optional.of("request cancelled") + ); + completionClaimed = true; + changed = tokenChanged || relationChanged; + } + completion.complete(result); + return changed; } } @@ -648,14 +658,13 @@ private LoadHandle start( resolverCompletion.completeExceptionally(failure); } resolverCompletion.whenComplete((page, failure) -> schedulePublication(handle, () -> { + LoadResult result; synchronized (handle) { - if (completion.isDone()) return; + if (handle.completionClaimed || completion.isDone()) return; Throwable cause = unwrap(failure); if (cause != null) { - completeFailure(handle, cause); - return; - } - try { + result = failureResultLocked(handle, cause); + } else try { handle.cancellation.throwIfCancelled(); validatePage(request, page, resolver); ArrayList edges = new ArrayList<>(); @@ -684,17 +693,19 @@ private LoadHandle start( entryGeneration++; } } - completion.complete(new LoadResult( + result = new LoadResult( evidence, disposition, published.snapshot(), Optional.of(page), Optional.empty() - )); + ); } catch (Throwable validationFailure) { - completeFailure(handle, validationFailure); + result = failureResultLocked(handle, validationFailure); } + handle.completionClaimed = true; } + completion.complete(result); })); return handle; } @@ -711,46 +722,52 @@ private void schedulePublication(LoadHandle handle, Runnable publication) { } private void completeFailure(LoadHandle handle, Throwable failure) { + LoadResult result; synchronized (handle) { - if (handle.completion.isDone()) return; - Throwable cause = unwrap(failure); - if (cause instanceof CancellationException || handle.cancellation.isCancelled()) { - relations.cancel(handle.ticket); - handle.completion.complete(new LoadResult( - handle.evidence, - LoadDisposition.CANCELLED, - relations.snapshot(), - Optional.empty(), - Optional.of("request cancelled") - )); - return; - } - if (cause instanceof SFMExplorerResolver.StaleGenerationException) { - relations.cancel(handle.ticket); - handle.completion.complete(new LoadResult( - handle.evidence, - LoadDisposition.STALE, - relations.snapshot(), - Optional.empty(), - Optional.of(cause.getMessage()) - )); - return; - } - String diagnostic = cause == null - ? "resolver failed without an exception" - : cause.getClass().getSimpleName() + ": " + String.valueOf(cause.getMessage()); - SFMChildRelationRepository.PublishResult failed = relations.fail(handle.ticket, diagnostic); - LoadDisposition disposition = failed.disposition() == SFMChildRelationRepository.PublishDisposition.STALE - ? LoadDisposition.STALE - : LoadDisposition.FAILED; - handle.completion.complete(new LoadResult( + if (handle.completionClaimed || handle.completion.isDone()) return; + result = failureResultLocked(handle, failure); + handle.completionClaimed = true; + } + handle.completion.complete(result); + } + + /** Selects the terminal relation state while the caller holds the handle monitor. */ + private LoadResult failureResultLocked(LoadHandle handle, Throwable failure) { + Throwable cause = unwrap(failure); + if (cause instanceof CancellationException || handle.cancellation.isCancelled()) { + relations.cancel(handle.ticket); + return new LoadResult( handle.evidence, - disposition, - failed.snapshot(), + LoadDisposition.CANCELLED, + relations.snapshot(), Optional.empty(), - Optional.of(diagnostic) - )); + Optional.of("request cancelled") + ); } + if (cause instanceof SFMExplorerResolver.StaleGenerationException) { + relations.cancel(handle.ticket); + return new LoadResult( + handle.evidence, + LoadDisposition.STALE, + relations.snapshot(), + Optional.empty(), + Optional.of(cause.getMessage()) + ); + } + String diagnostic = cause == null + ? "resolver failed without an exception" + : cause.getClass().getSimpleName() + ": " + String.valueOf(cause.getMessage()); + SFMChildRelationRepository.PublishResult failed = relations.fail(handle.ticket, diagnostic); + LoadDisposition disposition = failed.disposition() == SFMChildRelationRepository.PublishDisposition.STALE + ? LoadDisposition.STALE + : LoadDisposition.FAILED; + return new LoadResult( + handle.evidence, + disposition, + failed.snapshot(), + Optional.empty(), + Optional.of(diagnostic) + ); } private void publishFilterDomain( diff --git a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/review/release_review/SFMReleaseReviewSurfaceRuntime.java b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/review/release_review/SFMReleaseReviewSurfaceRuntime.java index 935ca2665..64547e721 100644 --- a/platform/minecraft/src/main/java/ca/teamdman/sfm/client/review/release_review/SFMReleaseReviewSurfaceRuntime.java +++ b/platform/minecraft/src/main/java/ca/teamdman/sfm/client/review/release_review/SFMReleaseReviewSurfaceRuntime.java @@ -234,8 +234,14 @@ public CompletableFuture generate( if (reviewGeneration <= 0) throw new IllegalArgumentException("Review generation must be positive"); cancellation.throwIfCancelled(); String cacheKey = cacheKey(recipe); - CompletableFuture existing = cache.get(cacheKey); - if (existing != null) { + CompletableFuture existing; + while ((existing = cache.get(cacheKey)) != null) { + // A document callback can finish before the failed future's eviction + // callback runs. Never reuse that already-failed cache entry. + if (existing.isCompletedExceptionally()) { + cache.remove(cacheKey, existing); + continue; + } cacheHits.incrementAndGet(); return existing; } @@ -355,8 +361,13 @@ public CompletableFuture generate( "review.surface.transport-failed", "Review-surface process invocation failed", failure)); } }, executor); - CompletableFuture raced = cache.putIfAbsent(cacheKey, created); - if (raced != null) { + while (true) { + CompletableFuture raced = cache.putIfAbsent(cacheKey, created); + if (raced == null) break; + if (raced.isCompletedExceptionally()) { + cache.remove(cacheKey, raced); + continue; + } created.cancel(false); cacheHits.incrementAndGet(); return raced; diff --git a/platform/minecraft/src/main/resources/assets/sfm/template_programs/changelog.sfml b/platform/minecraft/src/main/resources/assets/sfm/template_programs/changelog.sfml index 723889d2b..9fa347938 100644 --- a/platform/minecraft/src/main/resources/assets/sfm/template_programs/changelog.sfml +++ b/platform/minecraft/src/main/resources/assets/sfm/template_programs/changelog.sfml @@ -7,6 +7,9 @@ NAME "Changelog" -- https://github.com/TeamDman/SuperFactoryManager/issues/new ---- 4.35.0 PRE ---- +-- Prevent Explorer refresh completion from deadlocking with closing or cancelling a session +-- Retry failed release-review surface generation instead of reusing a failed cached result +-- Prevent late Vox channel credit from disconnecting otherwise healthy game-control and terminal connections -- Repair release-branch rendering, input and identifier adapters through 26.1.2, and keep client GUI registrations out of dedicated-server startup -- Add real Just Dire Things and Mekanism GameTests demonstrating one-FE-gap downstream starvation and label-order/retention controls without changing production energy transfers -- Double the AE2/Mekanism fixture's netherite scrap pattern input without increasing its declared output, allowing for partial batches stranded across factories diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/action/SFMReleaseReviewCreateRuntimeTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/action/SFMReleaseReviewCreateRuntimeTests.java index 1fa23290b..752cdeb9f 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/action/SFMReleaseReviewCreateRuntimeTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/action/SFMReleaseReviewCreateRuntimeTests.java @@ -63,7 +63,7 @@ void completeGitProducerCommandUsesExplicitPortableInputs(@TempDir Path reposito "toolchain-test.exe", "--output-format", "json", "review", "session", "create-ledger", - "--file", "docs\\reviews\\4.34.0-to-candidate.sfm-review.json", + "--file", Path.of("docs", "reviews", "4.34.0-to-candidate.sfm-review.json").toString(), "--branch", "1.19.2", "--before", "4.34.0-1.19.2", "--candidate", "HEAD", diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/control/SFMClientThreadGateTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/control/SFMClientThreadGateTests.java index 13955550e..2da3be130 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/control/SFMClientThreadGateTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/control/SFMClientThreadGateTests.java @@ -51,8 +51,8 @@ void workRunsExactlyOnceOnClientExecutorAndCompletesOnControlWorker() throws Exc clientThread.start(); clientThread.join(Duration.ofSeconds(5).toMillis()); - assertEquals(1, result.get(5, TimeUnit.SECONDS)); observed.get(5, TimeUnit.SECONDS); + assertEquals(1, result.get(5, TimeUnit.SECONDS)); assertEquals(1, calls.get()); assertEquals("test-minecraft-client", workThread.get()); assertEquals("test-control-worker", completionThread.get()); diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerCompletionLockTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerCompletionLockTests.java new file mode 100644 index 000000000..17102f008 --- /dev/null +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerCompletionLockTests.java @@ -0,0 +1,257 @@ +package ca.teamdman.sfm.client.explorer.lazy; + +import ca.teamdman.sfm.client.explorer.SFMChildRelationRepository; +import ca.teamdman.sfm.client.explorer.SFMExplorerId; +import ca.teamdman.sfm.client.explorer.SFMPath; +import ca.teamdman.sfm.client.explorer.SFMSelectionRepository; +import org.junit.jupiter.api.Test; + +import java.lang.management.ManagementFactory; +import java.lang.management.ThreadInfo; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +public class SFMLazyExplorerCompletionLockTests { + private static final SFMPath ROOT = SFMPath.parse("registry://test/completion"); + private static final SFMPath CHILD = SFMPath.parse("registry://test/completion/child"); + + @Test + public void publicationCompletesOutsideHandleAndWinsOverLaterCancellation() throws Exception { + ManualExecutor publication = new ManualExecutor(); + Fixture fixture = fixture(publication); + SFMLazyExplorerLoader.LoadHandle handle = fixture.loader().refresh(ROOT, 8); + CompletionProbe probe = new CompletionProbe(handle); + + fixture.resolver().pending.complete(page(ROOT)); + assertFalse(handle.completion().isDone()); + publication.runNext(); + + assertTerminal(fixture, handle, probe, SFMLazyExplorerLoader.LoadDisposition.PUBLISHED); + assertEquals(Set.of(CHILD), fixture.relations().snapshot().relation().childrenOf(ROOT)); + } + + @Test + public void cancellationCompletesOutsideHandleAndWinsOverQueuedPublication() throws Exception { + ManualExecutor publication = new ManualExecutor(); + Fixture fixture = fixture(publication); + SFMLazyExplorerLoader.LoadHandle handle = fixture.loader().refresh(ROOT, 8); + CompletionProbe probe = new CompletionProbe(handle); + fixture.resolver().pending.complete(page(ROOT)); + + assertTrue(handle.cancel()); + publication.runNext(); + + assertTerminal(fixture, handle, probe, SFMLazyExplorerLoader.LoadDisposition.CANCELLED); + assertTrue(fixture.resolver().request.cancellation().isCancelled()); + assertTrue(fixture.relations().snapshot().relation().edges().isEmpty()); + assertTrue(fixture.loader().entry(CHILD).isEmpty()); + } + + @Test + public void resolverFailureCompletesOutsideHandle() throws Exception { + Fixture fixture = fixture(Runnable::run); + SFMLazyExplorerLoader.LoadHandle handle = fixture.loader().refresh(ROOT, 8); + CompletionProbe probe = new CompletionProbe(handle); + + fixture.resolver().pending.completeExceptionally(new IllegalStateException("resolver fixture failed")); + + SFMLazyExplorerLoader.LoadResult result = assertTerminal( + fixture, handle, probe, SFMLazyExplorerLoader.LoadDisposition.FAILED); + assertTrue(result.diagnostic().orElseThrow().contains("resolver fixture failed")); + } + + @Test + public void pageValidationFailureCompletesOutsideHandle() throws Exception { + Fixture fixture = fixture(Runnable::run); + SFMLazyExplorerLoader.LoadHandle handle = fixture.loader().refresh(ROOT, 8); + CompletionProbe probe = new CompletionProbe(handle); + + fixture.resolver().pending.complete(page(SFMPath.parse("registry://test/wrong-parent"))); + + SFMLazyExplorerLoader.LoadResult result = assertTerminal( + fixture, handle, probe, SFMLazyExplorerLoader.LoadDisposition.FAILED); + assertTrue(result.diagnostic().orElseThrow().contains("parent does not match")); + assertTrue(fixture.relations().snapshot().relation().edges().isEmpty()); + } + + @Test + public void publicationExecutorRejectionCompletesOutsideHandle() throws Exception { + Fixture fixture = fixture(command -> { + throw new RejectedExecutionException("owner fixture stopped"); + }); + SFMLazyExplorerLoader.LoadHandle handle = fixture.loader().refresh(ROOT, 8); + CompletionProbe probe = new CompletionProbe(handle); + + fixture.resolver().pending.complete(page(ROOT)); + + SFMLazyExplorerLoader.LoadResult result = assertTerminal( + fixture, handle, probe, SFMLazyExplorerLoader.LoadDisposition.FAILED); + assertTrue(result.diagnostic().orElseThrow().contains("publication executor rejected work")); + assertTrue(fixture.relations().snapshot().relation().edges().isEmpty()); + } + + @Test + public void sessionCanCloseWhilePublicationCallbackWaitsForItsMonitor() throws Exception { + ManualExecutor publication = new ManualExecutor(); + Fixture fixture = fixture(publication); + SFMExplorerSession session = new SFMExplorerSession( + new SFMExplorerId("completion-lock"), ROOT, new SFMSelectionRepository()); + SFMLazyExplorerLoader.LoadHandle handle = session.requestChildren(ROOT, fixture.loader(), 8); + fixture.resolver().pending.complete(page(ROOT)); + FutureTask publish = new FutureTask<>(() -> { + publication.runNext(); + return null; + }); + FutureTask cancel = new FutureTask<>(handle::cancel); + Thread publisher = daemonThread("explorer-publication-lock-test", publish); + Thread canceller = daemonThread("explorer-cancellation-lock-test", cancel); + boolean cancellationFinished = false; + try { + synchronized (session) { + publisher.start(); + awaitBlockedOn(publisher, session); + canceller.start(); + try { + assertFalse(cancel.get(2, TimeUnit.SECONDS), "publication already won"); + cancellationFinished = true; + session.close(); + } catch (TimeoutException lockRegression) { + // Release the session before asserting: the old lock order can + // then unwind, rather than stranding a real deadlocked pair. + } + } + } finally { + publisher.join(2000); + canceller.join(2000); + } + assertFalse(publisher.isAlive(), "publication thread must finish after releasing the session"); + assertFalse(canceller.isAlive(), "cancellation thread must finish after releasing the session"); + publish.get(2, TimeUnit.SECONDS); + assertFalse(cancel.get(2, TimeUnit.SECONDS)); + session.close(); + assertTrue(cancellationFinished, "a callback waiting for the session must not retain the handle monitor"); + assertTrue(session.snapshot().closed()); + assertEquals(0, session.activeRequestCount()); + assertEquals(1, session.recentRequestEvidence().size(), "record the request exactly once"); + assertEquals(handle.evidence(), session.recentRequestEvidence().get(0).evidence()); + assertEquals(Optional.of(SFMLazyExplorerLoader.LoadDisposition.PUBLISHED), + session.recentRequestEvidence().get(0).disposition()); + assertEquals(Set.of(CHILD), fixture.relations().snapshot().relation().childrenOf(ROOT)); + } + + private static void awaitBlockedOn(Thread thread, Object monitor) throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadline) { + ThreadInfo info = ManagementFactory.getThreadMXBean().getThreadInfo(thread.getId()); + if (info != null && info.getThreadState() == Thread.State.BLOCKED + && info.getLockInfo() != null + && info.getLockInfo().getIdentityHashCode() == System.identityHashCode(monitor)) { + return; + } + Thread.sleep(5); + } + fail("publication callback did not contend for the held session monitor"); + } + + private static Thread daemonThread(String name, Runnable work) { + Thread thread = new Thread(work, name); + thread.setDaemon(true); + return thread; + } + + private static SFMLazyExplorerLoader.LoadResult assertTerminal( + Fixture fixture, + SFMLazyExplorerLoader.LoadHandle handle, + CompletionProbe probe, + SFMLazyExplorerLoader.LoadDisposition disposition + ) throws Exception { + SFMLazyExplorerLoader.LoadResult result = probe.observed.get(2, TimeUnit.SECONDS); + assertFalse(probe.heldHandle.get(), "completion callbacks must run outside the handle monitor"); + assertEquals(disposition, result.disposition()); + assertEquals(handle.evidence(), result.evidence()); + assertFalse(handle.cancel(), "a terminal request cannot change its winner"); + assertSame(result, handle.completion().get(2, TimeUnit.SECONDS)); + assertEquals(1, probe.calls.get()); + assertTrue(fixture.loader().activeParents().isEmpty()); + return result; + } + + private static Fixture fixture(Executor publication) { + DeferredResolver resolver = new DeferredResolver(); + SFMExplorerResolverRegistry registry = new SFMExplorerResolverRegistry(); + registry.register(resolver); + SFMChildRelationRepository relations = new SFMChildRelationRepository(); + return new Fixture(resolver, relations, new SFMLazyExplorerLoader(registry, relations, publication)); + } + + private static SFMExplorerResolver.ChildPage page(SFMPath parent) { + return new SFMExplorerResolver.ChildPage(parent, + List.of(SFMExplorerEntry.simple(CHILD, "child", false, Optional.of("fixture"))), + Optional.empty(), 7, List.of(), 1); + } + + private record Fixture(DeferredResolver resolver, SFMChildRelationRepository relations, + SFMLazyExplorerLoader loader) { + } + + private static final class CompletionProbe { + private final AtomicBoolean heldHandle = new AtomicBoolean(); + private final AtomicInteger calls = new AtomicInteger(); + private final CompletableFuture observed; + + private CompletionProbe(SFMLazyExplorerLoader.LoadHandle handle) { + observed = handle.completion().thenApply(result -> { + heldHandle.set(Thread.holdsLock(handle)); + calls.incrementAndGet(); + return result; + }); + } + } + + private static final class DeferredResolver implements SFMExplorerResolver { + private final CompletableFuture pending = new CompletableFuture<>(); + private ChildRequest request; + + @Override + public String scheme() { return "registry"; } + + @Override + public long generation() { return 7; } + + @Override + public CompletableFuture describe(SFMPath path, SFMExplorerCancellationToken cancellation) { + return CompletableFuture.completedFuture(SFMExplorerEntry.simple(path, "root", true, Optional.empty())); + } + + @Override + public CompletableFuture resolveChildren(ChildRequest request) { + this.request = request; + return pending; + } + } + + private static final class ManualExecutor implements Executor { + private final ArrayDeque work = new ArrayDeque<>(); + + @Override + public void execute(Runnable command) { work.add(command); } + + private void runNext() { work.removeFirst().run(); } + } +} diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/screen/explorer/SFMExplorerNavigationChoicesTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/screen/explorer/SFMExplorerNavigationChoicesTests.java index 644eaacd3..9bad8541e 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/screen/explorer/SFMExplorerNavigationChoicesTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/screen/explorer/SFMExplorerNavigationChoicesTests.java @@ -3,27 +3,30 @@ import ca.teamdman.sfm.client.explorer.*; import ca.teamdman.sfm.client.explorer.lazy.*; import org.junit.jupiter.api.Test; +import java.nio.file.Path; import java.util.*; import static org.junit.jupiter.api.Assertions.*; class SFMExplorerNavigationChoicesTests { + private static final Path FIXTURE_ROOT = Path.of("").toAbsolutePath().getRoot().resolve("fixture"); + @Test void nativeRootOffersDistinctReplaceAddAndRefresh() { - var root=SFMPath.parse("file:///C:/fixture/child"); + var root=SFMPath.fromNative(FIXTURE_ROOT.resolve("child")); var session=new SFMExplorerSession(new SFMExplorerId("navigation-test"),root,new SFMSelectionRepository()); var commands=SFMExplorerNavigationChoices.roots(session.snapshot()).stream().map(c->c.command()).toList(); assertEquals(3,commands.size()); assertTrue(commands.stream().anyMatch(c->c.equals("sfm action invoke sfm:explorer/refresh"))); - assertTrue(commands.stream().anyMatch(c->c.contains("sfm:explorer/root/parent/set file:///C:/fixture/child --expected-revision "))); - assertTrue(commands.stream().anyMatch(c->c.contains("sfm:explorer/root/add ") && c.endsWith(" file:///C:/fixture"))); - var directory=new SFMExplorerEntry(SFMPath.parse("file:///C:/fixture/child/nested"),"nested",true, + assertTrue(commands.stream().anyMatch(c->c.contains("sfm:explorer/root/parent/set " + root.canonical() + " --expected-revision "))); + assertTrue(commands.stream().anyMatch(c->c.contains("sfm:explorer/root/add ") && c.endsWith(" " + SFMPath.fromNative(FIXTURE_ROOT).canonical()))); + var directory=new SFMExplorerEntry(SFMPath.fromNative(FIXTURE_ROOT.resolve("child/nested")),"nested",true, Map.of(SFMExplorerEntry.SUBJECT_KIND,SFMExplorerEntry.SortKey.available("container"), SFMExplorerEntry.SORT_NAME,SFMExplorerEntry.SortKey.available("nested")),List.of("nested"),List.of()); assertTrue(SFMExplorerNavigationChoices.row(session.snapshot(),directory).stream() .anyMatch(c->c.command().contains("sfm:explorer/root/add ") && c.command().endsWith("/nested"))); } @Test void mountedFileCanReplaceOrJoinTheCurrentRootSetWithoutPretendingToBeADirectory() { - var root=SFMPath.parse("file:///C:/fixture"); - var mountedPath=SFMPath.parse("file:///C:/fixture/review.sfm-review.json"); + var root=SFMPath.fromNative(FIXTURE_ROOT); + var mountedPath=SFMPath.fromNative(FIXTURE_ROOT.resolve("review.sfm-review.json")); var session=new SFMExplorerSession(new SFMExplorerId("mounted-navigation-test"),root,new SFMSelectionRepository()); var mounted=new SFMExplorerEntry(mountedPath,"review.sfm-review.json",true, Map.of(SFMExplorerEntry.SUBJECT_KIND,SFMExplorerEntry.SortKey.available("file"), @@ -38,7 +41,7 @@ class SFMExplorerNavigationChoicesTests { && c.command().endsWith(mountedPath.canonical()))); } @Test void volumeAndContributedAuthoritiesDoNotInventParents() { - assertTrue(SFMExplorerNavigationChoices.parent(SFMPath.parse("file:///C:/")).isEmpty()); + assertTrue(SFMExplorerNavigationChoices.parent(SFMPath.fromNative(FIXTURE_ROOT.getRoot())).isEmpty()); assertTrue(SFMExplorerNavigationChoices.parent(SFMPath.parse("registry://minecraft/item/")).isEmpty()); assertTrue(SFMExplorerNavigationChoices.parent(SFMPath.parse("review://session/document")).isEmpty()); } diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMDefinitionContextAdapterTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMDefinitionContextAdapterTests.java index 9fb418b2c..ae4c076ac 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMDefinitionContextAdapterTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMDefinitionContextAdapterTests.java @@ -13,6 +13,8 @@ import ca.teamdman.sfm.client.text_editor.SFMTextDocumentSourceRootIdentity; import ca.teamdman.sfm.client.text_editor.SFMTextDocumentLanguage; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import java.nio.charset.StandardCharsets; import java.nio.file.Path; @@ -30,7 +32,7 @@ class SFMDefinitionContextAdapterTests { @Test void explicitPinnedAnalysisIdentitySuppressesOnlyTheAmbientDiskHash() { - Path repoRoot = Path.of("D:/workspace/sfm"); + Path repoRoot = fixturePath("workspace/sfm"); Path sourceRoot = repoRoot.resolve("src/main/java"); Path nativePath = sourceRoot.resolve("example/A.java"); SFMPath durableRoot = SFMPath.parse("review://release/revision/"); @@ -77,7 +79,7 @@ void explicitPinnedAnalysisIdentitySuppressesOnlyTheAmbientDiskHash() { @Test void dirtyUnicodeCrlfOverlayUsesDeepestAnalysisRootAndBothExactHashes() { - Path repoRoot = Path.of("D:/workspace/sfm"); + Path repoRoot = fixturePath("workspace/sfm"); Path sourceRoot = repoRoot.resolve("platform/minecraft/src/main/java"); Path file = sourceRoot.resolve("ca/teamdman/sfm/A.java"); String baselineText = "package ca.teamdman.sfm;\r\nclass A {}\r\n"; @@ -121,7 +123,7 @@ void dirtyUnicodeCrlfOverlayUsesDeepestAnalysisRootAndBothExactHashes() { @Test void sameLookingSourceRootsAreSelectedByFullCanonicalContainment() { - Path repoRoot = Path.of("D:/workspace/sfm"); + Path repoRoot = fixturePath("workspace/sfm"); Path first = repoRoot.resolve("one/src/main/java"); Path second = repoRoot.resolve("two/src/main/java"); Path file = second.resolve("example/A.java"); @@ -146,6 +148,7 @@ void sameLookingSourceRootsAreSelectedByFullCanonicalContainment() { } @Test + @EnabledOnOs(OS.WINDOWS) void windowsRootContainmentUsesNativeCaseInsensitivePathSemantics() { Path authorizedRoot = Path.of("D:/WORKSPACE/SFM"); Path sourceRoot = Path.of("D:/workspace/sfm/src/main/java"); @@ -173,7 +176,7 @@ void windowsRootContainmentUsesNativeCaseInsensitivePathSemantics() { @Test void resolverAuthorizedSubtreeComposesWithContainingWorkerSourceRoot() { - Path repoRoot = Path.of("D:/workspace/sfm"); + Path repoRoot = fixturePath("workspace/sfm"); Path sourceRoot = repoRoot.resolve("platform/minecraft/src/main/java"); Path authorizedPackage = sourceRoot.resolve("ca/teamdman/sfm"); Path file = authorizedPackage.resolve("OutputStatement.java"); @@ -207,7 +210,7 @@ void resolverAuthorizedSubtreeComposesWithContainingWorkerSourceRoot() { @Test void acquiredDependencySourceUsesItsNegotiatedRootPrefixAndContributedAddress() { - Path dependencyRoot = Path.of("D:/cache/dependency-sources/forge"); + Path dependencyRoot = fixturePath("cache/dependency-sources/forge"); Path file = dependencyRoot.resolve("net/minecraftforge/ForgeType.java"); String text = "package net.minecraftforge; class ForgeType {}\n"; SFMSymbolServerProtocol.DependencySourceRootMapping dependency = @@ -255,7 +258,7 @@ void acquiredDependencySourceUsesItsNegotiatedRootPrefixAndContributedAddress() @Test void retainedDependencyRootIdentityDisambiguatesOnePhysicalTreeWithMultipleSemanticRoots() { - Path sharedRoot = Path.of("D:/cache/forge/combined-deobfuscated.filetree"); + Path sharedRoot = fixturePath("cache/forge/combined-deobfuscated.filetree"); Path file = sharedRoot.resolve("net/minecraft/network/chat/contents/TranslatableContents.java"); String text = "package net.minecraft.network.chat.contents; class TranslatableContents {}\n"; SFMSymbolServerProtocol.DependencySourceRootMapping forge = @@ -334,7 +337,7 @@ void retainedDependencyRootIdentityDisambiguatesOnePhysicalTreeWithMultipleSeman @Test void staleRetainedDependencyRootIdentityFailsClosed() { - Path sharedRoot = Path.of("D:/cache/forge/combined-deobfuscated.filetree"); + Path sharedRoot = fixturePath("cache/forge/combined-deobfuscated.filetree"); Path file = sharedRoot.resolve("net/minecraft/network/chat/contents/TranslatableContents.java"); String text = "package net.minecraft.network.chat.contents; class TranslatableContents {}\n"; SFMSymbolServerProtocol.DependencySourceRootMapping forge = @@ -376,7 +379,7 @@ void staleRetainedDependencyRootIdentityFailsClosed() { @Test void managedJdkSourceUsesNegotiatedResolverIdentityWithoutWorkspaceFallback() { - Path jdkRoot = Path.of("D:/cache/jdk/java-17/abc123/tree"); + Path jdkRoot = fixturePath("cache/jdk/java-17/abc123/tree"); Path file = jdkRoot.resolve("java.base/java/lang/String.java"); String text = "package java.lang; public final class String {}\n"; SFMDefinitionRequest.SourceRoot requestRoot = new SFMDefinitionRequest.SourceRoot( @@ -445,7 +448,7 @@ void managedJdkSourceUsesNegotiatedResolverIdentityWithoutWorkspaceFallback() { @Test void inconsistentManagedJdkIdentityFailsClosedInsteadOfUsingOrderedRoot() { - Path jdkRoot = Path.of("D:/cache/jdk/java-17/abc123/tree"); + Path jdkRoot = fixturePath("cache/jdk/java-17/abc123/tree"); Path file = jdkRoot.resolve("java.base/java/lang/String.java"); String text = "package java.lang; public final class String {}\n"; SFMDefinitionRequest.SourceRoot requestRoot = new SFMDefinitionRequest.SourceRoot( @@ -493,7 +496,7 @@ void inconsistentManagedJdkIdentityFailsClosedInsteadOfUsingOrderedRoot() { @Test void equallyDeepMappingsAndOutsideAuthorizationFailClosed() { - Path repoRoot = Path.of("D:/workspace/sfm"); + Path repoRoot = fixturePath("workspace/sfm"); Path sourceRoot = repoRoot.resolve("src/main/java"); Path file = sourceRoot.resolve("A.java"); String text = "class A {}\n"; @@ -519,7 +522,7 @@ void equallyDeepMappingsAndOutsideAuthorizationFailClosed() { ambiguous.diagnostics().get(0).code() ); - Path outsideAuthorization = Path.of("D:/different/repo"); + Path outsideAuthorization = fixturePath("different/repo"); var outside = new SFMDefinitionContextAdapter().adapt( contribution(projection( outsideAuthorization, @@ -540,7 +543,7 @@ void equallyDeepMappingsAndOutsideAuthorizationFailClosed() { @Test void canvasWhitespaceAndAbsentHandshakeProduceTypedDiagnostics() { - Path repoRoot = Path.of("D:/workspace/sfm"); + Path repoRoot = fixturePath("workspace/sfm"); Path sourceRoot = repoRoot.resolve("src/main/java"); Path file = sourceRoot.resolve("A.java"); String text = "class A {}\n"; @@ -704,6 +707,10 @@ private static SFMSymbolServerProtocol.ServerHello externalHello( ); } + private static Path fixturePath(String relative) { + return Path.of("").toAbsolutePath().getRoot().resolve("sfm-definition-fixture").resolve(relative); + } + private static RootFixture root( String id, String sourceSet, diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMFindReferencesControllerTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMFindReferencesControllerTests.java index 1a3e7519b..555e5b16c 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMFindReferencesControllerTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMFindReferencesControllerTests.java @@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import static ca.teamdman.sfm.client.symbol.SFMJumpToDefinitionActionTests.fixturePath; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; @@ -392,7 +393,7 @@ private static SFMSymbolServerProtocol.ServerHello hello() { new SFMSymbolServerProtocol.WorkspaceMetadata( workspace, List.of(new SFMSymbolServerProtocol.SourceRootMapping( - "D:\\workspace\\src", "main", "main", "src"))), + fixturePath("workspace/src").toString(), "main", "main", "src"))), "{}" ); } diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJavaInteractionMapSessionTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJavaInteractionMapSessionTests.java index c4af6a60f..d94d6e1e7 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJavaInteractionMapSessionTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJavaInteractionMapSessionTests.java @@ -11,6 +11,7 @@ import com.google.gson.JsonObject; import org.junit.jupiter.api.Test; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -23,6 +24,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class SFMJavaInteractionMapSessionTests { + private static final Path FIXTURE_ROOT = Path.of("").toAbsolutePath().getRoot() + .resolve("sfm-interaction-session-fixture"); + private static final Path SOURCE_ROOT = FIXTURE_ROOT.resolve("workspace/source"); + private static final Path SOURCE_FILE = SOURCE_ROOT.resolve("A.java"); + @Test void ordinaryNonJavaPathsDoNotRequestJavaWorkEvenWithFileAuthority() { FakeLookupService service = new FakeLookupService(); @@ -30,8 +36,8 @@ void ordinaryNonJavaPathsDoNotRequestJavaWorkEvenWithFileAuthority() { long generation = 0; for (String name : List.of("options.txt", "Cargo.lock", "README.md", "config.json", "sample.rs", "program.sfml", "Fake.java.txt", "changes.diff")) { - var path = SFMPath.parse("file:///D:/workspace/" + name); - var baseline = SFMTextDocumentSnapshot.pinned(path, SFMPath.parse("file:///D:/workspace/"), + var path = SFMPath.fromNative(FIXTURE_ROOT.resolve("workspace").resolve(name)); + var baseline = SFMTextDocumentSnapshot.pinned(path, SFMPath.fromNative(FIXTURE_ROOT.resolve("workspace")), "text", SFMTextDocumentSnapshot.literal("text").sha256().orElseThrow(), Optional.empty(), Optional.empty()); var context = contribution(baseline, "text", ++generation); assertEquals(Optional.of(SFMDefinitionContextAdapter.DiagnosticCode.DOCUMENT_LANGUAGE_NOT_JAVA), @@ -44,12 +50,12 @@ void ordinaryNonJavaPathsDoNotRequestJavaWorkEvenWithFileAuthority() { @Test void declaredSourceLanguageSurvivesNativeMaterializationAndOverridesMisleadingFileNames() { - var root = SFMPath.parse("file:///D:/materialized/"); - var path = SFMPath.parse("file:///D:/materialized/opaque-snapshot"); + var root = SFMPath.fromNative(FIXTURE_ROOT.resolve("materialized")); + var path = SFMPath.fromNative(FIXTURE_ROOT.resolve("materialized/opaque-snapshot")); var java = SFMTextDocumentSnapshot.pinned(path, root, "class A {}", SFMTextDocumentSnapshot.literal("class A {}").sha256().orElseThrow(), Optional.empty(), Optional.empty(), Optional.empty(), SFMTextDocumentLanguage.java()); assertTrue(SFMJavaInteractionMapSession.structurallyUnavailable(contribution(java, java.text(), 1)).isEmpty()); - var diff = SFMTextDocumentSnapshot.pinned(SFMPath.parse("file:///D:/materialized/NotSource.java"), root, + var diff = SFMTextDocumentSnapshot.pinned(SFMPath.fromNative(FIXTURE_ROOT.resolve("materialized/NotSource.java")), root, "class A {}", java.sha256().orElseThrow(), Optional.empty(), Optional.empty(), Optional.empty(), SFMTextDocumentLanguage.diff()); assertEquals(Optional.of(SFMDefinitionContextAdapter.DiagnosticCode.DOCUMENT_LANGUAGE_NOT_JAVA), @@ -67,7 +73,7 @@ void declaredSourceLanguageSurvivesNativeMaterializationAndOverridesMisleadingFi void switchingAwayFromJavaCancelsPendingWorkAndRejectsItsLatePublication() { var service = new FakeLookupService(); var session = new SFMJavaInteractionMapSession(service); - var request = SFMJavaInteractionMapProtocolTests.request(); + var request = request(); session.refresh(contribution(request.document().text(), 3), 3, request.document().contentHash()); session.refresh(pathlessContribution("non-java scratch", 4), 4, SFMDefinitionRequest.sha256("non-java scratch")); assertEquals(1, service.cancellations.get()); @@ -84,9 +90,9 @@ void genuineJavaFailuresStillSubmitAndKeepCorrelatableNonContentEvidence() { var evidence = SFMJavaInteractionMapSession.requestEvidence(context); assertEquals("java", evidence.language()); assertEquals("file", evidence.addressScheme()); - assertEquals(SFMDefinitionRequest.sha256("file:///D:/workspace/source/A.java"), evidence.documentId()); + assertEquals(SFMDefinitionRequest.sha256(SOURCE_FILE.toUri().toString()), evidence.documentId()); assertEquals(SFMDefinitionRequest.sha256("class A {}"), evidence.contentHash()); - assertFalse(evidence.toString().contains("D:/workspace")); + assertFalse(evidence.toString().contains("sfm-interaction-session-fixture")); assertFalse(evidence.toString().contains("class A")); session.refresh(context, 7, SFMDefinitionRequest.sha256("class A {}")); assertEquals(1, service.results.size()); @@ -113,7 +119,7 @@ void pathlessScratchRevisionsNeverSubmitInteractionMapWork() { void onlyTheLatestExactDocumentGenerationCanPublish() { FakeLookupService service = new FakeLookupService(); SFMJavaInteractionMapSession session = new SFMJavaInteractionMapSession(service); - SFMJavaInteractionMap.Request firstRequest = SFMJavaInteractionMapProtocolTests.request(); + SFMJavaInteractionMap.Request firstRequest = request(); SFMJavaInteractionMap.Request secondRequest = new SFMJavaInteractionMap.Request( 18, 4, @@ -144,7 +150,7 @@ void onlyTheLatestExactDocumentGenerationCanPublish() { void aMismatchedPublicationFailsClosed() { FakeLookupService service = new FakeLookupService(); SFMJavaInteractionMapSession session = new SFMJavaInteractionMapSession(service); - SFMJavaInteractionMap.Request request = SFMJavaInteractionMapProtocolTests.request(); + SFMJavaInteractionMap.Request request = request(); SFMContextContribution contribution = contribution(request.document().text(), 3); session.refresh(contribution, 99, request.document().contentHash()); @@ -155,7 +161,7 @@ void aMismatchedPublicationFailsClosed() { @Test void invalidRequestDiagnosticsBecomeActionableWithoutRetainingPrivatePaths() { - SFMJavaInteractionMap.Request request = SFMJavaInteractionMapProtocolTests.request(); + SFMJavaInteractionMap.Request request = request(); JsonObject json = SFMJavaInteractionMapProtocolTests.resultJson(request); json.addProperty("outcome", "invalid-request"); JsonObject diagnostic = new JsonObject(); @@ -181,6 +187,25 @@ void invalidRequestDiagnosticsBecomeActionableWithoutRetainingPrivatePaths() { assertFalse(summary.toString().contains("String.java")); } + private static SFMJavaInteractionMap.Request request() { + SFMJavaInteractionMap.Request fixture = SFMJavaInteractionMapProtocolTests.request(); + SFMDefinitionRequest.Document document = fixture.document(); + return new SFMJavaInteractionMap.Request( + fixture.requestId(), + fixture.requestGeneration(), + fixture.workspace(), + SFMDefinitionRequest.Document.sha256( + SOURCE_FILE.toUri().toString(), + document.rootId(), + document.rootRelativePath(), + document.reportPath(), + document.sourceSet(), + document.text(), + document.diskContentHash() + ) + ); + } + private static SFMContextContribution contribution(String text, long generation) { return contribution(addressedSnapshot(text), text, generation); } @@ -216,8 +241,8 @@ private static SFMTextDocumentSnapshot addressedSnapshot(String text) { literal.state(), literal.text(), literal.mutationCapability(), - Optional.of(SFMPath.parse("file:///D:/workspace/source/A.java")), - Optional.of(SFMPath.parse("file:///D:/workspace/source/")), + Optional.of(SFMPath.fromNative(SOURCE_FILE)), + Optional.of(SFMPath.fromNative(SOURCE_ROOT)), literal.sha256(), literal.byteLength(), literal.lastModified(), @@ -242,7 +267,7 @@ private static SFMJavaInteractionMapLookupService.Lookup lookup(SFMJavaInteracti new SFMSymbolServerProtocol.WorkspaceMetadata( request.workspace(), List.of(new SFMSymbolServerProtocol.SourceRootMapping( - "D:\\workspace\\source", + SOURCE_ROOT.toString(), root.id(), root.sourceSet(), root.path() diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJumpToDefinitionActionTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJumpToDefinitionActionTests.java index 09f0c9ed8..cf30d8439 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJumpToDefinitionActionTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMJumpToDefinitionActionTests.java @@ -33,6 +33,7 @@ import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.Duration; @@ -54,9 +55,19 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class SFMJumpToDefinitionActionTests { + private static final Path FIXTURE_ROOT = Path.of("build", "symbol-navigation-fixtures") + .toAbsolutePath().normalize(); private static final SFMContextOriginId DOCUMENT_ORIGIN = new SFMContextOriginId("sfm:text-editor", "panel-under-test", "document"); + static Path fixturePath(String relative) { + return FIXTURE_ROOT.resolve(relative); + } + + static String fixtureAddress(String relative) { + return fixturePath(relative).toUri().toString(); + } + @Test void canonicalActionExposesDirectLookupAndExactSelectionRoutes() { SFMJumpToDefinitionAction action = new SFMJumpToDefinitionAction(); @@ -341,7 +352,7 @@ void cursorSelectionFocusAndCaptureChangesDoNotInvalidateAnExplicitRequest() { void changedResolverAddressRejectsWithoutBlamingTheCursor() { String text = "class Use { Target value; }\n"; AtomicReference currentContribution = new AtomicReference<>( - addressedContribution(text, "file:///D:/workspace/src/Use.java", 12, 1)); + addressedContribution(text, fixtureAddress("workspace/src/Use.java"), 12, 1)); SFMScreenMultiplexer workspace = uninitializedWorkspace(); CompletableFuture pending = new CompletableFuture<>(); AtomicInteger navigations = new AtomicInteger(); @@ -354,7 +365,7 @@ void changedResolverAddressRejectsWithoutBlamingTheCursor() { feedback::add )); currentContribution.set(addressedContribution( - text, "file:///D:/workspace/src/Replaced.java", 12, 2)); + text, fixtureAddress("workspace/src/Replaced.java"), 12, 2)); pending.complete(successfulLookup()); assertEquals(0, navigations.get()); @@ -650,7 +661,7 @@ private static SFMContextContribution addressedContribution( long generation ) { SFMPath path = SFMPath.parse(address); - SFMPath root = SFMPath.parse("file:///D:/workspace/src/"); + SFMPath root = SFMPath.parse(fixtureAddress("workspace/src")); SFMTextDocumentSnapshot baseline = new SFMTextDocumentSnapshot( SFMTextDocumentSnapshot.State.READY, text, @@ -735,7 +746,7 @@ private static SFMSymbolServerProtocol.ServerHello hello() { new SFMSymbolServerProtocol.WorkspaceMetadata( workspace, List.of(new SFMSymbolServerProtocol.SourceRootMapping( - "D:\\workspace\\src", "main", "main", "src")) + fixturePath("workspace/src").toString(), "main", "main", "src")) ), "{}" ); @@ -814,7 +825,7 @@ static SFMDefinitionResult result( "blake3:classpath", "arborium", "blake3:index" ), new SFMDefinitionResult.DocumentIdentity( - "file:///D:/workspace/src/Use.java", "main", "Use.java", "Use.java", "main", + fixtureAddress("workspace/src/Use.java"), "main", "Use.java", "Use.java", "main", "sha256:0000000000000000000000000000000000000000000000000000000000000000", Optional.empty() ), diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolDefinitionPaletteTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolDefinitionPaletteTests.java index 78b308528..4d478a7b2 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolDefinitionPaletteTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolDefinitionPaletteTests.java @@ -33,6 +33,8 @@ import java.util.OptionalLong; import java.util.Set; +import static ca.teamdman.sfm.client.symbol.SFMJumpToDefinitionActionTests.fixtureAddress; +import static ca.teamdman.sfm.client.symbol.SFMJumpToDefinitionActionTests.fixturePath; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -76,8 +78,8 @@ void choicesAreStableLabelledExactAndOneShot() throws Exception { @Test void existingImmutableTargetIsFocusedAtExactRangeWithoutOpeningOrReplacing() { - SFMPath root = SFMPath.parse("file:///D:/workspace/src"); - SFMPath target = SFMPath.parse("file:///D:/workspace/src/Target.java"); + SFMPath root = SFMPath.parse(fixtureAddress("workspace/src")); + SFMPath target = SFMPath.parse(fixtureAddress("workspace/src/Target.java")); String text = "class Target {}\n"; SFMDefinitionResult.Definition definition = SFMJumpToDefinitionActionTests.definition( "example.Target", "Target.java", 6, "Target"); @@ -104,8 +106,8 @@ void existingImmutableTargetIsFocusedAtExactRangeWithoutOpeningOrReplacing() { @Test void existingDeferredEditorAtomicallyPublishesTheNewExactRangeWithoutOpeningDuplicate() throws Exception { - SFMPath root = SFMPath.parse("file:///D:/workspace/src"); - SFMPath target = SFMPath.parse("file:///D:/workspace/src/Target.java"); + SFMPath root = SFMPath.parse(fixtureAddress("workspace/src")); + SFMPath target = SFMPath.parse(fixtureAddress("workspace/src/Target.java")); String text = "class Target {}\n"; SFMTextDocumentRange originalRange = new SFMTextDocumentRange( SFMTextDocumentRange.positionAtByteOffset(text, 0), @@ -148,11 +150,11 @@ void existingDeferredEditorAtomicallyPublishesTheNewExactRangeWithoutOpeningDupl @Test void unseenDefinitionOpensAsATabInTheOriginatingPanelStack() { - SFMPath root = SFMPath.parse("file:///D:/workspace/src"); + SFMPath root = SFMPath.parse(fixtureAddress("workspace/src")); SFMWorkspacePanelId sourcePanelId = new SFMWorkspacePanelId(1); TrackingDocumentPanel source = new TrackingDocumentPanel(snapshot( "class Source {}\n", - SFMPath.parse("file:///D:/workspace/src/Source.java"), + SFMPath.parse(fixtureAddress("workspace/src/Source.java")), root )); TrackingWorkspace workspace = new TrackingWorkspace(sourcePanelId, source); @@ -211,22 +213,22 @@ void portableWorkspaceTargetResolvesThroughNegotiatedRootBeforeOpening() { fileSpan.startByte(), fileSpan.endByte(), fileSpan.startLine(), fileSpan.startColumn(), fileSpan.endLine(), fileSpan.endColumn() ); - SFMPath authorizedRoot = SFMPath.parse("file:///D:/workspace/src"); + SFMPath authorizedRoot = SFMPath.parse(fixtureAddress("workspace/src")); SFMPath target = SFMDefinitionNavigation.resolveTarget( SFMPath.parse(workspaceSpan.address()), workspaceSpan, authorizedRoot); - assertEquals(SFMPath.parse("file:///D:/workspace/src/Target.java"), target); + assertEquals(SFMPath.parse(fixtureAddress("workspace/src/Target.java")), target); } @Test void definitionReadReusesOriginatingDocumentAuthorityWithoutGrantingWorkerRoot() { - SFMPath documentRoot = SFMPath.parse("file:///D:/workspace"); - SFMPath analysisRoot = SFMPath.parse("file:///D:/workspace/src"); - SFMPath target = SFMPath.parse("file:///D:/workspace/src/Target.java"); + SFMPath documentRoot = SFMPath.parse(fixtureAddress("workspace")); + SFMPath analysisRoot = SFMPath.parse(fixtureAddress("workspace/src")); + SFMPath target = SFMPath.parse(fixtureAddress("workspace/src/Target.java")); TrackingDocumentPanel source = new TrackingDocumentPanel(snapshot( "class Source {}\n", - SFMPath.parse("file:///D:/workspace/src/Source.java"), + SFMPath.parse(fixtureAddress("workspace/src/Source.java")), documentRoot )); @@ -236,18 +238,18 @@ void definitionReadReusesOriginatingDocumentAuthorityWithoutGrantingWorkerRoot() ); assertTrue(SFMDefinitionNavigation.sourceReadAuthority( source, - SFMPath.parse("file:///D:/other/src"), - SFMPath.parse("file:///D:/other/src/Target.java") + SFMPath.parse(fixtureAddress("other/src")), + SFMPath.parse(fixtureAddress("other/src/Target.java")) ).isEmpty()); } @Test void definitionReadUsesTheIntersectionOfNarrowResolverAndWorkerRoots() { - SFMPath analysisRoot = SFMPath.parse("file:///D:/workspace/src"); - SFMPath packageGrant = SFMPath.parse("file:///D:/workspace/src/example"); + SFMPath analysisRoot = SFMPath.parse(fixtureAddress("workspace/src")); + SFMPath packageGrant = SFMPath.parse(fixtureAddress("workspace/src/example")); TrackingDocumentPanel source = new TrackingDocumentPanel(snapshot( "class Source {}\n", - SFMPath.parse("file:///D:/workspace/src/example/Source.java"), + SFMPath.parse(fixtureAddress("workspace/src/example/Source.java")), packageGrant )); @@ -256,13 +258,13 @@ void definitionReadUsesTheIntersectionOfNarrowResolverAndWorkerRoots() { SFMDefinitionNavigation.sourceReadAuthority( source, analysisRoot, - SFMPath.parse("file:///D:/workspace/src/example/Target.java") + SFMPath.parse(fixtureAddress("workspace/src/example/Target.java")) ) ); assertTrue(SFMDefinitionNavigation.sourceReadAuthority( source, analysisRoot, - SFMPath.parse("file:///D:/workspace/src/other/Target.java") + SFMPath.parse(fixtureAddress("workspace/src/other/Target.java")) ).isEmpty(), "the worker root must not broaden the originating resolver grant"); } @@ -283,10 +285,10 @@ void dependencySourceTargetResolvesOnlyWithinItsAdvertisedManagedRoot() { fileSpan.startByte(), fileSpan.endByte(), fileSpan.startLine(), fileSpan.startColumn(), fileSpan.endLine(), fileSpan.endColumn() ); - SFMPath managedRoot = SFMPath.parse("file:///D:/managed/forge"); + SFMPath managedRoot = SFMPath.parse(fixtureAddress("managed/forge")); assertEquals( - SFMPath.parse("file:///D:/managed/forge/net/minecraftforge/ForgeType.java"), + SFMPath.parse(fixtureAddress("managed/forge/net/minecraftforge/ForgeType.java")), SFMDefinitionNavigation.resolveTarget( SFMPath.parse(dependencySpan.address()), dependencySpan, managedRoot) ); @@ -294,10 +296,10 @@ void dependencySourceTargetResolvesOnlyWithinItsAdvertisedManagedRoot() { @Test void dependencyNavigationRetainsExactSemanticRootWhenPhysicalSourceTreeIsShared() { - SFMPath sharedRoot = SFMPath.parse("file:///D:/managed/forge/combined-deobfuscated.filetree"); + SFMPath sharedRoot = SFMPath.parse(fixtureAddress("managed/forge/combined-deobfuscated.filetree")); SFMPath target = SFMPath.parse( - "file:///D:/managed/forge/combined-deobfuscated.filetree/" - + "net/minecraft/network/chat/contents/TranslatableContents.java" + fixtureAddress("managed/forge/combined-deobfuscated.filetree/" + + "net/minecraft/network/chat/contents/TranslatableContents.java") ); String text = "class TranslatableContents {}\n"; SFMDefinitionResult.SymbolIdentity symbol = new SFMDefinitionResult.SymbolIdentity( @@ -392,7 +394,7 @@ void workspaceTargetWithMismatchedRootIdentityFailsBeforePanelMutation() { void targetOutsideWorkerRootFailsBeforeAnyPanelMutation() { SFMDefinitionResult.Definition outside = SFMJumpToDefinitionActionTests.definitionAtAddress( "example.Target", - "file:///D:/outside/Target.java", + fixtureAddress("outside/Target.java"), "Target.java", 6, "Target" @@ -435,8 +437,8 @@ void missingSha256WitnessFailsClosedBeforeExistingOrNewPanelsAreTouched() { @Test void staleExistingPanelIsNotReusableAndTheAsynchronousSourceRetainsTheExactWitness() { - SFMPath root = SFMPath.parse("file:///D:/workspace/src"); - SFMPath target = SFMPath.parse("file:///D:/workspace/src/Target.java"); + SFMPath root = SFMPath.parse(fixtureAddress("workspace/src")); + SFMPath target = SFMPath.parse(fixtureAddress("workspace/src/Target.java")); String staleText = "class Target {}\n// stale panel\n"; SFMDefinitionResult.Definition definition = SFMJumpToDefinitionActionTests.definition( "example.Target", "Target.java", 6, "Target"); @@ -503,7 +505,7 @@ private static SFMSymbolServerProtocol.ServerHello hello() { new SFMSymbolServerProtocol.WorkspaceMetadata( workspace, List.of(new SFMSymbolServerProtocol.SourceRootMapping( - "D:\\workspace\\src", "main", "main", "src")) + fixturePath("workspace/src").toString(), "main", "main", "src")) ), "{}" ); @@ -526,15 +528,15 @@ private static SFMSymbolServerProtocol.ServerHello helloWithJdk() { workspace, List.of( new SFMSymbolServerProtocol.SourceRootMapping( - "D:\\workspace\\src", "main", "main", "src"), + fixturePath("workspace/src").toString(), "main", "main", "src"), new SFMSymbolServerProtocol.SourceRootMapping( - "D:\\cache\\jdk\\tree", "jdk-java-17-abc123", + fixturePath("cache/jdk/tree").toString(), "jdk-java-17-abc123", "jdk:java-17", "jdk/java-17/abc123") ), List.of(), List.of(new SFMSymbolServerProtocol.ManagedSourceRootMapping( "jdk-source", "jdk-source", "jdk/java-17/abc123", - "D:\\cache\\jdk\\tree", "jdk-java-17-abc123", "jdk:java-17", + fixturePath("cache/jdk/tree").toString(), "jdk-java-17-abc123", "jdk:java-17", Optional.of("jdk/java-17/abc123"), Optional.empty())) ), "{}" @@ -543,7 +545,7 @@ private static SFMSymbolServerProtocol.ServerHello helloWithJdk() { private static SFMSymbolServerProtocol.ServerHello helloWithSharedDependencyRoots() { SFMSymbolServerProtocol.ServerHello base = hello(); - String shared = "D:\\managed\\forge\\combined-deobfuscated.filetree"; + String shared = fixturePath("managed/forge/combined-deobfuscated.filetree").toString(); return new SFMSymbolServerProtocol.ServerHello( SFMSymbolServerProtocol.PROTOCOL_SCHEMA, "sfm-symbol-server", diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolInspectionSnapshotTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolInspectionSnapshotTests.java index 6a443b245..516858a32 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolInspectionSnapshotTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolInspectionSnapshotTests.java @@ -27,6 +27,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class SFMSymbolInspectionSnapshotTests { + private static final Path SOURCE_ROOT = Path.of("").toAbsolutePath().getRoot() + .resolve("sfm-symbol-inspection-fixture/src"); + private static final Path SOURCE_FILE = SOURCE_ROOT.resolve("p/Use.java"); + @Test void unavailableBranchRetainsTruthfulReplayInputsWithoutInventingAVersion() { Fixture fixture = fixture("class A {}\n", 0, 6); @@ -55,7 +59,7 @@ void unresolvedUnicodeCrlfPointRetainsTruthfulSourceHashPositionAndReplay() { assertEquals("Missing", snapshot.region().selectedText()); assertEquals("java-identifier", snapshot.region().semanticKind()); assertEquals(fixture.document().currentSha256(), snapshot.document().currentSha256()); - assertEquals("file:///D:/repo/src/p/Use.java", snapshot.document().address().orElseThrow()); + assertEquals(SOURCE_FILE.toUri().toString(), snapshot.document().address().orElseThrow()); assertEquals("p/Use.java", snapshot.document().rootRelativePath().orElseThrow()); assertTrue(snapshot.replayCommand().contains("--source-path 'p/Use.java'")); assertTrue(snapshot.replayCommand().contains("--line 2 --column 5 --branch '1.19.2'")); @@ -86,7 +90,7 @@ void resolvedStaticMethodUsesOneExactSelectorAcrossGranularAndAggregateFormats() fixture.document().currentSha256(), fixture.point(), new SFMSymbolInspectionSnapshot.DocumentEvidence( - Optional.of("file:///D:/repo/src/p/Use.java"), + Optional.of(SOURCE_FILE.toUri().toString()), Optional.of("file"), Optional.of("main-java"), Optional.of("p/Use.java"), @@ -259,7 +263,7 @@ private static SFMSymbolInspectionSnapshot.Outlink outlink(String id, String rel return new SFMSymbolInspectionSnapshot.Outlink( id, relation, - Optional.of("file:///D:/repo/src/p/A.java"), + Optional.of(SOURCE_ROOT.resolve("p/A.java").toUri().toString()), "sfm:java-symbol-index", 9, "resolved", @@ -271,8 +275,8 @@ private static SFMSymbolInspectionSnapshot.Outlink outlink(String id, String rel } private static Fixture fixture(String text, int line, int column) { - SFMPath root = SFMPath.fromNative(Path.of("D:/repo/src")); - SFMPath path = SFMPath.fromNative(Path.of("D:/repo/src/p/Use.java")); + SFMPath root = SFMPath.fromNative(SOURCE_ROOT); + SFMPath path = SFMPath.fromNative(SOURCE_FILE); SFMTextDocumentSnapshot baseline = new SFMTextDocumentSnapshot( SFMTextDocumentSnapshot.State.READY, text, diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerNavigationProviderTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerNavigationProviderTests.java index f00cbf508..8a5786f2d 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerNavigationProviderTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerNavigationProviderTests.java @@ -74,7 +74,7 @@ void handshakeMismatchFailsPendingAndAReplacementSessionCanRestart() throws Exce FakeSession mismatched = new FakeSession(0, frame -> { if (kind(frame).equals("hello")) { JsonObject response = JsonParser.parseString( - SFMSymbolServerProtocolTests.helloEnvelope(7, "D:/workspace/source", null, null) + SFMSymbolServerProtocolTests.helloEnvelope(7, SFMSymbolServerProtocolTests.fixturePath("workspace/source").toString(), null, null) ).getAsJsonObject(); response.getAsJsonObject("hello").addProperty("protocol_schema", "wrong"); mismatchedRef.get().send(response.toString()); @@ -102,7 +102,7 @@ void fragmentedHelloAndCoalescedOutOfOrderResultsSupportConcurrentRequests() thr String kind = kind(frame); if (kind.equals("hello")) { sessionRef.get().send(SFMSymbolServerProtocolTests.helloEnvelope( - 7, "D:/workspace/source", null, null)); + 7, SFMSymbolServerProtocolTests.fixturePath("workspace/source").toString(), null, null)); } else if (kind.equals("definition")) { requests.add(SFMDefinitionJsonCodec.decodeRequest(frame.get("request").toString())); if (requests.size() == 2) { @@ -135,7 +135,7 @@ void oneWorkerServesMixedDefinitionAndUsageRequestsOutOfOrder() throws Exception FakeSession session = new FakeSession(1, frame -> { switch (kind(frame)) { case "hello" -> sessionRef.get().send(SFMSymbolServerProtocolTests.helloEnvelope( - 7, "D:/workspace/source", null, null)); + 7, SFMSymbolServerProtocolTests.fixturePath("workspace/source").toString(), null, null)); case "definition" -> { definition.set(SFMDefinitionJsonCodec.decodeRequest(frame.get("request").toString())); sendMixedResultsWhenReady(sessionRef.get(), definition.get(), usage.get()); @@ -232,7 +232,7 @@ void crashFailsCurrentRequestAndNextQueryStartsOneReplacementProcess() throws Ex FakeSession first = new FakeSession(0, frame -> { if (kind(frame).equals("hello")) { firstRef.get().send(SFMSymbolServerProtocolTests.helloEnvelope( - 7, "D:/workspace/source", null, null)); + 7, SFMSymbolServerProtocolTests.fixturePath("workspace/source").toString(), null, null)); } else if (kind(frame).equals("definition")) { firstRef.get().crash(); } @@ -319,7 +319,7 @@ void launchAndCompletionsStayOnDedicatedThreads() throws Exception { FakeSession session = new FakeSession(0, frame -> { if (kind(frame).equals("hello")) { sessionRef.get().send(SFMSymbolServerProtocolTests.helloEnvelope( - 7, "D:/workspace/source", null, null)); + 7, SFMSymbolServerProtocolTests.fixturePath("workspace/source").toString(), null, null)); } else if (kind(frame).equals("definition")) { captured.set(SFMDefinitionJsonCodec.decodeRequest(frame.get("request").toString())); } @@ -386,7 +386,7 @@ private static FakeSession scriptedSession( FakeSession session = new FakeSession(fragmentSize, frame -> { switch (kind(frame)) { case "hello" -> reference.get().send(SFMSymbolServerProtocolTests.helloEnvelope( - generation, "D:/workspace/source", null, null)); + generation, SFMSymbolServerProtocolTests.fixturePath("workspace/source").toString(), null, null)); case "definition" -> { if (answerDefinitions) { SFMDefinitionRequest request = SFMDefinitionJsonCodec.decodeRequest( @@ -471,7 +471,7 @@ private static String pong(long nonce) { private static String workspaceAck(long generation, long cancelled) { JsonObject hello = JsonParser.parseString(SFMSymbolServerProtocolTests.helloEnvelope( - generation, "D:/workspace/source", null, null)).getAsJsonObject() + generation, SFMSymbolServerProtocolTests.fixturePath("workspace/source").toString(), null, null)).getAsJsonObject() .getAsJsonObject("hello"); JsonObject update = new JsonObject(); update.add("workspace", hello.getAsJsonObject("workspace")); diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerProtocolTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerProtocolTests.java index 3e96f5eda..dcb7ee139 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerProtocolTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/symbol/SFMSymbolServerProtocolTests.java @@ -4,6 +4,7 @@ import com.google.gson.JsonParser; import org.junit.jupiter.api.Test; +import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -13,6 +14,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class SFMSymbolServerProtocolTests { + private static final String DOCUMENT_ADDRESS = fixturePath("workspace/source/A.java").toUri().toASCIIString(); + + static Path fixturePath(String relative) { + return Path.of("").toAbsolutePath().getRoot().resolve("sfm-symbol-fixture").resolve(relative); + } + @Test void rustExtendedLengthWindowsPathsBecomeOrdinaryDriveAndUncPaths() { assertEquals( @@ -44,7 +51,7 @@ void clientHelloExactlyMatchesTheConvergedRustEnvelope() { @Test void helloRetainsTypedWorkspaceAndRawAdditiveMetadata() throws Exception { - String json = helloEnvelope(7, "D:/workspace/source", "extra_field", "retained"); + String json = helloEnvelope(7, fixturePath("workspace/source").toString(), "extra_field", "retained"); var frame = assertInstanceOf( SFMSymbolServerProtocol.HelloFrame.class, @@ -56,7 +63,7 @@ void helloRetainsTypedWorkspaceAndRawAdditiveMetadata() throws Exception { assertEquals("source", frame.hello().workspace().rootMappings().get(0).reportRootPath()); assertTrue(frame.hello().rawHelloJson().contains("extra_field")); assertEquals( - "file:///D:/workspace/source", + fixturePath("workspace/source").toUri().toASCIIString(), frame.hello().workspace().rootMappings().get(0).absoluteRootAddress() ); } @@ -64,11 +71,11 @@ void helloRetainsTypedWorkspaceAndRawAdditiveMetadata() throws Exception { @Test void helloRetainsManagedDependencySourceRootMetadata() throws Exception { JsonObject envelope = JsonParser.parseString( - helloEnvelope(7, "D:/workspace/source", null, null) + helloEnvelope(7, fixturePath("workspace/source").toString(), null, null) ).getAsJsonObject(); JsonObject workspace = envelope.getAsJsonObject("hello").getAsJsonObject("workspace"); JsonObject dependencyRoot = new JsonObject(); - dependencyRoot.addProperty("canonical_absolute_path", "D:/workspace/dependencies/forge"); + dependencyRoot.addProperty("canonical_absolute_path", fixturePath("workspace/dependencies/forge").toString()); dependencyRoot.addProperty("root_id", "dependency-source-0"); dependencyRoot.addProperty("source_set", "dependency:forge"); dependencyRoot.addProperty("report_prefix", "dependency/forge/userdev/loader-pipeline"); @@ -85,7 +92,7 @@ void helloRetainsManagedDependencySourceRootMetadata() throws Exception { assertEquals("dependency:forge", decoded.sourceSet()); assertEquals("dependency/forge/userdev/loader-pipeline", decoded.reportPrefix()); assertEquals( - "file:///D:/workspace/dependencies/forge", + fixturePath("workspace/dependencies/forge").toUri().toASCIIString(), decoded.absoluteRootAddress() ); } @@ -93,7 +100,7 @@ void helloRetainsManagedDependencySourceRootMetadata() throws Exception { @Test void helloRetainsCanonicalJdkManagedSourceAuthority() throws Exception { JsonObject envelope = JsonParser.parseString( - helloEnvelope(7, "D:/workspace/source", null, null) + helloEnvelope(7, fixturePath("workspace/source").toString(), null, null) ).getAsJsonObject(); JsonObject workspace = envelope.getAsJsonObject("hello").getAsJsonObject("workspace"); JsonObject requestWorkspace = workspace.getAsJsonObject("request_workspace"); @@ -107,7 +114,7 @@ void helloRetainsCanonicalJdkManagedSourceAuthority() throws Exception { requestWorkspace.getAsJsonArray("source_roots").add(jdkRoot); JsonObject orderedRoot = new JsonObject(); - orderedRoot.addProperty("canonical_absolute_path", "D:/cache/jdk/java-17/abc123/tree"); + orderedRoot.addProperty("canonical_absolute_path", fixturePath("cache/jdk/java-17/abc123/tree").toString()); orderedRoot.addProperty("root_id", "jdk-java-17-abc123"); orderedRoot.addProperty("source_set", "jdk:java-17"); orderedRoot.addProperty("report_root_path", "jdk/java-17/abc123"); @@ -117,7 +124,7 @@ void helloRetainsCanonicalJdkManagedSourceAuthority() throws Exception { managed.addProperty("resolver_id", "jdk-source"); managed.addProperty("address_scheme", "jdk-source"); managed.addProperty("resolver_identity", "jdk/java-17/abc123"); - managed.addProperty("canonical_absolute_path", "D:/cache/jdk/java-17/abc123/tree"); + managed.addProperty("canonical_absolute_path", fixturePath("cache/jdk/java-17/abc123/tree").toString()); managed.addProperty("root_id", "jdk-java-17-abc123"); managed.addProperty("source_set", "jdk:java-17"); managed.addProperty("portable_root_path", "jdk/java-17/abc123"); @@ -134,12 +141,12 @@ void helloRetainsCanonicalJdkManagedSourceAuthority() throws Exception { assertEquals("jdk-source", decoded.resolverId()); assertEquals("jdk-java-17-abc123", decoded.rootId()); assertEquals(Optional.of("jdk/java-17/abc123"), decoded.portableRootPath()); - assertEquals("file:///D:/cache/jdk/java-17/abc123/tree", decoded.absoluteRootAddress()); + assertEquals(fixturePath("cache/jdk/java-17/abc123/tree").toUri().toASCIIString(), decoded.absoluteRootAddress()); } @Test void workspaceAckCarriesACompleteReplacementWorkspace() throws Exception { - JsonObject hello = JsonParser.parseString(helloEnvelope(12, "D:/workspace/new-source", null, null)) + JsonObject hello = JsonParser.parseString(helloEnvelope(12, fixturePath("workspace/new-source").toString(), null, null)) .getAsJsonObject() .getAsJsonObject("hello"); JsonObject update = new JsonObject(); @@ -156,7 +163,7 @@ void workspaceAckCarriesACompleteReplacementWorkspace() throws Exception { ); assertEquals(12, frame.workspaceGeneration()); assertEquals(2, frame.cancelledRequests()); - assertEquals("D:\\workspace\\new-source", + assertEquals(fixturePath("workspace/new-source").toString(), frame.workspace().rootMappings().get(0).canonicalAbsolutePath()); } @@ -225,7 +232,7 @@ void requestAndAddressedResultExactlyMatchTheRustVersionThreeJsonContract() { + "\"kind\":\"custom\",\"exists\":true}],\"classpath_fingerprint\":\"blake3:workspace\"," + "\"workspace_fingerprint\":\"blake3:0000000000000000000000000000000000000000000000000000000000000000\"," + "\"workspace_generation\":11},\"document\":{" - + "\"address\":\"file:///D:/workspace/source/A.java\",\"root_id\":\"custom-0\"," + + "\"address\":\"" + DOCUMENT_ADDRESS + "\",\"root_id\":\"custom-0\"," + "\"root_relative_path\":\"A.java\",\"report_path\":\"source/A.java\"," + "\"source_set\":\"custom\",\"text\":\"class A {}\\n\",\"content_hash\":\"" + hash + "\"," + "\"disk_content_hash\":\"" + hash + "\"}," @@ -234,14 +241,14 @@ void requestAndAddressedResultExactlyMatchTheRustVersionThreeJsonContract() { assertEquals(request, SFMDefinitionJsonCodec.decodeRequest(expectedRequest)); SFMDefinitionResult addressed = addressedResult(request); - String identifierSpan = "{\"address\":\"file:///D:/workspace/source/A.java\"," + String identifierSpan = "{\"address\":\"" + DOCUMENT_ADDRESS + "\"," + "\"resolver_id\":\"sfm:file\",\"root_id\":\"custom-0\"," + "\"root_relative_path\":\"A.java\",\"report_path\":\"source/A.java\"," + "\"source_set\":\"custom\",\"source_hash\":\"" + hash + "\"," + "\"source_sha256\":\"" + hash + "\"," + "\"start_byte\":6,\"end_byte\":7,\"start_line\":1,\"start_column\":7," + "\"end_line\":1,\"end_column\":8}"; - String declarationSpan = "{\"address\":\"file:///D:/workspace/source/A.java\"," + String declarationSpan = "{\"address\":\"" + DOCUMENT_ADDRESS + "\"," + "\"resolver_id\":\"sfm:file\",\"root_id\":\"custom-0\"," + "\"root_relative_path\":\"A.java\",\"report_path\":\"source/A.java\"," + "\"source_set\":\"custom\",\"source_hash\":\"" + hash + "\"," @@ -260,7 +267,7 @@ void requestAndAddressedResultExactlyMatchTheRustVersionThreeJsonContract() { + "\"source_exclusions\":[],\"classpath_mode\":\"isolated\"," + "\"classpath_fingerprint\":\"blake3:workspace\",\"parser_fingerprint\":\"arborium\"," + "\"index_fingerprint\":\"blake3:index\"},\"document\":{" - + "\"address\":\"file:///D:/workspace/source/A.java\",\"root_id\":\"custom-0\"," + + "\"address\":\"" + DOCUMENT_ADDRESS + "\",\"root_id\":\"custom-0\"," + "\"root_relative_path\":\"A.java\",\"report_path\":\"source/A.java\"," + "\"source_set\":\"custom\",\"content_hash\":\"" + hash + "\"," + "\"disk_content_hash\":\"" + hash + "\"}," @@ -394,7 +401,7 @@ static SFMDefinitionRequest request(long requestId, long requestGeneration, long workspaceGeneration ), SFMDefinitionRequest.Document.sha256( - "file:///D:/workspace/source/A.java", + DOCUMENT_ADDRESS, "custom-0", "A.java", "source/A.java", diff --git a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/syntax/process/SFMSyntaxServerHighlightProviderTests.java b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/syntax/process/SFMSyntaxServerHighlightProviderTests.java index 5da637a44..20f4fc32f 100644 --- a/platform/minecraft/src/test/java/ca/teamdman/sfm/client/syntax/process/SFMSyntaxServerHighlightProviderTests.java +++ b/platform/minecraft/src/test/java/ca/teamdman/sfm/client/syntax/process/SFMSyntaxServerHighlightProviderTests.java @@ -293,6 +293,7 @@ void malformedSchemaFailsTheSessionAndDoesNotPublishAResult() throws Exception { failure(supervisor.submit(request(21, 1, "class InvalidSchema {}" )).result()) ); assertEquals(1, supervisor.telemetry().protocolFailures()); + await(() -> malformed.terminated.get()); assertTrue(malformed.terminated.get()); assertEquals(0, supervisor.telemetry().completed()); } @@ -398,6 +399,7 @@ void handshakeTimeoutReapsTheSilentProcess() throws Exception { failureWithin(supervisor.start(Duration.ofSeconds(1))) ); assertEquals(1, supervisor.telemetry().transportFailures()); + await(() -> silent.terminated.get()); assertTrue(silent.terminated.get()); }