From f7dc28338a6d7b94304a218c162578020f75291b Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:12:11 -0400 Subject: [PATCH 01/17] Add branch CI and isolated Minecraft puppet experiments --- .github/workflows/ci.yml | 207 ++++++++++++++++++ containers/.gitattributes | 4 + containers/sfm/Dockerfile | 69 ++++++ containers/sfm/Dockerfile.dockerignore | 17 ++ containers/sfm/graphics.sh | 18 ++ containers/sfm/run.sh | 66 ++++++ containers/sfm/smoke.sh | 44 ++++ containers/sfm/verify.py | 38 ++++ ...ci and container puppet experiment plan.md | 132 +++++++++++ .../src/jar_build/json_path.rs | 69 +++++- 10 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 containers/.gitattributes create mode 100644 containers/sfm/Dockerfile create mode 100644 containers/sfm/Dockerfile.dockerignore create mode 100644 containers/sfm/graphics.sh create mode 100644 containers/sfm/run.sh create mode 100644 containers/sfm/smoke.sh create mode 100644 containers/sfm/verify.py create mode 100644 docs/tasks/ci and container puppet experiment plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..dea2dec3c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,207 @@ +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: 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: 110 + 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: 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: Compile all Java source sets + timeout-minutes: 35 + run: | + "$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: | + "$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: 100 + 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: 22 + 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/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..648925e3c --- /dev/null +++ b/containers/sfm/Dockerfile @@ -0,0 +1,69 @@ +# 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 \ + 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 \ + JAVA_TOOL_OPTIONS="-Xmx3g -XX:ActiveProcessorCount=4" \ + 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 + +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 \ + && sfm-propagate-changes jar build --branch ci-container --require-portable-artifacts \ + && /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/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/run.sh b/containers/sfm/run.sh new file mode 100644 index 000000000..980a75802 --- /dev/null +++ b/containers/sfm/run.sh @@ -0,0 +1,66 @@ +#!/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 + local previews=/workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview + if [[ -d "$previews" ]]; then + cp -a "$previews" "$artifacts/previews" + fi + # Do not copy the home directory or game-instance descriptors: they contain RPC tokens. + local logs=/workspace/platform/minecraft/runGameTestPreview/logs + if [[ -d "$logs" ]]; then cp -a "$logs" "$artifacts/game-logs"; fi + printf '%s\n' "$status" > "$artifacts/exit-code.txt" + 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 + +# Keep one X server alive for both the renderer probe and the game; no host display/GPU. +xvfb-run --auto-servernum --server-args='-screen 0 1280x720x24 -nolisten tcp' \ + bash -euo pipefail -c ' + glxinfo -B | tee /workspace/container-artifacts/glxinfo.txt + grep -qi llvmpipe /workspace/container-artifacts/glxinfo.txt + sfm-propagate-changes puppet run title_screen_capture,game_test_orbit_capture \ + --game-test sfm:move_1_stack_direct --branch ci-container \ + --width 1280 --height 720 --variant preferred --require-portable-artifacts \ + 2>&1 | tee /workspace/container-artifacts/console.log + ' + +python3 /opt/sfm-container/verify.py \ + /workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview \ + "$artifacts/console.log" | tee "$artifacts/verification.json" diff --git a/containers/sfm/smoke.sh b/containers/sfm/smoke.sh new file mode 100644 index 000000000..d31b64a0a --- /dev/null +++ b/containers/sfm/smoke.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +image=${1:-sfm-ci:local} +artifacts=${2:-build/container-smoke} +if [[ $# -gt 2 ]]; then + echo 'Usage: smoke.sh [image] [artifact-directory]' >&2 + exit 2 +fi +mkdir -p "$artifacts" +container= + +cleanup() { + local status=$? + trap - EXIT + if [[ -n "$container" ]]; then + docker logs "$container" > "$artifacts/docker.log" 2>&1 || true + docker inspect "$container" > "$artifacts/docker-inspect.json" || true + docker cp "$container:/workspace/container-artifacts/." "$artifacts/" || true + docker 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. +container=$(docker 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 \ + --shm-size 256m --init \ + --tmpfs /tmp:rw,exec,nosuid,nodev,size=512m,mode=1777 \ + --tmpfs /home/sfm:rw,nosuid,nodev,size=128m,uid=10001,gid=10001,mode=700 \ + --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 12m docker start --attach "$container" +status=$(docker inspect --format '{{.State.ExitCode}}' "$container") +if [[ "$status" != 0 ]]; then + echo "Container fixture failed with exit code $status" >&2 + exit 1 +fi +docker cp "$container:/workspace/container-artifacts/." "$artifacts/" +echo "Offline container fixture passed. Artifacts: $artifacts" diff --git a/containers/sfm/verify.py b/containers/sfm/verify.py new file mode 100644 index 000000000..7fdb5b25a --- /dev/null +++ b/containers/sfm/verify.py @@ -0,0 +1,38 @@ +"""Verify new game-puppet screenshots and completion, using only Python's stdlib.""" +import json +from collections import Counter +from pathlib import Path +import re +import struct +import sys + +root = Path(sys.argv[1]).resolve() +log = Path(sys.argv[2]).read_text(encoding="utf-8", errors="replace") +if not re.search(r"SFM_GAME_PUPPET_COMPLETE failed=0 total=[1-9]\d*", log): + raise SystemExit("Missing successful game-puppet completion marker") +manifest = json.loads((root / "preview-manifest.json").read_text(encoding="utf-8")) +captures = manifest.get("captures", []) +puppets = {capture["puppet"].removeprefix("sfm:") for capture in captures} +expected = {"title_screen_capture", "game_test_orbit_capture"} +if not expected <= puppets: + raise SystemExit(f"Missing requested screenshot sets: {expected - puppets}") +counts = Counter(capture["puppet"].removeprefix("sfm:") for capture in captures) +if counts["title_screen_capture"] < 3 or counts["game_test_orbit_capture"] < 8: + raise SystemExit(f"Expected 3 title-screen and 8 orbit captures, found {counts}") +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']}") + header = path.read_bytes()[: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}") +print(json.dumps({"passed": True, "puppets": sorted(puppets), "screenshots": len(seen)}, indent=2)) 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..3f190edb5 --- /dev/null +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -0,0 +1,132 @@ +# CI and container puppet experiment + +**Plan status:** Active +**Primary implementation root:** branch `ci/1.19.2-container-puppet`, based on `707f53f4a` +**Last updated:** 2026-09-19 +**Intent audit:** Passed against the initial CI/container request + +## 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. | + +## Intent audit evidence + +- Extraction: reread the initial request and recorded build verification, branch/worktree constraints, uncertain local Docker installation, Discord isolation purpose, graphics/Kubernetes question, existing puppet, and source checkout as U1-U7. +- Traceability: every requirement maps to a task and evidence; Docker images and workflow plumbing are reversible implementation choices within the requested experiment. +- Adversarial omission: preserved the future nature of the help bot and possible Kubernetes deployment; neither is represented as already deployed. The busy checkout remains outside the implementation working directory. +- Source limitation: none. + +## 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`. + +`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. Deterministic restoration of +their 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. + +**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`. + +## [~] 2. Build and deliver a mod artifact from the feature branch + +**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. + +## [~] 3. Run a graphical puppet inside a restricted Docker worker + +**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. + +## [ ] 4. Review isolation and provide reproducible handoff + +**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. + +## 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. | + +## Operational readiness + +- Target: `ci/1.19.2-container-puppet`, base `707f53f4a`. +- Existing installed CLI reports `ea4dcc9aa`, older than this source; CI must build its own current-source executable. +- Tooling source changes: none initially; installation responsibility must be revisited if portability fixes change Rust source. +- Dependency posture: frozen project dependencies; new container infrastructure as scoped above. +- New developer/reference clones: none. +- Process preflight: no local game launch or process termination is planned; hosted workers own their test processes. +- Final process state, exact test commands, artifact evidence and remote run URL: pending validation. 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()); + } +} From 10967aefcd181c5bdcde6b4e97dc56b9575fb45a Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:21:06 -0400 Subject: [PATCH 02/17] Make container fixtures independent and pin Vox build compiler --- .github/README.md | 52 +++++++ .github/workflows/ci.yml | 24 ++- containers/sfm/Dockerfile | 8 +- containers/sfm/README.md | 137 ++++++++++++++++++ containers/sfm/install-jbr.sh | 48 ++++++ containers/sfm/run.sh | 63 +++++--- containers/sfm/smoke.sh | 2 +- containers/sfm/verify.py | 76 ++++++---- ...ci and container puppet experiment plan.md | 22 ++- 9 files changed, 374 insertions(+), 58 deletions(-) create mode 100644 .github/README.md create mode 100644 containers/sfm/README.md create mode 100644 containers/sfm/install-jbr.sh diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 000000000..5502ec02f --- /dev/null +++ b/.github/README.md @@ -0,0 +1,52 @@ +# 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. + +## 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 complete +game run is a separate acceptance check. + +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 index dea2dec3c..28a56b0e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: build: name: Compile, test and package (Linux) runs-on: ubuntu-24.04 - timeout-minutes: 110 + timeout-minutes: 140 env: CARGO_TERM_COLOR: always RUST_BACKTRACE: '1' @@ -102,6 +102,14 @@ jobs: 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: | @@ -121,10 +129,18 @@ jobs: "$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: | - "$SFM_CI_CLI" --log-filter info --log-file build/ci/compile.ndjson \ + 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 @@ -175,7 +191,7 @@ jobs: container-smoke: name: Isolated Minecraft client (Mesa and Xvfb) runs-on: ubuntu-24.04 - timeout-minutes: 100 + timeout-minutes: 120 steps: - name: Check out the event revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -192,7 +208,7 @@ jobs: 2>&1 | tee build/container-ci/image-build.log - name: Capture screenshots in an offline restricted container - timeout-minutes: 22 + timeout-minutes: 40 run: bash containers/sfm/smoke.sh sfm-ci:local build/container-smoke - name: Upload container evidence diff --git a/containers/sfm/Dockerfile b/containers/sfm/Dockerfile index 648925e3c..076da7a38 100644 --- a/containers/sfm/Dockerfile +++ b/containers/sfm/Dockerfile @@ -31,6 +31,11 @@ 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/ @@ -57,7 +62,8 @@ RUN test -n "$SFM_SOURCE_REVISION" \ 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 \ - && sfm-propagate-changes jar build --branch ci-container --require-portable-artifacts \ + && 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 \ diff --git a/containers/sfm/README.md b/containers/sfm/README.md new file mode 100644 index 000000000..bb751bce8 --- /dev/null +++ b/containers/sfm/README.md @@ -0,0 +1,137 @@ +# 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. + +## 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 +``` + +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 +``` + +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. + +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 `console.log`, + `game-logs/`, `exit-code.txt`, and `previews/` with the existing SFM HTML preview, + manifest, and screenshots. +- `docker.log`: container launch and failure diagnostics. +- `verification.json`, `exit-code.txt`, and `source-revision.txt`: result and input. +- `docker-inspect.json`: the 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; its build log is the evidence in that case. 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, Docker'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/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/run.sh b/containers/sfm/run.sh index 980a75802..560fa6d9c 100644 --- a/containers/sfm/run.sh +++ b/containers/sfm/run.sh @@ -19,13 +19,6 @@ cp source-revision.txt "$artifacts/" collect_artifacts() { local status=$? trap - EXIT - local previews=/workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview - if [[ -d "$previews" ]]; then - cp -a "$previews" "$artifacts/previews" - fi - # Do not copy the home directory or game-instance descriptors: they contain RPC tokens. - local logs=/workspace/platform/minecraft/runGameTestPreview/logs - if [[ -d "$logs" ]]; then cp -a "$logs" "$artifacts/game-logs"; fi printf '%s\n' "$status" > "$artifacts/exit-code.txt" exit "$status" } @@ -50,17 +43,49 @@ print('uid=10001 capabilities=none no_new_privileges=1 seccomp=filter network=lo PY fi -# Keep one X server alive for both the renderer probe and the game; no host display/GPU. +# 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 -c ' - glxinfo -B | tee /workspace/container-artifacts/glxinfo.txt - grep -qi llvmpipe /workspace/container-artifacts/glxinfo.txt - sfm-propagate-changes puppet run title_screen_capture,game_test_orbit_capture \ - --game-test sfm:move_1_stack_direct --branch ci-container \ - --width 1280 --height 720 --variant preferred --require-portable-artifacts \ - 2>&1 | tee /workspace/container-artifacts/console.log - ' + bash -euo pipefail <<'BASH' +glxinfo -B | tee /workspace/container-artifacts/glxinfo.txt +grep -qi llvmpipe /workspace/container-artifacts/glxinfo.txt -python3 /opt/sfm-container/verify.py \ - /workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview \ - "$artifacts/console.log" | tee "$artifacts/verification.json" +previews=/workspace/platform/minecraft/build/sfm-toolchain/artifacts/game-test-preview +game_dir=/workspace/platform/minecraft/runGameTestPreview +current_artifacts= +snapshot_current() { + if [[ -z "$current_artifacts" ]]; then return; 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" + 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 + 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 index d31b64a0a..1708213fe 100644 --- a/containers/sfm/smoke.sh +++ b/containers/sfm/smoke.sh @@ -34,7 +34,7 @@ container=$(docker create --network none --read-only --user 10001:10001 \ "$image" verify) # Enforce wall time outside the game JVM. Cleanup kills and removes this container and its volume. -timeout --signal=TERM --kill-after=30s 12m docker start --attach "$container" +timeout --signal=TERM --kill-after=30s 35m docker start --attach "$container" status=$(docker inspect --format '{{.State.ExitCode}}' "$container") if [[ "$status" != 0 ]]; then echo "Container fixture failed with exit code $status" >&2 diff --git a/containers/sfm/verify.py b/containers/sfm/verify.py index 7fdb5b25a..412e133fb 100644 --- a/containers/sfm/verify.py +++ b/containers/sfm/verify.py @@ -1,38 +1,50 @@ -"""Verify new game-puppet screenshots and completion, using only Python's stdlib.""" +"""Verify independent, fresh game-puppet runs using only Python's stdlib.""" import json -from collections import Counter from pathlib import Path import re import struct import sys -root = Path(sys.argv[1]).resolve() -log = Path(sys.argv[2]).read_text(encoding="utf-8", errors="replace") -if not re.search(r"SFM_GAME_PUPPET_COMPLETE failed=0 total=[1-9]\d*", log): - raise SystemExit("Missing successful game-puppet completion marker") -manifest = json.loads((root / "preview-manifest.json").read_text(encoding="utf-8")) -captures = manifest.get("captures", []) -puppets = {capture["puppet"].removeprefix("sfm:") for capture in captures} -expected = {"title_screen_capture", "game_test_orbit_capture"} -if not expected <= puppets: - raise SystemExit(f"Missing requested screenshot sets: {expected - puppets}") -counts = Counter(capture["puppet"].removeprefix("sfm:") for capture in captures) -if counts["title_screen_capture"] < 3 or counts["game_test_orbit_capture"] < 8: - raise SystemExit(f"Expected 3 title-screen and 8 orbit captures, found {counts}") -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']}") - header = path.read_bytes()[: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}") -print(json.dumps({"passed": True, "puppets": sorted(puppets), "screenshots": len(seen)}, indent=2)) +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}") + log = (run / "console.log").read_text(encoding="utf-8", errors="replace") + if not re.search(r"SFM_GAME_PUPPET_COMPLETE failed=0 total=1\b", 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 not expected_captures <= capture_names: + raise SystemExit(f"Missing captures for {puppet}: {expected_captures - 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/docs/tasks/ci and container puppet experiment plan.md b/docs/tasks/ci and container puppet experiment plan.md index 3f190edb5..206b94a49 100644 --- a/docs/tasks/ci and container puppet experiment plan.md +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -69,6 +69,12 @@ list`, and `podman system connection list`. ## [~] 2. Build and deliver a mod artifact from the feature branch +**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. The initial +run has independent graphics, build/test/package, and full container jobs. + **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. @@ -83,6 +89,20 @@ recorded without claiming a passing build. ## [~] 3. Run a graphical puppet inside a restricted Docker worker +**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`. Full Minecraft execution and the all-feature Rust checks +are pending. + **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, @@ -125,7 +145,7 @@ and identify the remaining production decisions. - Target: `ci/1.19.2-container-puppet`, base `707f53f4a`. - Existing installed CLI reports `ea4dcc9aa`, older than this source; CI must build its own current-source executable. -- Tooling source changes: none initially; installation responsibility must be revisited if portability fixes change Rust source. +- Tooling source changes: portable serialized-path conversion in `jar_build/json_path.rs`; final rebuild/install and verification are required. - Dependency posture: frozen project dependencies; new container infrastructure as scoped above. - New developer/reference clones: none. - Process preflight: no local game launch or process termination is planned; hosted workers own their test processes. From bd6aff529e3b02952bae32d24bad78c57d460bfd Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:23:26 -0400 Subject: [PATCH 03/17] Use UTF-8 when compiling Java fixtures in Docker --- containers/sfm/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/containers/sfm/Dockerfile b/containers/sfm/Dockerfile index 076da7a38..0318c4c3c 100644 --- a/containers/sfm/Dockerfile +++ b/containers/sfm/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && 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 \ From 69890f0923bd9338cb81fe0e5964aeaddb3f8dca Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:27:31 -0400 Subject: [PATCH 04/17] Verify native Windows builds alongside the Linux experiment --- .github/workflows/windows-ci.yml | 192 ++++++++++++++++++ ...ci and container puppet experiment plan.md | 29 ++- 2 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/windows-ci.yml diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml new file mode 100644 index 000000000..644b354b3 --- /dev/null +++ b/.github/workflows/windows-ci.yml @@ -0,0 +1,192 @@ +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' + JAVA_TOOL_OPTIONS: '-Dfile.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/docs/tasks/ci and container puppet experiment plan.md b/docs/tasks/ci and container puppet experiment plan.md index 206b94a49..e827bdea6 100644 --- a/docs/tasks/ci and container puppet experiment plan.md +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -72,8 +72,15 @@ list`, and `podman system connection list`. **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. The initial -run has independent graphics, build/test/package, and full container jobs. +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. **Work:** Add a push/PR workflow with least permissions, explicit 1.19.2 scope, fresh-checkout tooling, bounded jobs, preserved failure diagnostics, and mod @@ -100,8 +107,18 @@ 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`. Full Minecraft execution and the all-feature Rust checks -are pending. +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. Full Minecraft execution is pending. + +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 installed JDK source content/hash differs from its recorded JDK +fixtures (for example, `String.java` has 4660 lines instead of 4656). 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 @@ -144,8 +161,8 @@ and identify the remaining production decisions. ## Operational readiness - Target: `ci/1.19.2-container-puppet`, base `707f53f4a`. -- Existing installed CLI reports `ea4dcc9aa`, older than this source; CI must build its own current-source executable. -- Tooling source changes: portable serialized-path conversion in `jar_build/json_path.rs`; final rebuild/install and verification are required. +- 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 `bd6aff529`. No user installer step is required. CI builds its own executable from each event revision. - Dependency posture: frozen project dependencies; new container infrastructure as scoped above. - New developer/reference clones: none. - Process preflight: no local game launch or process termination is planned; hosted workers own their test processes. From 4eeaee45f7e956b7d3a45900df1a0f68dd797544 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:34:26 -0400 Subject: [PATCH 05/17] Capture the pinned Vox Linux test failure in isolation --- .github/workflows/vox-diagnostic.yml | 59 ++++++++++++++ containers/sfm/vox-diagnostic.py | 118 +++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 .github/workflows/vox-diagnostic.yml create mode 100644 containers/sfm/vox-diagnostic.py diff --git a/.github/workflows/vox-diagnostic.yml b/.github/workflows/vox-diagnostic.yml new file mode 100644 index 000000000..ae63c1c4c --- /dev/null +++ b/.github/workflows/vox-diagnostic.yml @@ -0,0 +1,59 @@ +name: Pinned Vox Linux diagnostic + +on: + push: + branches: + - 'ci/1.19.2-vox-diagnostic' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: vox-diagnostic-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + original-test: + name: Unchanged pinned VoxRuntimeTest with driver diagnostics + 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 locked Java 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 diff --git a/containers/sfm/vox-diagnostic.py b/containers/sfm/vox-diagnostic.py new file mode 100644 index 000000000..2f62f6111 --- /dev/null +++ b/containers/sfm/vox-diagnostic.py @@ -0,0 +1,118 @@ +"""Diagnose the unchanged locked Vox Java test, without rebuilding Rust or SFM.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("workspace", "scratch", "artifacts", "java-home"): + parser.add_argument("--" + name, type=Path, required=True) + args = parser.parse_args() + 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": []} + + 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] + source = artifact["source_git"] + revision = source["commit"] + remote = source["remote_url"] + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise RuntimeError("Locked Vox source must name an immutable Git commit") + if remote.rstrip("/") != "https://github.com/TeamDman/facet": + raise RuntimeError("Diagnostic is restricted to the existing locked Facet repository") + receipt.update({"source_revision": revision, "source_remote": remote, + "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")}}) + # Transient materialization of the already-locked source; no developer clone or lock edits. + 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 SFM lock") + + 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) + environment["VOX_DLOG"] = "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"}) + 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()) From e8318d2f0ed2a2ba5c66c91e4ed83dd3ebaf027f Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:43:34 -0400 Subject: [PATCH 06/17] Compare pinned Vox with a reviewed late-credit candidate --- .github/workflows/vox-diagnostic.yml | 41 ++++ containers/sfm/vox-diagnostic.py | 45 +++- containers/sfm/vox-diagnostic/README.md | 75 +++++++ .../vox-diagnostic/VoxMissingCreditProbe.java | 204 ++++++++++++++++++ .../sfm/vox-diagnostic/credit-candidate.patch | 50 +++++ 5 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 containers/sfm/vox-diagnostic/README.md create mode 100644 containers/sfm/vox-diagnostic/VoxMissingCreditProbe.java create mode 100644 containers/sfm/vox-diagnostic/credit-candidate.patch diff --git a/.github/workflows/vox-diagnostic.yml b/.github/workflows/vox-diagnostic.yml index ae63c1c4c..058db95e8 100644 --- a/.github/workflows/vox-diagnostic.yml +++ b/.github/workflows/vox-diagnostic.yml @@ -57,3 +57,44 @@ jobs: path: build/vox-diagnostic/ if-no-files-found: error retention-days: 14 + + candidate-test: + name: Review candidate in disposable pinned source only + 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/containers/sfm/vox-diagnostic.py b/containers/sfm/vox-diagnostic.py index 2f62f6111..7070f9b4d 100644 --- a/containers/sfm/vox-diagnostic.py +++ b/containers/sfm/vox-diagnostic.py @@ -13,6 +13,8 @@ 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 pinned source") args = parser.parse_args() workspace = args.workspace.resolve() scratch = args.scratch.resolve() @@ -21,7 +23,8 @@ def main(): 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": []} + receipt = {"test_runs": 0, "outcome": "setup", "commands": [], + "candidate": args.candidate} def save_receipt(): (artifacts / "receipt.json").write_text( @@ -74,6 +77,19 @@ def required(command, log_name, timeout, **kwargs): if actual != revision: raise RuntimeError("Fetched source revision differs from the SFM lock") + 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") @@ -98,6 +114,11 @@ def required(command, log_name, timeout, **kwargs): 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: + 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" receipt["test_runs"] = 1 code = run([str(java), "-ea", @@ -105,6 +126,28 @@ def required(command, log_name, timeout, **kwargs): "-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)}) diff --git a/containers/sfm/vox-diagnostic/README.md b/containers/sfm/vox-diagnostic/README.md new file mode 100644 index 000000000..bf2e74008 --- /dev/null +++ b/containers/sfm/vox-diagnostic/README.md @@ -0,0 +1,75 @@ +# Vox credit retirement diagnostic + +This is a review experiment for the source already pinned by the SFM toolchain +lock. The diagnostic workflow applies `credit-candidate.patch` only inside a new +temporary checkout at that exact commit. SFM builds do not use this patch, and no +dependency declaration, lockfile, or published artifact is changed. + +## 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. + +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 + +Each workflow job runs the unchanged full `VoxRuntimeTest` once. The original job +uses untouched pinned 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; From 06e8fe9ce378fa5f1d6e91d7077e7b2a8d4d8741 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:47:00 -0400 Subject: [PATCH 07/17] Retain a deterministic Linux reproduction of late credit --- .github/workflows/vox-diagnostic.yml | 52 +++++++++++++++++++++++++ containers/sfm/vox-diagnostic.py | 33 +++++++++++++++- containers/sfm/vox-diagnostic/README.md | 16 +++++++- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/.github/workflows/vox-diagnostic.yml b/.github/workflows/vox-diagnostic.yml index 058db95e8..6ba9f813e 100644 --- a/.github/workflows/vox-diagnostic.yml +++ b/.github/workflows/vox-diagnostic.yml @@ -5,6 +5,14 @@ on: 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 @@ -18,8 +26,51 @@ defaults: 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: @@ -60,6 +111,7 @@ jobs: 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: diff --git a/containers/sfm/vox-diagnostic.py b/containers/sfm/vox-diagnostic.py index 7070f9b4d..05d375005 100644 --- a/containers/sfm/vox-diagnostic.py +++ b/containers/sfm/vox-diagnostic.py @@ -15,7 +15,11 @@ def main(): parser.add_argument("--" + name, type=Path, required=True) parser.add_argument("--candidate", action="store_true", help="Apply the review candidate only to the disposable pinned source") + 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() @@ -24,7 +28,7 @@ def main(): # 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} + "candidate": args.candidate, "reduced_only": args.reduced_only} def save_receipt(): (artifacts / "receipt.json").write_text( @@ -114,12 +118,37 @@ def required(command, log_name, timeout, **kwargs): 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: + 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"), diff --git a/containers/sfm/vox-diagnostic/README.md b/containers/sfm/vox-diagnostic/README.md index bf2e74008..38712671a 100644 --- a/containers/sfm/vox-diagnostic/README.md +++ b/containers/sfm/vox-diagnostic/README.md @@ -14,6 +14,10 @@ the same pinned Java sources with JBR 17.0.6 exposed the earlier driver exceptio 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 @@ -56,7 +60,17 @@ no-op would need a separate review. ## Validation contract -Each workflow job runs the unchanged full `VoxRuntimeTest` once. The original job +Pushes to the diagnostic branch and the default manual mode (`reduced`) compile +fresh, untouched pinned 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 pinned 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 From 859b6cbf468bd469cf9f9463a4ce70fcae74aaba Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 14:57:52 -0400 Subject: [PATCH 08/17] Scope Java options so dependency checks see clean output --- .github/workflows/windows-ci.yml | 4 +++- containers/sfm/Dockerfile | 1 - containers/sfm/README.md | 8 ++++++++ containers/sfm/run.sh | 6 +++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml index 644b354b3..786e95ec7 100644 --- a/.github/workflows/windows-ci.yml +++ b/.github/workflows/windows-ci.yml @@ -32,7 +32,9 @@ jobs: CARGO_BUILD_JOBS: '2' RUST_BACKTRACE: '1' RUSTUP_TOOLCHAIN: '1.96.0' - JAVA_TOOL_OPTIONS: '-Dfile.encoding=UTF-8' + # 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 diff --git a/containers/sfm/Dockerfile b/containers/sfm/Dockerfile index 0318c4c3c..678174f32 100644 --- a/containers/sfm/Dockerfile +++ b/containers/sfm/Dockerfile @@ -22,7 +22,6 @@ ENV JAVA_HOME=/opt/java \ GALLIUM_DRIVER=llvmpipe \ LP_NUM_THREADS=2 \ ALSOFT_DRIVERS=null \ - JAVA_TOOL_OPTIONS="-Xmx3g -XX:ActiveProcessorCount=4" \ CARGO_BUILD_JOBS=2 COPY containers/sfm/graphics.sh /opt/sfm-container/graphics.sh diff --git a/containers/sfm/README.md b/containers/sfm/README.md index bb751bce8..fbb7e4273 100644 --- a/containers/sfm/README.md +++ b/containers/sfm/README.md @@ -59,6 +59,14 @@ 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. +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). + Evidence is copied to `build/container-smoke` even when the game fails: - `glxinfo.txt` and `isolation.txt`: actual renderer and runtime assertions. diff --git a/containers/sfm/run.sh b/containers/sfm/run.sh index 560fa6d9c..0e3e3228b 100644 --- a/containers/sfm/run.sh +++ b/containers/sfm/run.sh @@ -79,7 +79,11 @@ for puppet in title_screen_capture game_test_orbit_capture; do options+=(--game-test sfm:move_1_stack_direct) fi status=0 - sfm-propagate-changes puppet run "$puppet" "${options[@]}" \ + # 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 From 7274bff1387e74df1b06db1197b79e5c6efeb098 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 21:26:57 -0400 Subject: [PATCH 09/17] fix(ci): pin Vox credit repair and make fixtures portable --- .gitattributes | 3 + .github/README.md | 32 ++++ containers/sfm/README.md | 11 +- containers/sfm/run.sh | 44 ++++++ containers/sfm/test_verify.py | 113 ++++++++++++++ containers/sfm/verify.py | 9 +- ...ci and container puppet experiment plan.md | 140 +++++++++++++++++- platform/minecraft/sfm-toolchain.lock.json | 10 +- .../sfm/template_programs/changelog.sfml | 1 + .../SFMReleaseReviewCreateRuntimeTests.java | 2 +- .../SFMExplorerNavigationChoicesTests.java | 17 ++- .../SFMDefinitionContextAdapterTests.java | 31 ++-- .../SFMFindReferencesControllerTests.java | 3 +- .../SFMJavaInteractionMapSessionTests.java | 53 +++++-- .../SFMJumpToDefinitionActionTests.java | 21 ++- .../SFMSymbolDefinitionPaletteTests.java | 66 +++++---- .../SFMSymbolInspectionSnapshotTests.java | 14 +- ...FMSymbolServerNavigationProviderTests.java | 14 +- .../symbol/SFMSymbolServerProtocolTests.java | 39 +++-- 19 files changed, 506 insertions(+), 117 deletions(-) create mode 100644 containers/sfm/test_verify.py 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 index 5502ec02f..93313c5f3 100644 --- a/.github/README.md +++ b/.github/README.md @@ -14,6 +14,38 @@ The delivery output is a downloadable Actions artifact named 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 workflows are under development. Docker has built the mod and completed both +puppets during image preparation, producing all 11 captures. The fresh offline +run and complete native JUnit/package jobs remain the acceptance checks. + +| 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 | + +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 diff --git a/containers/sfm/README.md b/containers/sfm/README.md index fbb7e4273..92e549601 100644 --- a/containers/sfm/README.md +++ b/containers/sfm/README.md @@ -70,16 +70,19 @@ The Java launcher still records its own options notice in the game logs. 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 `console.log`, - `game-logs/`, `exit-code.txt`, and `previews/` with the existing SFM HTML preview, - manifest, and screenshots. +- `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`: container launch and failure diagnostics. - `verification.json`, `exit-code.txt`, and `source-revision.txt`: result and input. - `docker-inspect.json`: the 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; its build log is the evidence in that case. A downloaded dependency that +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 diff --git a/containers/sfm/run.sh b/containers/sfm/run.sh index 0e3e3228b..0ba2fbf11 100644 --- a/containers/sfm/run.sh +++ b/containers/sfm/run.sh @@ -20,6 +20,43 @@ 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 @@ -53,9 +90,15 @@ 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/" @@ -73,6 +116,7 @@ for puppet in title_screen_capture game_test_orbit_capture; do 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 diff --git a/containers/sfm/test_verify.py b/containers/sfm/test_verify.py new file mode 100644 index 000000000..8253061ae --- /dev/null +++ b/containers/sfm/test_verify.py @@ -0,0 +1,113 @@ +"""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) + + +if __name__ == "__main__": + unittest.main() diff --git a/containers/sfm/verify.py b/containers/sfm/verify.py index 412e133fb..ca61b9c73 100644 --- a/containers/sfm/verify.py +++ b/containers/sfm/verify.py @@ -17,8 +17,13 @@ run = artifacts / puppet if (run / "exit-code.txt").read_text().strip() != "0": raise SystemExit(f"Puppet process failed: {puppet}") - log = (run / "console.log").read_text(encoding="utf-8", errors="replace") - if not re.search(r"SFM_GAME_PUPPET_COMPLETE failed=0 total=1\b", log): + # 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")) diff --git a/docs/tasks/ci and container puppet experiment plan.md b/docs/tasks/ci and container puppet experiment plan.md index e827bdea6..2f2d83d71 100644 --- a/docs/tasks/ci and container puppet experiment plan.md +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -23,6 +23,7 @@ implementation, container implementation, and integration/validation. | 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. | ## Intent audit evidence @@ -43,8 +44,9 @@ 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. Deterministic restoration of -their pinned inputs is allowed. Container base images and OS graphics/build +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. @@ -69,6 +71,27 @@ list`, and `podman system connection list`. ## [~] 2. Build and deliver a mod artifact from the feature branch +**Current checkpoint:** The latest 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. Fresh hosted builds will verify this exact pin. + **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 @@ -82,6 +105,76 @@ 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. + +**Remaining acceptance:** Verify the published Vox source/hash on fresh Linux +and Windows runners, then pass the full canonical JUnit and packaging jobs. +No source-test bypass, arbitrary cached JAR, or hash relaxation is accepted. + **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. @@ -96,6 +189,16 @@ recorded without claiming a passing build. ## [~] 3. Run a graphical puppet inside a restricted Docker worker +**Current 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 rejects +failure markers. Nine focused verifier regressions pass, including misleading +wrapper output, duplicate completion, missing images and failing process exits. +The next run must repeat both puppets after disabling networking and applying +all runtime restrictions; preparation screenshots alone do not satisfy that. + **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`: @@ -109,7 +212,9 @@ 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. Full Minecraft execution is pending. +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 @@ -133,7 +238,7 @@ output from the actual game. Verify runtime settings with container inspection. operation under the stated restrictions, or records the first actual failing layer without substituting a desktop-only test. -## [ ] 4. Review isolation and provide reproducible handoff +## [~] 4. Review isolation and provide reproducible handoff **Work:** Document exact tested commands, evidence and limitations. Describe a Discord broker/job boundary and Kubernetes translation, with ephemeral jobs, @@ -163,7 +268,30 @@ and identify the remaining production decisions. - Target: `ci/1.19.2-container-puppet`, base `707f53f4a`. - 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 `bd6aff529`. No user installer step is required. CI builds its own executable from each event revision. -- Dependency posture: frozen project dependencies; new container infrastructure as scoped above. +- 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: no local game launch or process termination is planned; hosted workers own their test processes. -- Final process state, exact test commands, artifact evidence and remote run URL: pending validation. +- 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: still clean on `1.19.2` at `707f53f4a` when rechecked after + the candidate diagnostic was prepared. +- 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: no local Minecraft instance was launched. Hosted jobs own and + clean up their test processes. New combined verification is pending. +- 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`. Preparation has reached both real game puppets; + fresh offline execution remains the acceptance check. +- Runtime limitation: this workstation has no running Linux container engine; + the proven graphics test ran on GitHub-hosted Linux. Discord and Kubernetes + remain design handoffs, not deployed services. 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/resources/assets/sfm/template_programs/changelog.sfml b/platform/minecraft/src/main/resources/assets/sfm/template_programs/changelog.sfml index 723889d2b..0af02d433 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,7 @@ NAME "Changelog" -- https://github.com/TeamDman/SuperFactoryManager/issues/new ---- 4.35.0 PRE ---- +-- 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/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", From d7e22e73ee5e864f3e00adda9118cd8592b33e44 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 21:36:10 -0400 Subject: [PATCH 10/17] fix(ci): keep puppet evidence fresh and retain Vox baseline --- .github/workflows/ci.yml | 5 ++ .github/workflows/vox-diagnostic.yml | 2 +- containers/sfm/README.md | 1 + containers/sfm/smoke.sh | 10 +++- containers/sfm/test_smoke.sh | 53 +++++++++++++++++++ containers/sfm/test_verify.py | 18 +++++++ containers/sfm/verify.py | 5 +- containers/sfm/vox-diagnostic.py | 29 ++++++---- containers/sfm/vox-diagnostic/README.md | 24 ++++++--- ...ci and container puppet experiment plan.md | 10 ++-- 10 files changed, 133 insertions(+), 24 deletions(-) create mode 100644 containers/sfm/test_smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28a56b0e0..28b3528a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,11 @@ jobs: --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 + - name: Verify software OpenGL inside the restricted container timeout-minutes: 3 run: | diff --git a/.github/workflows/vox-diagnostic.yml b/.github/workflows/vox-diagnostic.yml index 6ba9f813e..e929f9997 100644 --- a/.github/workflows/vox-diagnostic.yml +++ b/.github/workflows/vox-diagnostic.yml @@ -91,7 +91,7 @@ jobs: 2>&1 | tee build/vox-diagnostic/jdk-install.log echo "SFM_DIAGNOSTIC_JAVA_HOME=$RUNNER_TEMP/vox-diagnostic-jdk" >> "$GITHUB_ENV" - - name: Compile locked Java sources and run the original test once + - name: Compile recorded baseline sources and run the original test once timeout-minutes: 10 run: | python3 containers/sfm/vox-diagnostic.py \ diff --git a/containers/sfm/README.md b/containers/sfm/README.md index 92e549601..b1fe2d972 100644 --- a/containers/sfm/README.md +++ b/containers/sfm/README.md @@ -67,6 +67,7 @@ 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. diff --git a/containers/sfm/smoke.sh b/containers/sfm/smoke.sh index 1708213fe..d867c1d0a 100644 --- a/containers/sfm/smoke.sh +++ b/containers/sfm/smoke.sh @@ -7,7 +7,15 @@ if [[ $# -gt 2 ]]; then echo 'Usage: smoke.sh [image] [artifact-directory]' >&2 exit 2 fi -mkdir -p "$artifacts" +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 container= cleanup() { diff --git a/containers/sfm/test_smoke.sh b/containers/sfm/test_smoke.sh new file mode 100644 index 000000000..c64fb0593 --- /dev/null +++ b/containers/sfm/test_smoke.sh @@ -0,0 +1,53 @@ +#!/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 immediately at Docker creation: accepted destinations must reach this +# stub, while rejected destinations must never invoke any Docker operation. +cat > "$scratch/bin/docker" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$DOCKER_CALL_LOG" +exit 73 +SH +chmod +x "$scratch/bin/docker" + +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 status=0 + PATH="$scratch/bin:$PATH" DOCKER_CALL_LOG="$scratch/docker-calls" \ + 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/docker-calls" ]] +done +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/docker-calls") -eq 2 ]] +[[ $(grep -c '^create ' "$scratch/docker-calls") -eq 2 ]] +echo 'PASS: stale and hidden output rejected unchanged; empty and new output reach Docker.' diff --git a/containers/sfm/test_verify.py b/containers/sfm/test_verify.py index 8253061ae..fde7dfa58 100644 --- a/containers/sfm/test_verify.py +++ b/containers/sfm/test_verify.py @@ -108,6 +108,24 @@ def test_incomplete_manifest_fails(self): 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 index ca61b9c73..486d3940c 100644 --- a/containers/sfm/verify.py +++ b/containers/sfm/verify.py @@ -32,8 +32,9 @@ if observed_puppets != {puppet}: raise SystemExit(f"Unexpected screenshot set for {puppet}: {observed_puppets}") capture_names = {capture["capture"] for capture in captures} - if not expected_captures <= capture_names: - raise SystemExit(f"Missing captures for {puppet}: {expected_captures - capture_names}") + 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() diff --git a/containers/sfm/vox-diagnostic.py b/containers/sfm/vox-diagnostic.py index 05d375005..eaaa58874 100644 --- a/containers/sfm/vox-diagnostic.py +++ b/containers/sfm/vox-diagnostic.py @@ -1,4 +1,4 @@ -"""Diagnose the unchanged locked Vox Java test, without rebuilding Rust or SFM.""" +"""Replay the recorded original Vox Java failure without rebuilding Rust or SFM.""" import argparse import hashlib import json @@ -9,12 +9,19 @@ 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 pinned source") + 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() @@ -58,18 +65,18 @@ def required(command, log_name, timeout, **kwargs): if len(candidates) != 1: raise RuntimeError("Expected exactly one locked vox-java/main artifact") artifact = candidates[0] - source = artifact["source_git"] - revision = source["commit"] - remote = source["remote_url"] + revision = BASELINE_REVISION + remote = BASELINE_REMOTE if not re.fullmatch(r"[0-9a-f]{40}", revision): - raise RuntimeError("Locked Vox source must name an immutable Git commit") - if remote.rstrip("/") != "https://github.com/TeamDman/facet": - raise RuntimeError("Diagnostic is restricted to the existing locked Facet repository") + raise RuntimeError("Recorded Vox baseline must name an immutable Git commit") receipt.update({"source_revision": revision, "source_remote": remote, - "locked_artifact_hash": artifact["hash"], + "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")}}) - # Transient materialization of the already-locked source; no developer clone or lock edits. + # 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) @@ -79,7 +86,7 @@ def required(command, log_name, timeout, **kwargs): "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 SFM lock") + raise RuntimeError("Fetched source revision differs from the recorded baseline") if args.candidate: patch = workspace / "containers/sfm/vox-diagnostic/credit-candidate.patch" diff --git a/containers/sfm/vox-diagnostic/README.md b/containers/sfm/vox-diagnostic/README.md index 38712671a..8d9e9b7bb 100644 --- a/containers/sfm/vox-diagnostic/README.md +++ b/containers/sfm/vox-diagnostic/README.md @@ -1,9 +1,21 @@ # Vox credit retirement diagnostic -This is a review experiment for the source already pinned by the SFM toolchain -lock. The diagnostic workflow applies `credit-candidate.patch` only inside a new -temporary checkout at that exact commit. SFM builds do not use this patch, and no -dependency declaration, lockfile, or published artifact is changed. +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 @@ -61,7 +73,7 @@ no-op would need a separate review. ## Validation contract Pushes to the diagnostic branch and the default manual mode (`reduced`) compile -fresh, untouched pinned sources and run only two deterministic probe inputs once: +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 @@ -71,7 +83,7 @@ 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 pinned sources and remains failed when that test fails. The +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. diff --git a/docs/tasks/ci and container puppet experiment plan.md b/docs/tasks/ci and container puppet experiment plan.md index 2f2d83d71..0d7e6a355 100644 --- a/docs/tasks/ci and container puppet experiment plan.md +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -193,9 +193,13 @@ recorded without claiming a passing build. 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 rejects -failure markers. Nine focused verifier regressions pass, including misleading -wrapper output, duplicate completion, missing images and failing process exits. +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 next run must repeat both puppets after disabling networking and applying all runtime restrictions; preparation screenshots alone do not satisfy that. From 01b2391247f8e67df2c94984b8e0f9acdfbc3931 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Sat, 19 Sep 2026 22:15:46 -0400 Subject: [PATCH 11/17] docs: record passing CI and offline Minecraft results --- .github/README.md | 19 +++- containers/sfm/README.md | 26 +++++ ...ci and container puppet experiment plan.md | 99 ++++++++++++++----- 3 files changed, 115 insertions(+), 29 deletions(-) diff --git a/.github/README.md b/.github/README.md index 93313c5f3..521e2db06 100644 --- a/.github/README.md +++ b/.github/README.md @@ -22,9 +22,16 @@ the SFM build. ## Observed experiment results -The workflows are under development. Docker has built the mod and completed both -puppets during image preparation, producing all 11 captures. The fresh offline -run and complete native JUnit/package jobs remain the acceptance checks. +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 | | --- | --- | @@ -36,6 +43,8 @@ run and complete native JUnit/package jobs remain the acceptance checks. | [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 @@ -75,8 +84,8 @@ gh run download --repo TeamDman/SuperFactoryManager --dir build/ci-down 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 complete -game run is a separate acceptance check. +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). diff --git a/containers/sfm/README.md b/containers/sfm/README.md index b1fe2d972..d3cbf8537 100644 --- a/containers/sfm/README.md +++ b/containers/sfm/README.md @@ -4,6 +4,25 @@ Run the graphics probe first, then the complete SFM puppet fixture. These are Li 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. + +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: @@ -59,6 +78,13 @@ 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 diff --git a/docs/tasks/ci and container puppet experiment plan.md b/docs/tasks/ci and container puppet experiment plan.md index 0d7e6a355..b2ed2c351 100644 --- a/docs/tasks/ci and container puppet experiment plan.md +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -1,9 +1,9 @@ # CI and container puppet experiment -**Plan status:** Active +**Plan status:** Complete **Primary implementation root:** branch `ci/1.19.2-container-puppet`, based on `707f53f4a` **Last updated:** 2026-09-19 -**Intent audit:** Passed against the initial CI/container request +**Intent audit:** Passed against U1-U8, including the authorized Vox update ## How to update this plan @@ -69,9 +69,9 @@ or changing OS virtualization configuration. status`, `gh workflow list`, `wsl --status`, `podman version`, `podman machine list`, and `podman system connection list`. -## [~] 2. Build and deliver a mod artifact from the feature branch +## [x] 2. Build and deliver a mod artifact from the feature branch -**Current checkpoint:** The latest native Linux and Windows runs compiled all +**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 @@ -90,7 +90,25 @@ 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. Fresh hosted builds will verify this exact pin. +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) @@ -171,9 +189,9 @@ 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. -**Remaining acceptance:** Verify the published Vox source/hash on fresh Linux -and Windows runners, then pass the full canonical JUnit and packaging jobs. -No source-test bypass, arbitrary cached JAR, or hash relaxation is accepted. +**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 @@ -187,9 +205,25 @@ at the exact event commit so CLI worktree selection also works for PR checkouts. and produces the mod JAR, or a reproducible upstream blocker is precisely recorded without claiming a passing build. -## [~] 3. Run a graphical puppet inside a restricted Docker worker - -**Current checkpoint:** Run `35462847488` built the distributable mod and ran +## [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. + +**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 @@ -200,8 +234,8 @@ 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 next run must repeat both puppets after disabling networking and applying -all runtime restrictions; preparation screenshots alone do not satisfy that. +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 @@ -224,9 +258,13 @@ 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 installed JDK source content/hash differs from its recorded JDK -fixtures (for example, `String.java` has 4660 lines instead of 4656). Snapshots -were not changed. An initial sandbox-only inability to launch `rg` was resolved +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. @@ -242,7 +280,18 @@ output from the actual game. Verify runtime settings with container inspection. operation under the stated restrictions, or records the first actual failing layer without substituting a desktop-only test. -## [~] 4. Review isolation and provide reproducible handoff +## [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. **Work:** Document exact tested commands, evidence and limitations. Describe a Discord broker/job boundary and Kubernetes translation, with ephemeral jobs, @@ -271,7 +320,7 @@ and identify the remaining production decisions. - Target: `ci/1.19.2-container-puppet`, base `707f53f4a`. - 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 `bd6aff529`. No user installer step is required. CI builds its own executable from each event revision. +- 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. @@ -286,16 +335,18 @@ and identify the remaining production decisions. - 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: no local Minecraft instance was launched. Hosted jobs own and - clean up their test processes. New combined verification is pending. +- Process state: no local Minecraft instance was launched. The successful hosted + jobs finished and own/clean up their workers and test processes. No task-owned + local Minecraft, Cargo or helper process remains running. - 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`. Preparation has reached both real game puppets; - fresh offline execution remains the acceptance check. -- Runtime limitation: this workstation has no running Linux container engine; - the proven graphics test ran on GitHub-hosted Linux. Discord and Kubernetes + 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. +- Runtime scope: the experiment ran on GitHub-hosted Linux Docker; no local + container engine was required or configured. Discord and Kubernetes remain design handoffs, not deployed services. From 27676f5be7865c30643bbd0c9a58be7e3685d1c6 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Wed, 23 Sep 2026 14:01:53 -0400 Subject: [PATCH 12/17] Support local Podman puppet smoke runs --- containers/sfm/README.md | 54 ++++++++++++++++++++-- containers/sfm/smoke.sh | 52 ++++++++++++++++----- containers/sfm/test_smoke.sh | 88 ++++++++++++++++++++++++++++++++---- 3 files changed, 170 insertions(+), 24 deletions(-) diff --git a/containers/sfm/README.md b/containers/sfm/README.md index d3cbf8537..3ef2ed7a7 100644 --- a/containers/sfm/README.md +++ b/containers/sfm/README.md @@ -18,6 +18,16 @@ The raw game logs report both puppet completions and 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; @@ -36,6 +46,20 @@ docker run --rm --network none --read-only --cap-drop ALL \ 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. @@ -55,6 +79,30 @@ docker build --build-arg SFM_SOURCE_REVISION="$(git rev-parse HEAD)" \ 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 @@ -102,9 +150,9 @@ Evidence is copied to `build/container-smoke` even when the game fails: `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`: container launch and failure diagnostics. +- `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`: the container configuration and final process status. +- `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 @@ -115,7 +163,7 @@ 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, Docker's seccomp filter, no external network interfaces, a read-only +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 diff --git a/containers/sfm/smoke.sh b/containers/sfm/smoke.sh index d867c1d0a..4a2063f77 100644 --- a/containers/sfm/smoke.sh +++ b/containers/sfm/smoke.sh @@ -3,10 +3,18 @@ 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 @@ -16,37 +24,59 @@ 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 - docker logs "$container" > "$artifacts/docker.log" 2>&1 || true - docker inspect "$container" > "$artifacts/docker-inspect.json" || true - docker cp "$container:/workspace/container-artifacts/." "$artifacts/" || true - docker rm -f -v "$container" >/dev/null || true + "$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. -container=$(docker create --network none --read-only --user 10001:10001 \ +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 \ - --shm-size 256m --init \ - --tmpfs /tmp:rw,exec,nosuid,nodev,size=512m,mode=1777 \ - --tmpfs /home/sfm:rw,nosuid,nodev,size=128m,uid=10001,gid=10001,mode=700 \ + "${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 docker start --attach "$container" -status=$(docker inspect --format '{{.State.ExitCode}}' "$container") +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 -docker cp "$container:/workspace/container-artifacts/." "$artifacts/" +"$engine" cp "$container:/workspace/container-artifacts/." "$artifact_copy_destination/" echo "Offline container fixture passed. Artifacts: $artifacts" diff --git a/containers/sfm/test_smoke.sh b/containers/sfm/test_smoke.sh index c64fb0593..838f2b617 100644 --- a/containers/sfm/test_smoke.sh +++ b/containers/sfm/test_smoke.sh @@ -6,14 +6,25 @@ scratch=$(mktemp -d) trap 'rm -rf -- "$scratch"' EXIT mkdir -p "$scratch/bin" "$scratch/stale" "$scratch/hidden" "$scratch/empty" -# Stop immediately at Docker creation: accepted destinations must reach this -# stub, while rejected destinations must never invoke any Docker operation. +# 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\n' "$*" >> "$DOCKER_CALL_LOG" -exit 73 +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 -chmod +x "$scratch/bin/docker" +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" @@ -22,8 +33,14 @@ 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 - PATH="$scratch/bin:$PATH" DOCKER_CALL_LOG="$scratch/docker-calls" \ + 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 @@ -36,8 +53,10 @@ run_smoke() { 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/docker-calls" ]] + [[ ! -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') @@ -48,6 +67,55 @@ for destination in "$scratch/empty" "$scratch/new"; do run_smoke "$destination" 73 [[ -d "$destination" ]] done -[[ $(wc -l < "$scratch/docker-calls") -eq 2 ]] -[[ $(grep -c '^create ' "$scratch/docker-calls") -eq 2 ]] -echo 'PASS: stale and hidden output rejected unchanged; empty and new output reach Docker.' +[[ $(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.' From 73ead2d0a59dbfc1785990e2394cd8a95918ea30 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Wed, 23 Sep 2026 14:09:35 -0400 Subject: [PATCH 13/17] Wait for syntax worker termination in CI tests --- .../syntax/process/SFMSyntaxServerHighlightProviderTests.java | 2 ++ 1 file changed, 2 insertions(+) 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()); } From 9307a369aeb18661d95acb90171ac50d8e99120e Mon Sep 17 00:00:00 2001 From: TeamDman Date: Wed, 23 Sep 2026 14:39:08 -0400 Subject: [PATCH 14/17] Capture JUnit thread dumps during CI stalls --- .github/workflows/ci.yml | 19 ++ containers/sfm/junit-diagnostics.sh | 75 ++++++ containers/sfm/test_junit_diagnostics.sh | 303 +++++++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 containers/sfm/junit-diagnostics.sh create mode 100644 containers/sfm/test_junit_diagnostics.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28b3528a3..63cc56c1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,7 @@ jobs: 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 @@ -153,6 +154,24 @@ jobs: - 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 \ 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/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.' From 333f1cff827ddbd4eae8a2192e061768d8e65aac Mon Sep 17 00:00:00 2001 From: TeamDman Date: Wed, 23 Sep 2026 14:47:54 -0400 Subject: [PATCH 15/17] Await client gate observer before reading result --- .../teamdman/sfm/client/control/SFMClientThreadGateTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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()); From 5dbbaeae95fb83d676638a65b1aaa741c0416411 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Wed, 23 Sep 2026 15:34:00 -0400 Subject: [PATCH 16/17] Prevent explorer completion deadlock and retry failed review surfaces --- .../explorer/lazy/SFMLazyExplorerLoader.java | 129 +++++---- .../SFMReleaseReviewSurfaceRuntime.java | 19 +- .../sfm/template_programs/changelog.sfml | 2 + .../SFMLazyExplorerCompletionLockTests.java | 257 ++++++++++++++++++ 4 files changed, 347 insertions(+), 60 deletions(-) create mode 100644 platform/minecraft/src/test/java/ca/teamdman/sfm/client/explorer/lazy/SFMLazyExplorerCompletionLockTests.java 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 0af02d433..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,8 @@ 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 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(); } + } +} From f9b74fe9b5198f9d42cf51800282783b2d154ba6 Mon Sep 17 00:00:00 2001 From: TeamDman Date: Wed, 23 Sep 2026 17:30:19 -0400 Subject: [PATCH 17/17] docs: reconcile Podman and CI evidence --- ...ci and container puppet experiment plan.md | 144 +++++++++++++++--- 1 file changed, 127 insertions(+), 17 deletions(-) diff --git a/docs/tasks/ci and container puppet experiment plan.md b/docs/tasks/ci and container puppet experiment plan.md index b2ed2c351..624027849 100644 --- a/docs/tasks/ci and container puppet experiment plan.md +++ b/docs/tasks/ci and container puppet experiment plan.md @@ -1,9 +1,9 @@ # CI and container puppet experiment -**Plan status:** Complete +**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-19 -**Intent audit:** Passed against U1-U8, including the authorized Vox update +**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 @@ -24,13 +24,14 @@ implementation, container implementation, and integration/validation. | 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 recorded build verification, branch/worktree constraints, uncertain local Docker installation, Discord isolation purpose, graphics/Kubernetes question, existing puppet, and source checkout as U1-U7. -- Traceability: every requirement maps to a task and evidence; Docker images and workflow plumbing are reversible implementation choices within the requested experiment. -- Adversarial omission: preserved the future nature of the help bot and possible Kubernetes deployment; neither is represented as already deployed. The busy checkout remains outside the implementation working directory. -- Source limitation: none. +- 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 @@ -39,6 +40,14 @@ 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 @@ -65,6 +74,11 @@ 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`. @@ -189,6 +203,21 @@ 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. @@ -223,6 +252,28 @@ 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 @@ -293,6 +344,15 @@ 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, @@ -306,6 +366,40 @@ 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 | @@ -315,29 +409,37 @@ and identify the remaining production decisions. | 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`. +- 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: no local game launch or process termination is planned; hosted workers own their test processes. +- 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: still clean on `1.19.2` at `707f53f4a` when rechecked after - the candidate diagnostic was prepared. +- 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: no local Minecraft instance was launched. The successful hosted - jobs finished and own/clean up their workers and test processes. No task-owned - local Minecraft, Cargo or helper process remains running. +- 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`. @@ -347,6 +449,14 @@ and identify the remaining production decisions. 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. -- Runtime scope: the experiment ran on GitHub-hosted Linux Docker; no local - container engine was required or configured. Discord and Kubernetes - remain design handoffs, not deployed services. +- 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.