Gimme dat! #702
No reviewers
Labels
No labels
accepted
bug
dependencies
docker
documentation
duplicate
enhancement
github-actions
github_actions
good first issue
gradle
hacktoberfest-accepted
help wanted
invalid
javascript
not-an-issue
npm
question
rejected
wontfix
Working on it
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Griefed/ServerPackCreator!702
Loading…
Reference in a new issue
No description provided.
Delete branch "develop"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Griefed's call: code documentation explains the current state, for a reader who has never seen any other version of it. It does not explain what once was, or justify today's shape against yesterday's. The documentation this branch added broke that rule throughout, so this removes it from everything the branch touched -- 220 lines out, 155 in: ContainerFixtureBudget a paragraph tracing 60s -> 90s -> 300s through three run numbers and a table of workflow durations, where what a reader needs is "setup, not an assertion; sized so a loaded host cannot decide a verdict" DockerJavaContainerEngineIT six passages citing runs 681, 719, 726, 834, 954, 956 and an "old form" of an assertion that no longer exists ContainerOwnershipIT the 2026-09-27 interference narrative, and a fixture doc arguing against a number it no longer uses PowerShellInstallerJavaTest "this probe named /templates outright, so it was correct only in the container ... red there on 2026-10-02" EmbeddedMongoAvailable run 836's WiredTiger timings, and a constant defending its own spelling against a key retired in another class the four workflows run 739's login, "the comment that once said so was wrong", the pull_request doubling measured across a nine-run batch, and the dind reproduction's numbers What survives is the part a reader can act on. "BuildKit defaults a mirror to https, so the second stanza is required or it silently falls back" stays; "three runs died before we learned that" goes. The test applied throughout: does the sentence change what someone does, or only tell them a story? None of it is lost. Every incident, measurement, run number and date above is in this branch's other seven commit messages, which is where `git log -S` and `git blame` look, and the durable ones are in claude-docs/ and the module CLAUDE.md files. The convention is written into CLAUDE.md's **Conventions** section as well, because otherwise the next session regenerates exactly what this commit removes: documentation is present-tense, constraints stay and history moves to the commit message, and a fact lives in one place with the others referencing it by name. Fixed in passing, both stale rather than merely historical: the cache-address comment in both workflows pointed at section 3 of RUNNER-REGISTRY-CACHE.md for a compose file that is now section 1. Green after the rewrite: PowerShellInstallerJavaTest, WebServiceContextTest and WebPersistenceIT (15 tests), and both container IT classes under GRINDER_DOCKER_IT=1 (12 tests). All four workflows still parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>First batch of the repository-wide sweep: `.forgejo/workflows`, `.github/workflows`, `buildSrc` and the root build script. Removed throughout -- forge-migration archaeology ("replacing GitLab's `Build Release`", "ported from .github/workflows/devbuild.yml", "what the old GitLab `pages` job published"), run and job numbers (133, 472, 536, 644/936, 817, 832), dates on measurements, and commentary on earlier versions of the comment itself ("the previous wording had the premise right and the conclusion inverted", "an audit caught the first version listing the configurations a second time"). Kept, because a reader acts on it: that act exec's `node` inside the job container and so cannot take a tool image; that a `-v "$PWD:..."` bind under docker-in-docker resolves on the sibling daemon and silently mounts an empty directory; that Forgejo's `/actions/runs/{run}` means the per-repo index everywhere except the artifact route, which wants the id; that MAX_ARG_STRLEN caps one argv entry at 131,072 bytes, so a release body goes in a file; that an absent Gradle property stringifies to "null", which is not blank. Measurements kept their numbers and lost their dates, per Griefed: the AppImage glob still shows what two architectures produce and why a bare `*.AppImage` counts three, the Dokka HTML output is still ~1411 files / 20 MB. Two stale references fixed in passing, both naming infrastructure that is gone: `publishing-conventions` justified itself against `.gitlab-ci.yml` (now `.forgejo/workflows/release-build.yml`), and `docs.yml` explained a `--runner gitlab` flag it no longer passes. `./gradlew help` succeeds, every workflow still parses, and the comment-only check reports no code changed in any Kotlin file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Third batch. 69 passages across `-api`'s main and test sources. Removals follow the same rule as the earlier batches. Run and job numbers (472, 646, 1650), dated measurements ("Measured 2026-07-30: 517 verdicts over 3.5 h, zero HIGH", "Reproduced 2026-09-08: 53 wrappers, 268 log lines, OutOfMemoryError"), and sentences whose subject is a previous version of the code rather than the code ("It used to ask for `banner.txt`", "the first draft of this guard failed against correct code", "**And so does NeoForge, which used to throw instead.**"). Nearly all of these were arguing something true, so the argument is kept and restated about the code that exists. `JarUtilitiesTest` no longer says the test "used to assert only that the destination isFile"; it says why the assertion is on content — `copyFileFromJar` creates the destination before it resolves the stream, so `isFile` is true even when nothing was copied. `ScriptTemplateContentTest`'s exit-status guard no longer says the loop "used to end in an unconditional `exit 0`"; it says the loop must propagate the server's status, and what `BootLogClassifier` does with a `0` exit that has no ready-line. Same shape throughout. Measurements kept their numbers where the number is the point and lost their dates: the ~80 ms central-directory parse, the 4.71 ms vs 0.14 ms nested-find, the ~392 ms manifest refresh, Forge 1.20.2 dying at `SecureModuleClassLoader.<init>` while 1.21.1 reaches `Done (6.593s)!`. Two backlog identifiers went with them -- B30 and B31 named in doc comments. The tracker is `claude-docs/BACKLOG.md`; a doc comment that cites a ticket ages into a dangling reference. Not touched: 8 further matches in this module are present tense -- "@param old The old version", "files which no longer exist are skipped", "if the manifest has since been replaced", "the first attempt fails". A defect in my own verification turned up here and is worth recording, because it would have let a real change through unnoticed in the other direction: the comment-stripper used to end a Kotlin raw string at the FIRST `"""`, so `"""if test -s "x.jar""""` -- four consecutive quotes, which Kotlin closes with the last three -- left it parsing code as string and comments as code. Fixed to close on the last three of any run; re-verified. The whole branch now checks clean: 69 Kotlin files differ from `develop` in comments only. `:serverpackcreator-api:compileTestKotlin` succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Final module, and the largest: 203 passages across 73 files. The clientside engine's comments are almost entirely derived from live grinder output, so nearly every one carried a date. Most of it was the case Griefed named directly -- keep the fact, drop the date. 72 lines needed nothing but the date token removed ("Measured against the live Modrinth API on 2026-09-09, six published `ERROR` verdicts named a dependency that exists one patch away"), and the numbers that carry those arguments all survive: 36 of 112 kept boot logs failing on unmet dependencies, 15 of 200 crash logs dying in the sandbox, 20 of 35 Fabric API stagings on the wrong Minecraft, 16 of 16 Quilt boots running a loader build other than the one requested, all 90 boots against one poisoned install layer, 210 dependencies dropped by strict loader matching. The rest were sentences whose subject was an earlier version of the engine, and those are restated about the engine that exists. `BootLogClassifier`'s rungs are the clearest case: each one said what a console "was scored as" before that rung existed, and now says what the console means and what classifying it wrongly would cost. `ModIdRegistry` no longer explains that CurseForge "got no guess until" a date; it explains that a guess is only safe because the refusal split keys on the mapping type. Three repository landmarks went with the dates, deliberately: CI run 629 in the two host-process liveness tests, and the `B6`/`A-1`/`A-2` audit identifiers. The run is in `.claude/rules/ci-workflows.md`; the audit items are in `claude-docs/ANALYSIS-AUDIT.md`. Also removed three "an audit found/flagged this" asides. They describe how a defect was noticed, which changes nothing for a reader, and they age into a claim nobody can check. 7 matches remain in this module and are present tense: "the first rule to match decides", "no longer the protection in this dimension", "the first attempt". **A note on the verification, because it changed mid-sweep.** The comment-stripping check used in the earlier batches cannot parse a Kotlin raw string containing a template expression that itself contains a raw string (`${if (x) """a""" else """b"""}`), and reported a false positive on `MetadataScannerTest`. It is replaced by a check that needs no parsing at all and is strictly easier to audit: every line this branch adds or removes must be a comment line. Run over the whole branch -- 224 Kotlin and Gradle files, 887 insertions, 1001 deletions -- **not one changed line is code**. `:serverpackcreator-clientside:compileTestKotlin` succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Forgejo run 856 (grinder-container-it on the 9.1.0 release commit) failed one test: DockerJavaContainerEngineIT > capturesConsoleAndNonZeroExitFromARealContainer() FAILED org.opentest4j.AssertionFailedError: expected: <false> but was: <true> The container runs `echo; echo; exit 3` and exits in milliseconds. The assertion that failed is `assertFalse(output.timedOut)` -- and the one above it, `assertEquals(3, output.exitCode)`, passed. So the engine read the container's exit status back correctly and reported that it had timed out anyway. That is not a slow host producing a flake; it is `DockerJavaContainerEngine.run` asking the wrong question: while (isRunning(containerId) && !ready.get() && deadline.hasTimeLeft()) { ... } val timedOut = !ready.get() && !deadline.hasTimeLeft() The loop ends for three different reasons and the line after it can only see one of them. `hasTimeLeft()` is a wall-clock reading taken after create, start and log-attach have all been paid for, so on a host where those calls take longer than the budget, a container that finished long ago is reported as having been given up on. `ServerRunner.run` in -clientside computes the identical expression and has the identical hole. `ContainerRunOutput.timedOut` already documents the contract the code does not implement: "Whether the boot ran out of its budget **rather than finishing**". What it costs is not a mislabelled field. `BootLogClassifier.classify` branches on the timeout second, immediately after the ready line and before every evidence rung it has, and returns INCONCLUSIVE -- so a boot wrongly called a timeout throws away its exit code, its crash, and the client-only class marker the whole engine exists to find. On a loaded host that silently converts decisive verdicts into "nothing was learned". This commit lands the seam and the guard, red. `BootDeadline.timedOut` is **stubbed** to `!ready`, which is close enough to today's behaviour to make the red the defect rather than a missing symbol, and the production call sites are untouched -- nothing changes behaviour here. Observed: aRunThatFinishedIsNotATimeout() FAILED <- the defect aRunStillGoingWhenTheWaitEndedIsATimeout() PASSED <- vacuous against the stub aReadyRunIsNeverATimeout() PASSED <- vacuous against the stub Naming the two vacuous ones explicitly, per this repository's rule about landing a seam with its guard: they assert nothing the stub could fail, and only become pins in the next commit. Pinned on the decision rather than through a runner because neither runner can answer it deterministically -- the container engine needs a live daemon, the host runner starts its own process, and the case that matters needs the clock of a running boot under test control. Same reasoning, same module, as `SuspendAwareDeadline.isSuspendGap` and the shutdown-wait budget. Also in this commit, both test-only: the failing assertion gets the message it never had (it reported `expected: <false> but was: <true>` and nothing else), and its 30s budget becomes 2 minutes, since the budget is not that test's subject and the daemon calls around it are what spend it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Turns the preceding commit's guard green by asking the state the wait ended in rather than the clock: var stillRunning = isRunning(containerId) while (stillRunning && !ready.get() && deadline.hasTimeLeft()) { Thread.sleep(POLL_INTERVAL_MILLIS) deadline.tick() stillRunning = isRunning(containerId) } val timedOut = BootDeadline.timedOut(ready = ready.get(), stillRunning = stillRunning) A wait ends for three reasons and only one of them is a timeout. `hasTimeLeft()` cannot distinguish the other two, and it is read after create, start and log-attach have been paid for -- so on a host where those outlast the budget, a container that exited on its own was reported as given up on. `stillRunning` is the loop's own last answer, reused rather than asked again: the daemon is already round-tripped once per poll, and a second inspect would be a different moment in time. **Both copies, because there were two.** `HostProcessServerRunner.run` computed the identical expression for the `-verifyclientside` verb and the servertest plugin's boots. Fixing only the one that failed CI would have left the other to be rediscovered, which is the drift shape this module's own context file opens with. The predicate now has one home beside `SuspendAwareDeadline`, which both already share for the same reason. In `ServerRunner` the value is read **before** the teardown below it, which kills the process -- reading after would make every run look finished and the field could never be true. What changes for a verdict: a boot whose budget elapsed while it was still running is unaffected, which is every real timeout. A boot that crashed or exited before the loop noticed now keeps its exit code and its console instead of being flattened to INCONCLUSIVE/TIMED_OUT by the second branch of `BootLogClassifier.classify`. That is a behaviour change on the published API's `ContainerRunOutput.timedOut` and `RunResult.Completed.timedOut`, and it moves them *onto* their documented contract -- "ran out of its budget rather than finishing" -- rather than away from it. Green: BootDeadlineTest 3/3 (the two vacuous guards from the previous commit are now pins), :serverpackcreator-clientside:test and :serverpackcreator-grinder:test, and all 12 container ITs under GRINDER_DOCKER_IT=1 including the one that failed run 856. Landmine recorded in serverpackcreator-clientside/CLAUDE.md beside the suspend-aware one, since the two are the same mistake made about the same loop from opposite directions: that one counts time the host was asleep, this one reads a clock where it should read a state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`21` stood as six separate literals across five build files with nothing linking them -- the Gradle toolchain, the Kotlin jvmTarget, Dokka's JDK, buildSrc's own toolchain and target, and the IDEA language level. `kotlin-conventions` even carried a comment saying the target "is not repeated here" directly above the line that repeated it. It is now the `java` entry in `gradle/libs.versions.toml`, and nothing else carries a version. `misc/Java-References.md` is the other half: every Java version reference Gradle **cannot** reach, with the order to change them in. Twelve CI `setup-java` steps, Qodana's linter, install4j's bundled JRE, two Dockerfile stages, the AppImage script, the grinder's deploy prerequisites and the prose in three root documents -- plus the section that matters most, naming what must NOT move with it: the server-pack script templates track Mojang's per-Minecraft requirement, which is a different number that happens to share values. Measured by changing that one entry and nothing else, which is also the useful part for whoever does the next LTS bump: java = "21" builds, 135 tasks java = "17" fails -- buildSrc's own plugin dependencies require 21, so downwards is blocked java = "25" fails -- "Dependency requires at least JVM runtime version 25. This build uses a Java 21 JVM." That last one is the real prerequisite and was written down nowhere: the Gradle daemon loads buildSrc's output, so the CI runner JDKs have to move **before** the catalog entry, not after. One live bug found while taking the inventory and fixed here rather than filed: Dependabot watched the `docker` ecosystem at `/`, where this repository has never had a Dockerfile. Every image pin in it, `eclipse-temurin:21-jdk-jammy` included, was watched by nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>ServerPackCreator on Windows meant running an installer. It now also ships as a zip you extract and run -- the Windows counterpart of the AppImage, carrying its own Java runtime, with the same three entry points the installer gives you: the GUI, the CLI and the web service. Built for x86_64 and x86_64, marked `_experimental` like the AppImages. The installer is unchanged and is not going away. Built with `jpackage`, which cannot cross-compile -- JEP 392 makes that an explicit non-goal -- so unlike the AppImages, which are cross-packaged from one Linux runner because nothing architecture-specific has to execute, these need real Windows machines to build on. **That makes this half of a change.** The jobs are here; the runners they need are host configuration and are not. Until they exist the jobs queue rather than fail, which stalls the dev build's continuous release and a tagged release -- deliberately, because a release quietly short an artifact is worse than one that stops. `claude-docs/WINDOWS-RUNNER.md` is the setup, and `misc/windows-runner/` has a docker-compose that runs the Windows guest as a container on a Linux host, with the provisioning that registers it. Three things found while writing that, each of which changes what the setup takes: - **Forgejo publishes no runner binary for Windows at all** -- every asset of the last five releases is Linux. It is Go, so one cross-compile produces it. - **x86_64 only, and that is a decision rather than a gap.** A Windows ARM64 artifact would need a native ARM64 Windows runner, and the only way to host one here is an emulated guest: KVM virtualises, it does not translate instruction sets, so qemu falls back to TCG and every guest instruction is translated in software. That is a difference in kind rather than degree, and not something to gate a release on. Windows on ARM64 runs x86_64 binaries, so that audience is served by the one artifact. - **`winget` cannot install anything from the provisioning context.** It ships inside the App Installer MSIX, which is registered per *user*, and OEM provisioning runs as SYSTEM before any user profile exists -- so it fails with `The term 'winget' is not recognized`, which reads like a PATH problem and is not one. The official installers are downloaded and run directly instead: Node from `nodejs.org/dist` and Git for Windows from its GitHub release. Both versions are resolved at run time and both selection rules were executed against the live indexes: Node's skips 29 non-LTS releases to reach v24.21.0, and each Git pattern matches exactly one asset with `PortableGit-*` correctly excluded. The guests are stripped and tuned by `oem/optimise.ps1` before provisioning: OneDrive, Copilot, Feedback Hub, Clipchamp, News, Teams, To Do, Outlook, Power Automate, Quick Assist, Solitaire, Sound Recorder, Sticky Notes, Weather, Media Player, Paint and the Xbox stack, plus the policies that stop Windows re-acquiring them, Search indexing and SysMain off, hibernation off, and Defender exclusions for the build directories -- which Tamper Protection does not cover on a machine that is not Intune-enrolled, so they take effect, and the script reads them back rather than assuming it. Apps are matched by PATTERN and every pattern reports what it matched, because Microsoft renames these: the Xbox app became `Microsoft.GamingApp`, Teams became `MSTeams`. A hardcoded list of exact names fails silently -- removes nothing, says nothing -- so a pattern matching zero packages is logged as such. Verified against a 35-package fixture: every target matched exactly one pattern, and no pattern reached `VCLibs`, `UI.Xaml`, `NET.Native`, `DesktopAppInstaller`, `WindowsStore` or `SecHealthUI`, which are protected by name as well. Nothing in that script is fatal and it runs from its own log, because a fatter or slower guest is still a working runner and must not lose the step that makes it one. **Both guests install Windows 11 Pro (`VERSION: "11"`), and the leaner editions are a trap.** LTSC was the obvious choice and was taken first: it ships without the Microsoft Store, Game Bar, Feedback Hub and the rest of the consumer set, at 4.7 GB against Pro's 7.9 GB on x64 and 7.5 on ARM, where every gigabyte is installed under emulation. It is also **Evaluation media, and expires after 90 days with no product key able to activate it** -- the edition is not licensable, so the deadline is not something you can buy your way out of. Every lean alias dockur offers is the same story, which is visible in its source rather than inferred: `define.sh` rewrites `11l`, `11e` and `11i` to ids ending `-eval` (`win11x64-enterprise-ltsc-eval` and so on), and `mido.sh` routes any such id through `downloadWindowsEval` to `microsoft.com/en-us/evalcenter/download-...`. Only `11` takes the other branch, `downloadWindows` to `microsoft.com/en-us/software-download/windows11`, which is retail media. On ARM it is worse again: `downloadWindowsEval` redirects an LTSC request on some platforms to the IoT Enterprise LTSC image, so the guest would not even be the edition the alias names. Retail media left unactivated does not expire -- a desktop watermark and a locked *Settings > Personalization*, no shutdowns and no deadline, neither of which reaches a headless build runner. So `optimise.ps1` is the first line of defence rather than the second, which is what it was written for. Two escape hatches are in the compose file rather than left to be discovered: `KEY` takes a 25-character product key, which dockur installs with `slmgr /ipk` during setup, and a volume-licensed ISO bound at `/custom.iso` bypasses `VERSION` entirely -- the only route to a non-evaluation LTSC guest. `distribution: 'zulu'` across the new jobs is consistency with every other `setup-java` here rather than a constraint -- Adoptium, Zulu and Microsoft all publish windows/x64 JDK 21 and 25 GA. It still decides what ships: jpackage embeds whichever JDK it runs on into the artifact. The `setup-java` count in `misc/Java-References.md` and the root `CLAUDE.md` moves from twelve to fourteen with these jobs. Two things the compose gets right that are easy to get wrong, both stated in the file: - The web viewer and RDP are bound to **127.0.0.1**, not `0.0.0.0`. Port 8006 is an unauthenticated console to a Windows VM, before it has a login screen and after; published on a remote host that hands a desktop session to anything that can route to the box. The README has the SSH tunnel. - `./storage-<arch>` is a bind mount holding the installed disk image, so an image update, a host reboot or a `down && up` recreates only the container and keeps the registered runner. Commenting that line out is how you deliberately start over. - The installation media -- 7.9 GB -- is downloaded **again after every reinstall**, because dockur defaults `REMOVE` to `Y` and deletes the ISO once the install finishes -- so there is nothing left to reuse even within one guest. `REMOVE: "N"` keeps it, and a commented `./iso/<file>:/custom.iso` line makes it shareable: a bound custom ISO is used in place of any download and `VERSION` is ignored entirely, so clearing `storage-<arch>` then costs no bandwidth and a second guest on the same architecture reuses the same file. It ships commented rather than as something to delete later because Docker creates a DIRECTORY at a missing bind source, and a directory at `/custom.iso` fails confusingly rather than clearly. Four defects reported from real runs and fixed here: - **Node and Git resolved to garbage under Windows PowerShell 5.1.** The pipeline form (`Invoke-RestMethod ... | Where-Object { $_.lts } | Select-Object -First 1`) resolves correctly under PowerShell 7 and returned EVERY version at once under 5.1, which is what the guest runs: `$lts` became an 868-element string, the URL built from it was nonsense, and it surfaced as `400 Bad Request` from nodejs.org. Both resolutions are `foreach` now, which behaves the same under either edition, and both validate the shape of what they resolved -- so a bad resolution fails saying so instead of building a URL out of it. **The rules were verified in pwsh 7 and that could not have caught this**; the container is a different PowerShell from the target. - **The runner's DEPRECATION WARNING killed provisioning.** `register` writes it to stderr, and `... 2>&1 | Tee-Object` turns every stderr line into an ErrorRecord; with `$ErrorActionPreference = 'Stop'` a NativeCommandError is terminating, so a warning that is not a failure stopped the script -- at the point where registration had very likely already succeeded. It now runs through `Start-Process` with process-level redirection, which never reaches PowerShell's stream system, so only the exit code decides anything, and both streams are still captured and printed. A `.runner` existence check follows it: exit 0 is the claim, the file is the evidence, and without it the service would start, find no configuration and idle for ever -- a runner that registered and never answers. - **Registration is a configuration file, not a command.** Forgejo's web UI hands out a UUID and a Token from "Create new runner" and its own documentation says to copy them into the runner configuration's `server` section -- which is what `forgejo-runner register`'s deprecation warning means by "declare connections in the runner configuration instead". `provision.ps1` writes that `server.connections` block from `runner-uuid.txt` and `runner-token.txt` and does nothing else. The `register` route is gone rather than kept as an alternative: it consumes a separate single-use registration token for no gain, and supporting both meant sniffing the credential's shape to guess which one the operator meant. The token's format is deliberately NOT asserted. Its length was hard-coded here twice and wrong twice -- 43 characters read off the development branch, then 40-hex-versus-43 as a shape to route on -- so the check now catches an empty file, a leftover placeholder and pasted prose, reports the length and a masked form of what it read, and lets the instance judge the rest. The UUID is checked as a UUID, a format that does not move. - **A backtick inside a double-quoted here-string ate a letter.** `` `r `` is a carriage return, so "`registration token not found" rendered as "egistration token not found" in the one message whose job was to be read carefully. No backticks in those strings, and a note saying why. - **`COMMAND` and `oem/install.bat` are ONE mechanism, and setting both made dockur's linter report a critical error on every boot.** dockur does not run `COMMAND` separately: `install.sh` appends it to the install.bat it finds in /oem (`printf '%s\n' "$COMMAND" >> "$file"`, after a blank line), so the diagnostic breadcrumb that was set here landed on line 22, after that file's `exit /b 0` on line 20, where it could never execute -- "Unreachable code after EXIT or GOTO (E008)". `COMMAND` is gone from both services, with the reason in the file so it is not re-added. - **Binding a custom ISO onto an installed guest REINSTALLS it**, which the ISO instructions did not say. `needsInstall` compares the recorded media against the current media and a path change counts; the only carve-out is REMOVING custom media after a completed install, never adding it. The README now gives the order that costs nothing -- bind before first start if you already have an ISO, and otherwise switch at the next deliberate rebuild rather than on a working guest. - **The runner was registered as a Windows SERVICE, which cannot work.** `forgejo-runner daemon` is an ordinary long-running console program -- the runner implements no Windows service support anywhere -- so `sc.exe create` made the Service Control Manager start it, wait for the status report a service is required to send, and kill it after about 30 seconds. The runner connects inside that window, which Forgejo displays as a runner that is OFFLINE with a "last online" timestamp a few minutes old rather than as any kind of failure. It runs as a SCHEDULED TASK at startup now, under SYSTEM, through a generated `run-runner.cmd` that fixes PATH -- a scheduled task has no equivalent of a service's `Environment` registry value, and PATH is what bit this guest once already -- and keeps a `daemon.log`, since a daemon started by the scheduler has nowhere else to write. An existing service registration from an earlier run is removed. The provisioning now CHECKS the daemon is still alive ten seconds after starting it. The service registration it replaces was never checked, which is exactly why a runner the SCM had already killed read as a successful provisioning. - **`disable DoSvc` answered `Access is denied`.** Delivery Optimization's service key is ACL-protected and `Set-Service` cannot touch it even as an administrator. It is out of the service list, replaced by `DODownloadMode = 0` under the policy key -- HTTP downloads still work, peer-to-peer sharing does not happen, and the policy takes priority over the Settings UI. - **`git not found` immediately after installing git.** An installer writes the machine PATH in the registry and cannot update a process that is already running; re-reading the registry turned out to be necessary and not sufficient. The script now also asserts the install directories itself and calls `git` by resolved full path, so the step cannot depend on what the installer did or when. - **`registration token not found` reported only as `exited 1`.** That message is about the token being stale -- the *Create new runner* page issues a fresh one each time it is opened, invalidating the last -- and says nothing about the runner, labels or network. The failure is now recognised and explained, and the token file is checked for shape first, so a leftover `<registration-token>` placeholder or an empty file fails saying *that* instead of looking like a rejected token. Verified across nine real file shapes: newline, CRLF, BOM and surrounding spaces all accepted; two tokens, an empty file, the placeholder and a pasted URL all rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>commit cd8bb5a0966e8e4c961e6c9d08bebd34534a2d08 Author: Griefed <griefed@griefed.de> Date: Mon Oct 5 13:19:14 2026 +0200 docs(grinder): record that neither the store load nor the reaper's second walk explains a slow restart A restart that took "ages" was attributed first to the verdict store and then to the reaper, on reasoning rather than measurement. Both were then measured against a live 164 MB / ~215,000-row store and 354K inodes of leftover staging, and both are negative results worth keeping so they are not chased a third time. The store load is ~0,44-0,6 s. `measureLoadCost` puts the tree-then-convert pass at 605,6 ms against a streaming `MappingIterator`'s 427,7 ms for 215,000 fully-populated rows, on a fixture that overstates the real file by ~39%. The saving is a ratio of ~30%, not a constant, so a full second needs ~1,2M rows. Fusing the reaper's two traversals saves ~4%. It was implemented and proven behaviour-identical -- 11 guards green with no assertion changed, and an identical 19.200.000 bytes reclaimed across five runs -- then measured at 306.300 inodes: 17.219 ms before (2 runs), 16.510 ms after (3 runs). The unlinks dominate and the stat pass is nearly free once the dentry cache is warm. Reverted rather than landed, on the same grounds B30 was: a real saving that buys nothing worth the churn. What the exercise did leave behind is the scale -- roughly 17 s per 300K inodes of staging on a warm local SSD -- and the route to an answer: `Reclaimed N MiB of staging left behind by a previous run.` is INFO and is logged after the sweep, so the gap between it and the line before it IS the reaper's cost. Reading that beats predicting it, which is the part this entry exists to stop repeating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> commit 1412e6b0e12430b1a95b5e1375db87b6e625b74f Author: Griefed <griefed@griefed.de> Date: Mon Oct 5 13:12:31 2026 +0200 test(grinder): pin that a partial delete contributes no bytes to the reclaimed total `BootWorkspaceReaper` reports "bytes this sweep is known to have freed", and the rule that makes that true is all-or-nothing: `reclaimed += size` runs only when `deleteRecursively()` returned true, so an entry that could not be fully removed counts as zero even though some of its files did go. The entry is left behind for the next sweep to retry. Ten guards covered what survives, what is deleted, the scoping by platform and slug, and an exact byte figure -- and not one of them covered that rule. Proven, not assumed: mutating the accounting to entry.deleteRecursively() reclaimed += size leaves all ten green and fails only the guard added here, `expected: <2048>` against the 3072 the mutation produces. So the subtlest half of this class was unpinned, and a change that fused the sizing and deleting traversals could have started reporting bytes for packs still on disk with nothing to catch it. Green against unmodified code, which is correct for a characterization pin: it documents behaviour that already exists, ahead of restructuring that must preserve it. The red above is what shows it has teeth. **The failure is manufactured by clearing write permission on a directory, which root ignores.** So the precondition is asserted AFTER the sweep, via `Assumptions.assumeTrue` on the file still being there -- a run where the trick did not bite skips rather than passes, because a guard that cannot run must never report success. Verified it is not silently skipping here: 11 tests, 0 skipped. Permissions are restored in a `finally`, or @TempDir cannot clean up and the failure lands on an unrelated test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> commit d18bbee1409c2a2806155d5651d76c55b628102b Author: Griefed <griefed@griefed.de> Date: Mon Oct 5 13:12:18 2026 +0200 build(grinder): measure what a restart pays to load the verdict store The benchmark could time `record()` and the components of `persist()`, but not `load()` -- so the one number a restart actually waits on was the one it could not produce. `ReportServer.start()` is the last thing `GrinderApplication` does, after the store's constructor has run, so a load is dead time before the report answers at all. Adds `measureLoadCost`, timing the tree-then-convert pass `JsonVerdictStore.load` performs against a streaming `MappingIterator` over the same bytes. Both keep per-row failure isolation; they differ only in whether the whole file is materialised as a tree first. Also adds `populatedVerdict`, a fixture filling all 21 fields rather than the seven the write measurements use. That matters twice: a real store's rows are bound field by field, so a mostly-null fixture under-reports the binding cost, and the bytes/row it produces is what makes the file size comparable to a deployed store's. Measured on this machine, against a live store of ~215,000 rows / 164 MB: [load] 10000 rows 10,5 MiB tree then convert 23,2 ms [load] 10000 rows 10,5 MiB streaming 19,5 ms [load] 100000 rows 105,6 MiB tree then convert 226,0 ms [load] 100000 rows 105,6 MiB streaming 185,7 ms [load] 215000 rows 227,6 MiB tree then convert 605,6 ms [load] 215000 rows 227,6 MiB streaming 427,7 ms The fixture is 1,110 B/row against the live store's ~800, so 227,6 MiB OVERSTATES a 164 MB file by about 39%; the real load is nearer 440 ms with ~130 ms available. **That is the finding, and it is a negative one:** switching to a streaming load is not worth doing. The saving is a ratio of roughly 30% rather than a constant, and scaling is near-linear -- 21,5x the rows gives 26x the time -- so a full second of saving needs ~1,2M rows, by which point `persist()` rewriting the whole file every flush costs far more than the once-per-restart load does. Recorded rather than acted on, because the measurement is what stops it being re-argued: the store is ~0,5 s of a restart, so a slow start is somewhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Griefed <griefed@griefed.de>`test.yml` run 889 (URL numbering; API id 1222) failed one test: KDocAttachmentTest > noKDocBlockIsOrphanedFromItsDeclaration() FAILED StoreWriteBenchmark.kt:120 ==> expected: <true> but was: <false> `measureLoadCost`'s doc block sat immediately above `populatedVerdict`'s doc block, so it attached to no declaration at all and dokka drops it: the function it documents was left undocumented and the paragraph explaining why a restart waits on the store was rendered nowhere. Cause is mechanical and worth naming, because the next edit of this shape will do it again. The commit that added both of them inserted the new helper by matching the TEXT of the function signature -- " fun measureLoadCost(dir: File) {" -> <helper> + " fun measureLoadCost(dir: File) {" -- which lands the helper between that function and its own KDoc, because the anchor was the `fun` line and the doc block sits above it. Anchor on the doc block, or on the blank line before it, when inserting a member ahead of a documented one. Nothing moves but the comment: the block is lifted out and put back immediately above `fun measureLoadCost`, byte-identical. No code, no measurement and no fixture changes. `KDocAttachmentTest` is the guard `test(api): pin that no KDoc block comes loose from its declaration` added, and this is the first defect it has caught that was not already known -- the structural scan it replaced found 18 instances where Qodana's `KDocUnresolvedReference` saw 4, and this one would have been invisible to Qodana too, since the stranded block contains no `[link]` to go unresolved. Green: `:serverpackcreator-api:test --tests '*KDocAttachmentTest*'` -> 1 test, 0 failures, 0 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`devbuild.yml` run 884 (URL numbering; API id 1217) failed `Build Windows app-image (x86_64)` without running a line of it: Cannot find: pwsh in PATH skipping post step for 'Upload Windows app-image'; main step was skipped `shell: pwsh` is PowerShell 7, a SEPARATE install. Windows ships `powershell.exe` -- Windows PowerShell 5.1 -- and nothing else, and `provision.ps1` installs Node and Git but no pwsh. So the step could never have run on the guest it was written for. Worth stating plainly because the symptom is misleading: act skips the step, then skips every later step that depended on it, and reports the JOB as failed. Nothing says "missing interpreter", and the run reads as the Windows build being broken rather than as never having started. The job's own log is 63 KB of successful checkout and `setup-java` before one line of truth. All three sites move to `shell: powershell`, confirmed supported rather than assumed: act maps it in `pkg/model/workflow.go` as `case "powershell": shellCommand = "powershell -command . '{0}'"`. `misc/build-winimage.ps1` needed no change -- scanned for 7-only constructs (ternary, `??`, `&&`/`||` chains, `-Parallel`, `Join-String`, `-AsHashtable`, `$PSStyle`) and has none, and every cmdlet it uses (`Compress-Archive`, `Get-FileHash`, `Join-Path`, `Resolve-Path`, `Set-Content -NoNewline`, ...) exists in 5.1. Its header now says which dialect it is written in, so the next edit knows the constraint instead of rediscovering it. Two inline `Get-ChildItem` results are wrapped in `@()`. 5.1 unwraps a single-element result to a bare `FileInfo`, so `$image.Count` and `$image[0]` worked only by the compatibility shims PowerShell 3 added -- and the devbuild site tested `if (-not $image)` where the release site tested `.Count -ne 1`, which is two spellings of one question. Both now count, both on a real array. This is the same edition trap that produced an 868-element `$lts` and a 400 Bad Request from nodejs.org during provisioning; it is written to be edition-identical by construction rather than tested on the wrong edition again. `claude-docs/WINDOWS-RUNNER.md` records the constraint beside the node/git/long-paths checklist, where an operator looks before blaming a workflow, and drops a stale "both hosts" from the Node line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>commit b5463cbaddaf5bf988aa288965946a9516c8b45e Author: Griefed <griefed@griefed.de> Date: Wed Oct 7 12:34:11 2026 +0200 docs(readme): condense chapter 6, and stop miscrediting chapter 7 Chapter 7 is about consuming `serverpackcreator-api` as a maven dependency, not about the plugin API, so both references to it were wrong: the opening line claimed it "describes the plugin API", and 6.4 sent would-be plugin authors there. Plugin-API readers now go to HELP.md's *Plugins* chapter, which is where that material actually lives. Condensed 138 lines to 92. The four "what experimental means" bullets and the separate testing/installing headings became one short paragraph each; every per-plugin section lost its bullet list in favour of a single description plus the "rough edges" line, which is the part a reader is actually here for. 6.3 also carried a duplicated opening paragraph -- an earlier edit replaced its screenshot with a file listing and left the sentence it was meant to supersede. No claims changed, no sections dropped, every figure still resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> commit 378c0d48b86ce960dfe19f0efec3104fef59297f Author: Griefed <griefed@griefed.de> Date: Wed Oct 7 12:13:05 2026 +0200 docs(readme): a chapter for the four experimental plugins Four plugins are built in this repository and ship with every release, and until now the README said only "an example plugins is available with every release" -- singular, stale, and the one of the four that is documentation rather than a feature. Chapter 6 covers all four: what each does, what `_experimental` in the filename is promising and what it is not, how to install and disable one, and the rough edges each is known to have. API and Awesomesauce move to 7 and 8; nothing linked to either by anchor, so no cross-references needed fixing. The framing Griefed asked for is that these are not guaranteed to work and that testing them is how they stop being experimental -- so the chapter says both plainly rather than burying the caveat. Screenshots are from a real run: the four plugin jars installed into a throwaway home, a server pack generated from the Survive_Create_Prosper_4 fixture, and the GUI driven to each tab. plugin_servertest.png the generated pack listed, Forge 36.2.4 / MC 1.16.5, "Ready to launch", with the start-script dropdown resolved to start.sh plugin_example.png the Tetris tab and the global config panel, both extension points in frame plugin_grinder.png the verdict table against a local grinder store of 875 real verdicts The grinder shot shows "0 confirmed, 875 other verdicts" because the store it was served predates the `verdict` field (it is from 2026-07-31; `verdict` arrived alongside `confidence` later), so every row falls outside the Confirmed list. The UI, the ten columns and the controls are all genuine; re-shoot against a live daemon to fill the table, replacing img/plugin_grinder.png and nothing else. Self-extract has no interface to photograph -- it is a PostGenExtension and nothing else -- so its section shows the files it actually wrote during that run instead of a picture of a directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Griefed <griefed@griefed.de>commit ddd5b1617ae7029d182d7fc2f01ee8721ba9c2ab Author: Griefed <griefed@griefed.de> Date: Wed Oct 7 14:45:48 2026 +0200 docs(grinder): why the signal fix stops at the bash template Records what was measured against fish 4.6.0 while trying to port it: an --on-signal handler does not run during a foreground command, `wait $pid` does not wait, `wait` with no arguments waits but reports 0 whatever the job did, and `begin … end &` sets no `$last_pid`. So fish can have signal forwarding or a faithful exit status, not both — and its run loop exits with that status by design, so backgrounding would silently turn every crash into a clean shutdown. PowerShell is a different case entirely: the .ps1 targets Windows, which has no SIGTERM. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> commit d1ec0fca560c553756d5111c28892a109babac92 Author: Griefed <griefed@griefed.de> Date: Wed Oct 7 14:45:32 2026 +0200 fix(api): forward a stop signal to the server in the bash start script Turns the previous commit's seam into a real handler: `stopServer` hands the request to the running server and waits for it, and `runJavaCommand` backgrounds the server so the shell can enter that handler at once instead of only after the server returns. Exit status is unchanged -- `wait` reports what a foreground run would. `0<&0` is load-bearing and is the trap this fix sets for itself. With job control off a shell redirects an asynchronous command's input from /dev/null, so backgrounding the server to make it signallable would silently cut the console off from every command an operator types. Verified under bash:latest: `cat & wait` reads nothing, `cat 0<&0 & wait` reads its input. The guard now goes red for each half independently, checked by mutation: - handler forwarding nothing (today's shipped behaviour) -> FAILED - `"$JAVA" ${1} &` without the fd-0 redirection -> FAILED The guard itself was repaired before being trusted. Its first form collected the console from a pipe and lost exactly the lines it asserts on: the JDK closes the pipe as the process exits, so the reader threw `Stream closed` on output the handler had already written. It reads a file now. api suite: 504 tests, 0 failures. Only the bash template is fixed. fish and PowerShell cannot take this shape, for measured reasons recorded in the grinder's container CLAUDE.md -- fish defers an --on-signal handler until the foreground command returns, and its `wait` reports 0 whatever the job did, which the fish run loop exits with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> commit 22e52c34e1e85f8648e59fe2fc7bb9b15edbf913 Author: Griefed <griefed@griefed.de> Date: Wed Oct 7 14:34:10 2026 +0200 test(api): pin that a stop signal reaches the server, red A pack's PID 1 under docker or systemd is start.sh, and the kernel DISCARDS a default-action signal aimed at PID 1 -- so a script that neither traps TERM nor execs the server swallows every stop request and the server is SIGKILLed when the window expires, losing the world save. Measured with `docker stop -t 5` against the two shapes: no trap, 5096 ms and exit 137; a trap plus `wait`, 72 ms and exit 0. The guard executes the template's own `stopServer` and `runJavaCommand` against a stand-in server that reports what it read from stdin and announces the signal. Committed red, and the red is the logic rather than a missing symbol: this carries the seam -- a `stopServer` that exits without forwarding anything, plus its `trap` registration -- so the failure is `SIGTERM to the script must be forwarded to the server so it can save and exit`, with `CHILD-STDIN:say hello` already in the console above it. Without the seam it fails on `template default_template.sh has no function stopServer`, which says nothing about behaviour. VACUOUS AGAINST THIS SEAM: the stdin assertion. The server still runs in the foreground here, so fd 0 cannot be anything but the script's own; it only becomes load-bearing in the next commit, which backgrounds the server to make it signallable and must re-attach fd 0 by hand. A shell with job control off redirects an asynchronous command's stdin from /dev/null, so without that redirection the server console silently stops working -- verified under bash:latest, `cat & wait` reads nothing where `cat 0<&0 & wait` reads its input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> commit 33c1d79891f0bc2be6cb8e5a63d2074b14d26387 Author: Griefed <griefed@griefed.de> Date: Wed Oct 7 14:27:07 2026 +0200 docs(grinder): the graceful stop is sent, but no boot can honour it `stopThenRemove`'s doc claimed the `docker stop` is "what gives a Minecraft server the chance to save its world". It is not, for the containers this engine actually boots. A boot's PID 1 is `bash start.sh`, the shipped `default_template.sh` installs no trap and launches the server as a child rather than with `exec`, and the kernel discards a default-action signal aimed at PID 1 -- so the SIGTERM reaches nothing, the full window elapses, and the server is SIGKILLed anyway. Measured against docker 29.7.2, `docker stop -t 5` on the two shapes: sh -c 'sleep 600 & wait' 5096 ms, exit 137 (production) sh -c "trap 'exit 0' TERM; sleep 600 & wait" 72 ms, exit 0 (the IT fixture) Found while fixing that IT's own race. It passes because its fixture installs a trap, which no boot does -- so it guards the engine's half of the contract and is silent about the half that decides whether a server ever gets to save. The doc now says which half it is, and the package CLAUDE.md carries the measurement, the two costs (15 s per in-flight boot at every shutdown; a user's pack has the same shape) and the fix that is NOT taken here: a `trap` plus `wait` or `exec` in the three shipped start-script templates changes every generated pack, which is Griefed's call rather than a drive-by. Also corrects the companion object's summary, which listed a suspend-gap threshold that lives in -clientside's SuspendAwareDeadline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> commit 292024531c98fcb9c0c444aaba20b4b449dca03f Author: Griefed <griefed@griefed.de> Date: Wed Oct 7 14:26:54 2026 +0200 fix(grinder): the signal IT waits for the daemon, not for the trap it depends on `closeSignalsAContainerBeforeKillingIt` gated on `waitForContainerOf`, which returns as soon as the daemon reports the container running. Its assertion depends on a strictly later event: the shell having executed its `trap`. A SIGTERM landing in between is DISCARDED -- the kernel drops a default-action signal aimed at PID 1 -- so the container survives its own stop, the grace window expires, docker SIGKILLs it, and the test reads that as "the engine never signalled". The fixture's own script already prints a line to mark the trap being installed; nothing waited on it. It now does, via a latch fed by the same `onLine` sink that watches for the handler's marker, and both markers are companion constants so the shell that writes them and the sink that waits on them cannot drift. Measured against docker 29.7.2 on an idle host, where the window is hidden by `waitForContainerOf`'s 200 ms poll granularity -- the ready line arrives at +84 ms and the gate only opens at +242 ms: before, trap delayed behind `sleep 2` to open the window: 3 of 3 FAILED, container exit 137, console [starting-up, ready-to-be-stopped] after, same delayed trap: 3 of 3 passed after, unmodified fixture: 10 of 10 passed The guard keeps its teeth: with `stopThenRemove` mutated back to a bare `removeContainerCmd(force)` -- the regression it exists to catch -- it fails 3 of 3. Also drops the fixture's `while true; do sleep 1; done` for `sleep 600 & wait`. A trap runs only once the current foreground command returns, so the loop spent up to a second of the 5 s grace on shell granularity before entering the handler at all. `docker stop -t 5` against an identical trap: 1037 ms looping, 72 ms waiting. Suite time per run went 1130 ms -> 630 ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Griefed <griefed@griefed.de>