Grinding muh Gears! #672

Merged
Griefed merged 67 commits from develop into alpha 2026-08-23 18:08:11 +02:00
Owner
No description provided.
Griefed self-assigned this 2026-08-23 12:21:57 +02:00
The runtime image bakes in `USER 1000:1000` and `ContainerSpec.user` defaults to
the same literal, but every container bind-mounts a directory the *host* process
created. When the host identity is not uid 1000 -- which it stopped being the
moment the daemon moved to `User=grinder` -- the container can read the pack and
write nothing.

Observed live 2026-08-23, with the cause 20 lines above the visible symptom:

    start.sh: line 568: ./.previousrun: Permission denied
    Warning: Failed to open the file ./server.jar: Permission denied
    start.sh: line 206: user_jvm_args.txt: Permission denied
    Error: could not open `user_jvm_args.txt'

Both container paths are affected -- installs via DockerLoaderInstaller and mod
boots via ContainerServerRunner -- so every verdict since the systemd migration
is INCONCLUSIVE.

Red: `ContainerUser` does not exist yet, and ContainerServerRunner takes no
containerUser argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ContainerUser.forDirectory` resolves the work directory's owning uid:gid and
both container paths -- DockerLoaderInstaller and ContainerServerRunner -- now
run as that instead of the image's baked-in 1000:1000. `SPC_GRINDER_CONTAINER_USER`
overrides it for setups where the owner is not the right answer; a non-POSIX
filesystem or an unreadable path falls back to the image's own user, which is
the previous behaviour.

The owner of the directory is the question, not this process's uid: it is the
identity that has to be able to write there, and it stays correct if
SPC_GRINDER_WORK is relocated onto a share owned by somebody else.

The resolved identity is logged on the startup line beside bind/port/workers, so
the value can be checked without reproducing the failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DockerLoaderInstaller reports `output.lines.takeLast(25)` on a failed install.
The console of the real 2026-08-23 failure is exactly the shape that defeats:
the three `Permission denied` lines sit near the top, and the last 25 lines
carry only the JVM's downstream `Error: could not open 'user_jvm_args.txt'`,
which reads as a start-script-template bug. That truncation is what sent the
diagnosis after networks, templates and loader versions in turn.

The third case pins the other half of the contract: a console with no
recognisable cause must stay silent rather than invent one.

Red: `InstallFailureDiagnosis` does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The failure warning now consults InstallFailureDiagnosis, which scans every
captured line rather than the 25 the warning quotes, and names the install
console's path so the full record is one `cat` away instead of a directory
walk.

Recognises the unwritable mount for now, and nothing it cannot actually
identify -- the raw tail stays the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`BrowserDownloader.download` navigates to CurseForge's `/download` page from
inside `waitForDownload`. CurseForge answers that with a file transfer, and
Chromium aborts a navigation that turns into one -- Playwright throws
`net::ERR_ABORTED`. The throw escapes the callback and tears the wait down, so
a download that had actually started is discarded.

Observed live 2026-08-23 on bwncr-neoforge, tombstone-neoforge and Structory,
each stacked at `_FrameSession._navigate`.

The second test pins the other side: a real navigation failure or a timeout must
still fail, or the downloader would silently return nothing forever.

Red: `isDownloadAbort` does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes to the locked-file download flow, all from the 2026-08-23 grinder
run in which every CurseForge locked file failed:

- The `/download` navigation is wrapped so a `net::ERR_ABORTED` -- Chromium
  cancelling a navigation that turned into a file transfer -- no longer escapes
  the `waitForDownload` callback. A genuine navigation failure still propagates.
- Both navigations wait for DOMCONTENTLOADED instead of Playwright's default
  `load`. A CurseForge project page keeps fetching ads and trackers long after
  it is usable, so `load` turns a working page into a timeout.
- The 30s default becomes a configurable 60s. Every timeout in that run was
  exactly `Timeout 30000ms exceeded`, i.e. the default, never a page-specific
  budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/as-properties` is meant to be pointed at by an SPC instance's
`de.griefed.serverpackcreator.configuration.fallback.updateurl`, so the fallback
clientside-mod list stops depending on a maintainer hand-editing the repository.

The pins parse the rendered text with `java.util.Properties` rather than
asserting on its shape, because that is precisely what the consumer
(`UpdateConfig.updateFallback`) does -- a document that merely looks right is
worth nothing. `Properties.load(InputStream)` decodes ISO-8859-1, which is why
one pin puts a non-ASCII entry through a round-trip.

Pinned: only HIGH is published (a clean boot proves nothing, so MEDIUM/LOW/
INCONCLUSIVE stay out), one entry per mod however many loaders crashed, the
whitelist passes through so the endpoint replaces the GitHub URL wholesale,
output is order-stable so polling does not churn, and the document is valid
with nothing to publish.

Red: neither `FallbackPropertiesRenderer` nor `FallbackLists` exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An SPC instance can now point its
`de.griefed.serverpackcreator.configuration.fallback.updateurl` at the grinder
and receive the shipped clientside-mod list plus every mod the grinder has
proven clientside by crashing a real server with it -- so the list stops
depending on a maintainer editing the repository by hand.

Only HIGH confidence is published. A clean boot proves nothing, and a wrong
entry silently strips a mod out of every server pack built against the list, so
the gate is a floor rather than a threshold to tune.

The whitelist is passed through untouched, which makes the endpoint a drop-in
replacement for the GitHub raw URL rather than a partial one that would quietly
freeze a client's whitelist.

Two encoding details the format forces: the document is written for
`Properties.load(InputStream)`, which decodes ISO-8859-1, so entries are
`\uXXXX`-escaped and the response is served as ISO-8859-1 rather than the UTF-8
every other endpoint uses. Output is order-stable, so a poll that sees a
difference has seen an actual change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ContainerUser.forDirectory` no longer defaults its `override` to
`System.getenv(ENV_KEY)`; `GrinderApplication` passes it in, alongside every
other environment knob it reads.

Behaviour-preserving -- the same variable, read once, at a different call site
-- and no existing assertion changed: the tests already passed `override`
explicitly, which is what made the default dead weight.

The reason it matters is the two documentation guards. ReadmeConfigurationTest
and SystemdUnitConfigurationTest both scan the entry point's source for the
names it reads, so a knob consulted anywhere else is invisible to them and can
be added without ever reaching the README table or the shipped unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playwright + Chromium was listed as a host prerequisite in the module's
CLAUDE.md and nowhere an operator reads: README §1 named only Docker and JDK
21+, and install-grinder.sh checked only those two. It now checks
$SERVICE_USER's own Playwright cache -- the caller having a browser proves
nothing, since Playwright keeps them under $HOME -- and says what to run.

Also documented: SPC_GRINDER_CONTAINER_USER (README table, new §5 *Container
identity*, systemd unit, installer summary), the /as-properties endpoint with
its do-not-point-the-grinder-at-itself landmine, and three troubleshooting rows
-- the unwritable mount, the two distinct locked-CurseForge failures, and the
firewall case for a reverse proxy that still cannot connect after the bind was
widened (timeout vs. connection-refused is the discriminator).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Landmines in the two module files (a container must run as the owner of the
directory it mounts; the /download navigation is supposed to fail), the grinder
and clientside rows in the root table with re-derived counts, and the
blow-by-blow in REFACTOR-LOG.

The reporting failure is recorded beside the bug on purpose: the tail-quoting
warning is what sent three rounds of diagnosis after the wrong subsystem, and
"every loader failed at once" is the give-away worth keeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten commits reviewed against the refactoring conventions. No HIGH findings.
Five MEDIUM (a code change inside a docs commit, two unpinned joins, a comma
that silently corrupts the published list, a silently-ignored malformed
override) and five LOW.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M4: an entry containing a comma cannot survive the round trip. The consumer
splits the value on commas, so one such entry reaches every client as two bogus
`startsWith` matchers against real mod filenames. Filenames may legally contain
commas and stems are derived straight from them, so it needs nothing unusual to
happen -- and it is silent at both ends. Pinned: such an entry is dropped from
both lists, and the document says it dropped something, because dropping
silently is how a list quietly goes wrong.

M5: a malformed SPC_GRINDER_CONTAINER_USER is discarded and the owner used
instead. Right behaviour -- nonsense must not reach Docker -- but this is the
one knob whose purpose is overriding a resolution that already went wrong once,
so the operator has to learn it was ignored.

Red: `isUsableOverride` does not exist, and the renderer currently emits a
comma verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M4: an entry containing a comma is dropped from both published lists instead of
emitted, and the document states how many it dropped. Emitting it corrupted the
value at the far end into two bogus prefix-matchers; dropping it silently would
just move the corruption somewhere quieter.

M5: a malformed SPC_GRINDER_CONTAINER_USER is now logged as ignored, naming the
value that was used instead. Only when one was actually set -- an unset variable
is the normal case and needs no comment. `isUsableOverride` carries the rule, so
what "usable" means is pinned rather than living inside an if.

Numeric ids only, and now documented as to why: the ids resolve against the
*container's* /etc/passwd, so a host account name either fails to start the
container or silently means somebody else inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 17's LOW findings, no behaviour change:

- ReportServer.respond chose ISO-8859-1 for /as-properties, but the renderer
  escapes everything outside printable ASCII to \uXXXX, so both encodings
  produce identical bytes. The declared charset in the header stays -- that one
  is load-bearing for the consumer -- and the branch goes.
- The continuation-line rendering appended a backslash and removed it again for
  the last entry, asking the same index question twice in one expression.
- The two lists are normalised once each rather than twice, so the header's
  counts and the rendered entries cannot disagree.
- Test hygiene: two `!!` replaced with the repo's `?: Assertions.fail(...)`
  idiom, two fully-qualified names replaced with imports.

Every existing assertion unchanged and green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M2 — BrowserDownloader changed three behaviours and pinned one. The other two,
DOMCONTENTLOADED over Playwright's default `load` and a 60s budget over the 30s
default that timed out on every locked file, are decided host-side before any
browser exists, so they are pinned by *building* the options rather than by
reading the source for them.

M3 — /as-properties had no guard on its production wiring. ReportServerTest
supplies its own lambda, so the endpoint could be fed an empty list and every
test would stay green while every polling client silently lost the shipped
entries. `main` cannot be executed (it builds an ApiWrapper and a Docker
client), so the join is asserted against its source, the same technique
ReportBindWiringTest uses for SPC_GRINDER_HOST. The second case pins that the
lists are read per request rather than captured at startup -- a daemon runs for
weeks, and a snapshot taken at boot defeats the point.

Red: `navigationOptions`/`downloadOptions` are not extracted yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both `page.navigate` calls and the download wait now take their options from
`navigationOptions()` / `downloadOptions()` instead of constructing them inline
three times. Behaviour-preserving -- identical values, built in one place -- and
no existing assertion changed.

The point is testability: the choice of DOMCONTENTLOADED and the timeout are
made host-side, before Chromium exists, so extracting them turns two behaviours
that could only be verified by reading the source into two that are executed by
the suite.

Teeth verified for the sibling wiring guard by breaking the join and watching
FallbackListWiringTest go red on "the published clientside list must come from
SPC's own property".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other guard on this endpoint checks the document against
`java.util.Properties`, which is a *model* of the consumer. This one is the
consumer: a real `UpdateConfig.updateFallback` pointed at a running ReportServer
over an ephemeral loopback port, asserting the entries land in
`GenerationConfig.clientsideMods` -- the list generation actually excludes mods
with -- that an INCONCLUSIVE finding does not, and that the whitelist survives.

No Docker and no internet needed: PropertyStore is no-arg constructible and the
server binds loopback, which is why this is a plain test rather than a gated IT.

Teeth verified by dropping the continuation backslash from the renderer: the
whole list collapses to `[, entityculling-]` and both cases go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second pass over the branch. Iteration 17's ten findings verified closed.

P2-M1 (fixed here): UpdateConfig replaces a client's lists wholesale, so the
base list this endpoint publishes is only as fresh as the grinder's own SPC
instance -- an old build, or one that could not reach the repository at startup,
hands every client a staler list than they had. Now stated in README §5 and the
module landmine.

P2-M2 (open, cannot be closed from this host): the container-user fix has no
real-runtime verification. The attempt is recorded because its failure is
instructive -- with a named volume chowned to 1001:1001, a root container reads
it back as 1001:1001 and a --user 1001:1001 container reads the same inode as
0:0. That is Docker Desktop's id remapping, not kernel DAC, so neither the bug
nor the fix reproduces here and the run proves nothing. The audit carries the
two-command check to run on the Linux host instead.

P2-L1: dropped @JvmStatic from a helper with no Java callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No behaviour change; every existing assertion green.

- unrepresentable() counted duplicates while normalise() de-duplicated, so one
  mod dropped on three loaders was reported as three omissions -- sending a
  reader looking for two entries that never existed.
- The DOMCONTENTLOADED rationale sat both inline and in navigationOptions()'
  KDoc. Two copies of a reason is one copy that goes stale; the inline one goes.
- Root CLAUDE.md counts re-derived from the run that produced them (grinder 276,
  clientside 93) rather than adjusted by hand.

REFACTOR-LOG records the three audit passes, including the equivalence check:
develop's unmodified test tree against this branch's production code, 339
pre-existing guards, zero failures, zero compile errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
develop's unmodified test tree against this branch's production code: 339
pre-existing guards, zero failures, zero compile errors, no file needing
adaptation -- every changed signature gained a defaulted parameter.

Three LOW findings, all fixed. One item stays open and cannot be closed here:
the container-user fix has no real-runtime verification, because Docker
Desktop's id remapping means neither the bug nor the fix reproduces on this
workstation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: grinder container identity, CurseForge downloads, and /as-properties
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m29s
Qodana / scan (push) Successful in 8m7s
Continuous / Build JAR (push) Successful in 12m22s
Docker Test / build image (push) Successful in 13m59s
Documentation / Help image (push) Successful in 2m51s
Test / build (push) Successful in 15m14s
Qodana / notify (push) Successful in 8s
Continuous / Build AppImage (x86_64) (push) Successful in 2m40s
Docker Test / build image (pull_request) Successful in 14m25s
Test / build (pull_request) Successful in 14m6s
Continuous / Build AppImage (aarch64) (push) Successful in 3m18s
Continuous / Build Install4J Media (push) Successful in 10m31s
Continuous / Continuous Pre-Release (push) Successful in 6m59s
f2be9d5b4e
Three production defects found from a live grinder run, plus one new endpoint.

- Containers now run as the owner of the directory they mount, not the image's
  baked-in USER 1000:1000. That mismatch appeared with the systemd migration and
  made every loader install fail with Permission denied inside the pack -- while
  the visible error, twenty lines later, was the JVM's missing @argfile.
- A failed install is diagnosed from the whole console rather than its last 25
  lines, which is where the cause was not.
- Locked CurseForge files survive their own download: Chromium aborts a
  navigation that becomes a file transfer, and that throw used to discard the
  download it had just triggered. Navigations also stop waiting for the `load`
  event, which an ad-laden CurseForge page reaches long after it is usable.
- /as-properties publishes the fallback clientside-mod list -- shipped list plus
  crash-proven findings -- for an SPC instance's fallback.updateurl to poll, so
  the list stops depending on hand-edits to the repository. HIGH confidence only.
- Deployment gaps closed: Playwright/Chromium is now a checked prerequisite,
  SPC_GRINDER_CONTAINER_USER is documented in three places, and the firewall
  case for an unreachable reverse proxy has a troubleshooting row.

Audited three times (REFACTOR-AUDIT iterations 17-19); every finding fixed
except one that cannot be closed off the Linux host, recorded there. develop's
unmodified test tree against this branch's code: 339 guards, zero failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `Mirror to GitLab.com` step reported `exitcode '22': failure` in 0 s and
nothing else. Exit 22 is curl's --fail, i.e. HTTP >= 400, and the cause is not
in the workflow: gitlab.com's copy of the repository has not received a commit
since 2024-04-28. Its newest commit is `RELEASE: 5.2.1` (071e55402), 1397
commits behind main, and its newest tag is 5.2.1 -- four major lines behind the
9.0.0-alpha tags being released.

GitLab's POST /releases needs either an existing tag_name or a `ref` commit to
mint the tag from, and that repository has neither, so the step could never have
succeeded: the git push-mirror died with the GitLab->Forgejo migration, which is
the same migration that added this step. Its comment anticipated a 404 and added
`ref` to fix it -- the right fix for a mirror that is behind, useless for one
that is stopped. Checked anonymously; the project id 32677538 resolves fine, so
this was never a wrong-target or token-scope problem.

GITLABCOM_TOKEN stays: the maven job still uploads to GitLab's package registry,
which does not depend on git refs and is therefore unaffected.

Second change, same job. Every call used `curl -sf`, which sets exit 22 and
discards the response body -- the only place these APIs say what is wrong, and
the reason this failure arrived as four words. The Forgejo notes fetch and both
GitHub calls now capture the status with `-o file -w '%{http_code}'` and print
the body before exiting. Verified by executing the idiom against gitlab.com's
API: an existing project takes the success branch on 200, a missing one takes
the error branch on 404 and prints `{"message":"404 Project Not Found"}`.
`release`, `virustotal` and the release-body update still use `curl -sf`;
flagged in the rule file rather than changed here, to keep this to one concern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `Mirror to GitLab.com` step could never have succeeded. gitlab.com's copy of
the repository has not received a commit since 2024-04-28 -- newest commit
`RELEASE: 5.2.1`, 1397 behind main -- so GitLab's release API has neither the
tag nor a `ref` commit to mint it from, and returns HTTP >= 400 every time.
GitHub is now the only outward mirror.

GITLABCOM_TOKEN stays for the maven package-registry upload, which does not
depend on git refs.

Also in the same job: the `curl -sf` calls that turned this into a four-word log
line now capture the HTTP status and print the response body before exiting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stopping the unit currently relies on two things that do not hold.

Workers: `requestStop` sets a flag the worker loop reads *between* candidates,
so a worker parked inside a boot keeps going until that boot finishes -- up to
its fifteen-minute budget -- while systemd counts down to a SIGKILL that
orphans the container. Pinned: an in-flight verification is interrupted, and a
worker that ignores the interrupt is abandoned at the grace window rather than
waited out.

Containers: `close` went straight to `remove --force`, which is a SIGKILL to
PID 1 -- an in-flight Minecraft server loses its world save. Pinned by trapping
TERM inside the container and asserting the handler ran.

Two holes with no coverage at all:
- Nothing stopped a worker creating a container *after* close had swept. Once
  shutdown hooks run the JVM no longer waits for worker threads, so a worker
  between its loader install and its mod boot could start one that was never
  removed. Pinned: a closed engine refuses to create.
- A SIGKILLed JVM leaves containers running, parented by the docker daemon
  rather than the unit's cgroup, and they carried no label, no name and no
  autoremove -- so nothing could ever find them again. Pinned: a labelled
  orphan is reaped by a fresh engine, which is what the next start brings up.

Red: `GrindPool.awaitStop`, `ContainerEngine.reapOrphans` and the OWNER_LABEL
constant do not exist. The container cases are gated behind GRINDER_DOCKER_IT=1
as the rest of that file is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stopping the unit now signals both halves and gives them one shared 15s window
(SHUTDOWN_GRACE) before anything is killed.

Containers. `close()` issues `docker stop` with the window as its timeout --
SIGTERM, then the daemon's own SIGKILL -- instead of going straight to
`remove --force`, which was a SIGKILL to PID 1 and cost an in-flight Minecraft
server its world save. Stops run concurrently, capped at 8: the window is per
container, so ten workers stopped one after another would be ten windows and
would run past the unit's TimeoutStopSec into the very SIGKILL this avoids.

Workers. `GrindPool.awaitStop(grace)` signals, interrupts and joins with a
deadline. `requestStop` alone could not end a shutdown, because its flag is only
read between candidates -- a worker parked in a boot kept going for up to that
boot's fifteen-minute budget. A worker that ignores the interrupt is abandoned
and logged rather than waited for; nothing can force a thread to die in the JVM,
so the real force-kill is the process exiting.

Two holes closed at the same time:

- A `closed` flag makes the engine refuse to create a container once `close()`
  has begun, re-checked after the tracking-set add so a container created in
  the gap removes itself. Previously a worker between its loader install and
  its mod boot could start one behind the sweep and have it outlive the JVM.
- Every container carries the label `de.griefed.serverpackcreator.grinder`, and
  startup reaps whatever wears it. That is the only recovery from a SIGKILLed
  JVM: containers are children of the docker daemon, not of the unit's control
  group, so systemd never touches them, and with no label, no name and no
  autoremove nothing could find them again -- an orphan survived every restart
  forever. LANDMINE noted in the code: the label says "a grinder made this", not
  "this grinder", so it assumes one instance per daemon, which the unit is.

Verified against a live daemon (docker 29.7.2, GRINDER_DOCKER_IT=1): 6/6 cases,
including a container trapping SIGTERM to prove the signal arrives and is
honoured before removal, and a labelled orphan reaped by a fresh engine. The
pre-existing drain case now takes 15.6s rather than being instant -- busybox's
shell does not forward SIGTERM to `sleep`, so it uses the whole window and is
then killed, which is exactly the intended behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README gains a "Stopping it" section with the five ordered steps and the two
facts that make them necessary: workers are threads, so systemd has nothing to
kill separately, and containers belong to the docker daemon's control group
rather than the unit's, so systemd cannot reach them at all.

TimeoutStopSec 120 -> 60, with the arithmetic stated: 15s per container, 8 at a
time, so ceil(workers/8) * 15s plus removal and JVM exit. Raise it above 16
workers; lowering it below the window is the one change that actively causes the
leak. A SIGKILL is now recoverable rather than fatal, since a start reaps
orphans by label.

Module landmines for both, plus the one-grinder-per-daemon assumption the label
carries, and the root table's grinder row and count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stopping the unit now signals both halves and gives them one shared 15-second
window before anything is killed, instead of force-removing containers outright
and letting a worker run out a fifteen-minute boot budget.

Containers are children of the docker daemon, not members of the unit's control
group, so systemd never touches them and the shutdown hook is the only thing
that can. Two holes closed alongside: the engine now refuses to create a
container once closing has begun, and every container carries a label so a
start can reap what a SIGKILLed JVM left running -- previously an orphan was
unfindable and survived every restart.

Verified against a live daemon: 6/6 gated cases, including a container trapping
SIGTERM to prove the signal arrives before removal.
One HIGH: GrindPool publishes its worker list only after starting the threads,
so a SIGTERM in that window finds an empty list, interrupts nobody, and reports
a clean stop that did not happen -- the branch's own guarantee, wearing a
success message.

Four MEDIUM (unpinned hook wiring, unpinned 15s window, unenforced coupling
between TimeoutStopSec and the window, an uninjectable constant) and three LOW.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 20, H1/M2/M3/M4.

`tracksEveryWorkerBeforeAnyOfThemCanRun` holds the invariant behind H1: a
running worker is always already tracked, so awaitStop can never signal a subset
and call it a clean stop. **Neither it nor its sibling was observed red against
the unfixed code, and the test says so** -- the interleaving could not be
provoked at 8 workers or at 64, because the first `Grinder.grind` initialises
log4j and that reliably delays worker 1 past the thread-construction loop. The
window is real and opens on every pass; entering it needs a SIGTERM inside it,
which is what makes it rare rather than harmless.

ShutdownWiringTest covers the joins nothing could execute, the same technique
ReportBindWiringTest and FallbackListWiringTest use: that the hook closes the
engine BEFORE waiting on workers (reversing them re-opens the
create-behind-the-sweep hole), that startup reaps orphans, that SHUTDOWN_GRACE
is the 15s the unit and README promise an operator, and that TimeoutStopSec
outlasts a 16-worker cleanup.

Teeth verified for all three source-reading guards by breaking each in turn:
TimeoutStopSec=20 fails on "does not outlast a 30s cleanup", removing the reap
call fails on "no longer reaps orphaned containers", and moving engine.close()
after awaitStop fails on "must be closed BEFORE waiting on workers".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
H1. `grindAll` started its threads inside the `map` and assigned the field the
shutdown path reads only afterwards, so a SIGTERM landing in that window found
an empty list: awaitStop interrupted nobody, joined nothing, and returned true,
because an empty list satisfies "none alive". The hook logged no warning, the
JVM exited, and the workers were still running -- the exact failure this was
written to prevent, wearing a success message. Threads are now constructed,
published, and only then started.

Also from iteration 20:

- M5: the grace window becomes a constructor parameter defaulting to
  SHUTDOWN_GRACE, so its value is pinnable and an IT need not burn the
  production window to assert that a signal was sent. Production is unchanged.
- L3: a container the shutdown sweep already removed no longer logs
  `Could not remove container` at WARN. It is the expected 404, and at WARN it
  was indistinguishable in the journal from a removal that genuinely failed --
  the same class of noise that made the install diagnosis take three rounds.
- L1/L2: the IT's waitForContainer took an engine it never used (it polls the
  daemon by label), and the orphan case left its engine open with a worker still
  polling a removed container.

Re-verified against docker 29.7.2: 6/6, and the signal case now takes 1.1s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One HIGH: the one-shot run constructs its GrindPool inline and never stores it
in activePool, so Ctrl-C signals and awaits nothing -- the hook's requestStop
and awaitStop both no-op through a null. Containers still stop, which is why it
looks like it works.

One MEDIUM: the workers get a second full 15s window after the containers may
have spent the first, so the real worst case is 30s while the log line, the
comment and README all promise one shared window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P21-H1: the one-shot run builds its GrindPool inline and never stores it in
activePool, the only handle the shutdown hook has, so Ctrl-C on the end-to-end
verification path signals and awaits nothing -- both calls no-op through a null
receiver. It looks like it works because the engine still closes and the boots
collapse with their containers.

P21-M1: the workers are handed a full window *after* the containers may have
spent one, so the real worst case is 30s while the hook's log line, its comment
and README all promise a single 15-second budget -- and the unit's
TimeoutStopSec arithmetic assumes the two overlap.

Worth recording how the first guard was nearly useless: counting
`activePool.set(` occurrences made it pass, because the pass loop also clears
the reference with `activePool.set(null)` and a reset counts as a registration.
It now checks each construction against what follows it. Caught only because
the expected red did not arrive -- which is the whole reason for looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P21-H1: the one-shot pool is registered in activePool like the continuous
path's. It was built inline, so the shutdown hook -- whose only handle is that
reference -- signalled and awaited nothing on Ctrl-C. Containers still stopped,
which is why the gap survived: the visible behaviour was almost right.

P21-M1: the hook now takes a deadline at entry and hands awaitStop what is left
of it, instead of a second full window after close() may have spent the first.
The worst case was 30s while the log line, the comment and README §5 all
promised one shared 15-second budget, and the unit's TimeoutStopSec arithmetic
assumed the two overlap. In practice most of the window survives to the
workers, since stopping the containers is what frees them.

P21-L1: activePool is read once. Read twice, the two calls could in principle
signal one pass's pool and wait on the next one's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pre-shutdown test tree against current code: 276 guards, zero failures.

Two MEDIUM. The promised single 15s window is only true up to eight in-flight
containers -- above that the stops batch, so at the deployed 10 workers the
container phase alone is 30s and the workers get nothing. And a container that
burns the whole window leaves awaitStop zero milliseconds, making its warning
guaranteed rather than informative.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 22.

P22-L1: `theWorkersGetTheRemainderOfTheWindowRatherThanASecondOne` asserted an
absence -- `!contains("awaitStop(SHUTDOWN_GRACE)")` -- which passes for any
spelling that is not that exact string. It now asserts what the code must do:
take a deadline at entry and hand awaitStop the remainder.

P22-M1: the TimeoutStopSec arithmetic hard-coded the batch size of 8 in a test,
a unit comment and the README, so the promised single window silently became
two at the deployed 10 workers. The guard now reads the real cap, which makes
the relationship checkable rather than transcribed.

Red: MAX_PARALLEL_STOPS is private to the engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P22-M1: MAX_PARALLEL_STOPS 8 -> 64, and it moves next to SHUTDOWN_GRACE where
the relationship is visible. The window is per container, so a cap below the
number in flight turned one window into several -- at the deployed ten workers
the container phase alone was 30s and the workers were left with none of the
shared budget. A `docker stop` is an HTTP call that spends its time waiting and
concurrent boots are memory-bound at roughly twenty, so a cap above any real
worker count costs nothing. The arithmetic in the unit comment, the README and
the wiring guard collapses to one window; the guard now reads the real constant
instead of transcribing it.

P22-M2: the workers get a one-second floor. A container that ignores SIGTERM can
eat the whole window, and handing awaitStop 0ms meant the interrupt it had just
sent could not possibly be observed -- the "did not stop" warning was guaranteed
rather than informative. Worst case becomes 16s against a 60s stop timeout.

Full suite including the Docker ITs: 290, zero failures, no containers left on
the host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: three audit passes over the grinder's shutdown, and their fixes
Some checks failed
Documentation / Writerside webhelp (push) Successful in 1m2s
Continuous / Build JAR (push) Successful in 12m42s
Qodana / scan (push) Successful in 7m51s
Docker Test / build image (push) Successful in 14m31s
Test / build (push) Successful in 15m36s
Docker Test / build image (pull_request) Successful in 17m19s
Test / build (pull_request) Successful in 14m8s
Continuous / Build Install4J Media (push) Has been cancelled
Continuous / Continuous Pre-Release (push) Has been cancelled
Continuous / Build AppImage (aarch64) (push) Has been cancelled
Qodana / notify (push) Has been cancelled
Documentation / Help image (push) Has been cancelled
Continuous / Build AppImage (x86_64) (push) Has been cancelled
bd31464f98
Each pass found something the previous had not.

20 — the feature's own guarantee rested on a race: worker threads were started
before the list the shutdown path reads was published, so a stop landing in that
window would have interrupted nobody and reported a clean stop. Also pinned the
hook's wiring, the 15s window and the unit's stop timeout, none of which had a
guard.

21 — the one-shot run never registered its pool, so Ctrl-C on the end-to-end
verification path signalled and awaited nothing. And the workers were handed a
second full window after the containers had spent the first, against three
documents promising one shared budget.

22 — that shared window was still not real above eight in-flight containers,
because the stop concurrency was capped there; at the deployed ten workers the
container phase alone was thirty seconds. Cap raised above anything a host can
run, and the arithmetic in three places collapsed to one window.

Equivalence: the pre-shutdown test tree against current code, 276 guards, zero
failures. Full suite with the Docker ITs: 290, zero failures, nothing left on
the host.
Merge branch 'alpha' into develop
All checks were successful
Continuous / Build JAR (push) Successful in 11m41s
Docker Test / build image (pull_request) Successful in 17m1s
Docker Test / build image (push) Successful in 15m40s
Documentation / Writerside webhelp (push) Successful in 1m28s
Test / build (pull_request) Successful in 15m1s
Qodana / scan (push) Successful in 10m0s
Continuous / Build AppImage (x86_64) (push) Successful in 3m28s
Test / build (push) Successful in 13m8s
Continuous / Build AppImage (aarch64) (push) Successful in 3m29s
Qodana / notify (push) Successful in 1m24s
Documentation / Help image (push) Successful in 4m53s
Continuous / Build Install4J Media (push) Successful in 11m43s
Continuous / Continuous Pre-Release (push) Successful in 6m4s
4f7a1429a3
Two guards, both red at this commit.

ContainerResourcesTest pins the cores -> CFS-quota arithmetic that
SPC_GRINDER_CPUS is going to be, including the two ends nothing else
would notice: a quota computed against an assumed period throttles a
boot to a fraction of what was asked for (which then reads as a hanging
mod, not as a misconfiguration), and a quota under 1ms is refused by the
daemon at container-create time rather than at the knob.

CpuLimitWiringTest pins the join `main` has to make, the same defect
class ReportBindWiringTest exists for: every container-creating
collaborator has accepted a ContainerResources since it existed, and
`main` passes none -- so the cap is the hardcoded default and no
environment can change it. Both call sites are asserted, the mod boot
and the loader install.

Red as committed:

    ContainerResourcesTest.kt:38:43 Unresolved reference 'forCpus'.
    ContainerResourcesTest.kt:55:64 Unresolved reference 'cpuPeriod'.
    (+6 more) -> compileTestKotlin FAILED

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ContainerResources.forCpus(cores) is the conversion an operator-facing
knob needs: cores are the unit they think in, docker wants microseconds
per period, and the two only relate through a period that was never
stated anywhere. cpuPeriod is now a field defaulted to the 100ms the
kernel has always used, and DockerJavaContainerEngine sends it alongside
the quota -- sending only the quota left the real cap dependent on a
daemon default nothing here would have noticed changing.

The two ends are handled at the knob rather than at container creation:
0 cores means an unset quota (docker's own "no limit", matching how 0
reads for SPC_GRINDER_CACHE_TTL_DAYS), and a positive value below the
daemon's 1ms floor is raised to it, because a quota docker refuses fails
every boot instead of throttling it. A negative count throws.

No behaviour change for existing installs: the default quota is still
200_000 against a 100_000 period, i.e. the same ~2 cores, and
ContainerResourcesTest pins that equivalence rather than asserting it in
prose. ContainerResourcesTest green; CpuLimitWiringTest still red --
main does not read the knob yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC_GRINDER_CPUS caps every container the daemon starts -- each mod boot
and each loader install -- in cores, like docker's own --cpus. The cap
itself is not new: ContainerResources has carried a 200_000us quota since
the container runtime existed, and every collaborator has accepted one.
`main` simply never passed one, so the value was unreachable from the
outside; this is the same shape of gap SPC_GRINDER_HOST closed for the
report's bind address.

Default 2, i.e. the two cores every existing install already ran on, so
nothing is re-tuned by upgrading. What the grinder can occupy is
therefore workers x cpus (2 x 2 = 4 by default), which is what the README
now states -- together with the part the knob does not cover: the daemon's
own host-side work (mod downloads, pack generation, the headless Chromium
a locked CurseForge file needs) is a normal process and is capped the
normal way. The unit gains a commented CPUQuota= for exactly that, with
the reason it cannot reach the boots: containers are children of the
Docker daemon, not of the service's control group -- the same fact that
makes the shutdown hook the only thing able to stop them.

Verified against a live daemon (Docker 29.7.2), not reasoned about:
DockerJavaContainerEngineIT.theCpuCapReachesTheKernelWithItsPeriod reads
the cap back from inside the container's own cgroup, since docker echoing
a HostConfig only proves the field was transmitted. It uses a *non-default*
50ms period so the guard has teeth, and it does: with .withCpuPeriod
dropped, a requested 1.5 cores arrived as `75000 100000` -- 0.75 cores,
silently halved. That is the failure the assertion exists for.

Suite: 298 tests, 0 failures, 22 skipped (bind-address guards need a
non-loopback IPv4); the gated Docker IT green in full at 7/7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Context files for the new knob, and the one fact worth landmining: send a
CFS quota with its period or the cap is whatever the daemon's default
period makes it -- measured at 0.75 cores for a requested 1.5.

Grinder row 290 -> 298 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One HIGH (a positive cap that rounds to a 0us quota returns *uncapped*,
because forCpus uses the computed quota as its own sentinel), four
MEDIUM (a README section inserted mid-section, a startup line that logs
the derived quota instead of the cores the operator set, a guard shipped
in the same commit as the code it pins, and the installer's "three worth
a decision" list still not naming the new knob), four LOW.

Every claim measured rather than recalled, including three the branch's
own docs assert: the daemon's 1ms floor and its verbatim error, quota 0
reading as `max 100000` in the container's cgroup, and -- the one that
changes the fix list -- a 1000-core quota being accepted on a 16-core
host, so an over-large value needs a sentence and not a clamp.

Base equivalence clean: develop's 290 unmodified guards against the
branch's production code, 0 failures, 0 compile errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 23, H1. forCpus reads its *computed* quota as the
uncapped sentinel, so a count small enough to round to 0us returns quota
0 -- which is docker's "no limit", verified in the container's own
cgroup as `max 100000`. A request for the smallest possible cap
therefore yields none at all, the one direction a hardening knob must
not fail, and against a KDoc promising the floor instead.

Also pins the two values String.toDouble() accepts and the arithmetic
cannot: infinity rounds to Long.MAX_VALUE (a quota so large it means
uncapped) and NaN rounds to 0 (uncapped outright).

Red as committed:

    aCapTooSmallToRoundIsStillACapAndNotUncapped
      expected: <1000> but was: <0>
    aNonFiniteCountIsRejectedRatherThanRoundedIntoNonsense
      Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.

L2 from the same audit: theDefaultIsUnchangedByTheKnobExisting was not a
sentence; it asserts the shipped default is exactly two cores, so it now
says that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 23, H1. forCpus branched on the computed quota, so any
count under 5e-6 cores rounded to 0us and returned the unset quota --
docker's "no limit" -- instead of the floor its own KDoc promised. The
smallest possible cap became no cap, which is the wrong direction for a
knob that exists to contain untrusted mods. The test committed before
this one went from `expected: <1000> but was: <0>` to green.

The branch is now on `cpus == 0.0`, the only input that means uncapped,
with the sentinel named (UNSET_QUOTA) so the two zeroes cannot be
confused again. Non-finite input is rejected up front: both survive
String.toDouble(), and both round into a lie -- infinity into a quota so
large it means uncapped, NaN into 0.

Also from that audit: Math.round -> roundToLong (L1, Java-ism), and the
KDoc no longer implies docker's --cpus validation comes with its
arithmetic (L3). It does not -- measured on a 16-core host, a 1000-core
quota is accepted and the cgroup reports it verbatim -- so an over-large
value is documented as the operator's problem rather than clamped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 23, M2. The startup line logged the derived quota, which
answers in a unit nobody set -- and at the documented escape hatch
printed `cpuQuota=0/100000`, reading as "zero CPU" for the value that
means uncapped. Two guards: the rendering itself, and that `main`'s
startup line goes through it, which is the same join CpuLimitWiringTest
already covers for the cap reaching the containers.

Red as committed: Unresolved reference 'cpuCapDescription' (x3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit iteration 23, M1/M2/M4 — one concern, three surfaces, all the
operator's view of the same knob.

The startup line now says `cpus=2.0 cores (200000/100000µs)`, or
`cpus=uncapped`, through ContainerResources.cpuCapDescription(). It used
to print the derived quota, which answers in a unit nobody set, and at
`SPC_GRINDER_CPUS=0` printed `cpuQuota=0/100000` -- "zero CPU" for the
value that means the opposite. The raw pair rides along because it is
what the kernel was given, so it can be compared against a container's
own cpu.max when a boot looks throttled. Guard teeth checked by putting
the old line back and watching theStartupLineStatesTheCapInTheOperators
Unit fail; its matcher is bounded to the closing paren on its own line,
since a lazy match to the first `)` stopped inside the call it asserts on.

README: `### Capping CPU` had been inserted mid-section, leaving "Keep
the host awake" -- a paragraph about suspends -- as the closing advice of
the CPU section instead of the sizing one. Order restored, and the sizing
opener ("a memory question rather than a CPU one") now points at the new
section rather than contradicting it.

deploy/install-grinder.sh listed "Three worth a decision rather than a
default" and named WORKERS as the throughput lever without its CPU twin.
Four now, with the cgroup caveat, because the installer is the operator's
first surface and the one no guard test scans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight of nine findings fixed; M3 (a gated IT shipped in the same commit
as the code it pins) is accepted rather than rebased away, with the
reasoning in the audit file: the rewrite would invalidate every hash the
report cites, and the guard is a gated IT whose teeth were checked by
removal and recorded in three places. Stated as a judgement, not omitted.

Grinder row 298 -> 303 tests; the skip range becomes 16-23 because the
gated Docker IT grew a case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The memory cap is the last hardcoded half of the per-container budget,
and the one with the largest blast radius: with javaArgs left empty --
which the grinder never sets -- the server's heap is *derived* from this
number by the JVM's own container awareness, measured at 25% (Temurin 21,
--memory=3g -> MaxHeapSize 805306368, --memory=1g -> 268435456). So the
guards pin the conversion, the 3 GiB default the worker-sizing advice
divides by, the daemon's own 6MB floor, and the same input-decides-
uncapped rule the CPU cap now has.

CpuLimitWiringTest becomes ContainerLimitsWiringTest: it guarded one knob
reaching the containers and now guards both, through the single
forLimits() call, plus both caps appearing on the startup line. Assertions
are the existing ones with the memory equivalents added; only the matcher
that locates the variable changed, which is the reference-only kind of
test edit the conventions carve out.

Red as committed: Unresolved reference 'forLimits' (x7), and the wiring
guards fail on a `main` that reads no memory knob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SPC_GRINDER_MEMORY_GIB completes the per-container budget: cores and
gibibytes now come from the environment through one
ContainerResources.forLimits call, on exactly the rules the CPU cap
established -- an exact 0 is uncapped, a smaller positive value is raised
to the daemon's own floor ("Minimum memory limit allowed is 6MB") rather
than refused by it, and negative or non-finite input throws. The default
stays 3 GiB, so no existing install changes.

It ships with a warning rather than as another throughput lever, because
the default is load-bearing in three directions and only the third is
obvious:

  - The packs the grinder builds leave `javaArgs` empty, so nothing
    passes -Xmx and the JVM derives the server's heap from the cgroup
    limit. Measured on Temurin 21: --memory=3g -> MaxHeapSize 805306368
    (768 MiB, 25%), --memory=1g -> 268435456. Lowering the cap starves
    boots of heap.
  - It is the divisor in README §5's worker-sizing formula, so raising it
    without lowering SPC_GRINDER_WORKERS over-subscribes the host by
    exactly that factor.
  - Both failures are OOM kills scored INCONCLUSIVE -- they look like
    mods that hang, not like a misconfigured host, so the operator gets
    no signal that they did this.

The startup line now reports both caps (`cpus=… memory=3.0 GiB`), the
table row and unit comment carry the warning, and the sizing section
gained the paragraph explaining why "grind faster" means WORKERS or CPUS
and never this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The literal pins on `main`'s fallbacks cannot catch a drift *between* the
two places a default lives. A class default moved to 4 GiB with `main`
still falling back to "3" leaves every direct ContainerResources()
construction -- the fallbacks in ContainerCandidateVerifier,
ContainerServerRunner and DockerLoaderInstaller, plus
ReadmeConfigurationTest's own sizing check -- disagreeing with the daemon
that is actually running. So this asserts the identity, not the values.

Teeth checked rather than assumed: with the class default flipped to
4 GiB it fails with `expected: <...memoryBytes=4294967296...> but was:
<...memoryBytes=3221225472...>`, then passes again restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: record the memory knob and the heap derivation behind its warning
All checks were successful
Documentation / Writerside webhelp (push) Successful in 1m51s
Continuous / Build JAR (push) Successful in 11m10s
Docker Test / build image (push) Successful in 16m36s
Docker Test / build image (pull_request) Successful in 17m44s
Qodana / scan (push) Successful in 11m14s
Documentation / Help image (push) Successful in 3m18s
Continuous / Build AppImage (x86_64) (push) Successful in 2m23s
Continuous / Build AppImage (aarch64) (push) Successful in 3m7s
Test / build (pull_request) Successful in 15m54s
Test / build (push) Successful in 15m4s
Qodana / notify (push) Successful in 2m49s
Continuous / Build Install4J Media (push) Successful in 13m25s
Continuous / Continuous Pre-Release (push) Successful in 6m33s
54d32afb60
The measured fact worth landmining: the grinder's packs pass no -Xmx, so
the container's memory limit *is* each server's heap (25% of it on
Temurin 21), while also being the worker-sizing divisor. That is why the
knob ships with a don't-touch warning rather than as a throughput lever.

Grinder row 303 -> 310 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selection and staging were one function, so the only combination that
could ever be staged was the one selection returns -- the newest
bootable file. The crash re-check that follows needs to stage a
combination it picked itself, which is what the split enables.

Behaviour-preserving: prepareBootPack still selects exactly as before
and then delegates, and the release-only + minecraftAcceptable + loader-
availability predicate moves into bootableMinecraft() verbatim. Its
release set is now read once per call rather than once per selection,
which is the same single read prepareBootPack did.

BootVerifierSelectionTest's three guards (the minecraftAcceptable gate,
a real release advancing to download, the pre-release-only rejection)
pass unchanged, with no assertion edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported live on 2026-08-23: iron-chests -- a mod that is unarguably
server-safe -- came out HIGH on "Forge 48.1.0 / Minecraft 1.20.2 ->
CRASHED (exit 1)" with the note "Declared server/both but the server
crashed". Exactly one build of the mod is ever booted, so "this build
crashes" and "this mod is clientside" are indistinguishable, and the
engine resolves that ambiguity in the one direction that publishes a
wrong entry to the fallback list.

The guards pin the three pure decisions of a re-check that asks another
version of the mod:

- which versions to try -- the newest file of each *other* Minecraft
  version, most recent first, one per version, filtered by the same
  loader-availability gate selection uses;
- when it is worth trying -- only a CRASHED outcome that *contradicts*
  a declared server support, and only within a boot budget, so a true
  positive (metadata and boot agreeing) still costs one boot;
- how the attempts reconcile -- one clean boot clears the crash (a
  clientside mod cannot run server-side in any build), crashes
  everywhere keep it, and anything that learned nothing leaves it
  standing, same conservative direction as the loader-build re-check.

ClientsideVerifierServerSupportTest pins the shared "declares server
support" predicate, including the CurseForge shape that produced the
report: the platform has no sideness field, so the claim comes from
SPC's own jar scan, and a gate reading only the platform would never
arm for any CurseForge mod.

Red as committed: Unresolved reference 'pickRecheckCandidates' (x5),
'shouldRecheckAgainstOtherVersions' (x4), 'reconcileOtherVersionRecheck'
(x5), 'OtherVersionAttempt', 'declaresServerSupport' (x6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A crash that contradicts the metadata is now re-checked on up to two
other versions of the mod -- the newest file of each of the next two
most-recent Minecraft versions -- and one clean boot there clears it. A
mod that cannot run server-side cannot run server-side in *any* build,
so a version that boots proves the crash belonged to that build.

Before this, one build was booted and one build decided. iron-chests
came out HIGH off "Forge 48.1.0 / Minecraft 1.20.2 -> CRASHED (exit 1)"
with the note "Declared server/both but the server crashed", which is
the engine reading a broken build as a lying mod -- and HIGH is the one
confidence that reaches /as-properties, where a wrong entry silently
strips the mod from every server pack built against the list.

The gate is the contradiction, not the crash: only when the platform's
self-report or SPC's jar scan claims server support, which is the same
predicate that prints that note (ClientsideVerifier.declaresServerSupport,
now shared so the two cannot drift). Where the metadata already leans
clientside the crash confirms it, and re-checking would spend boots to
learn nothing while the crawl falls behind. Budget is a constructor knob
(otherVersionRecheckLimit, default 2, 0 switches it off), deliberately
not an env var -- no new deployment surface for a number nobody has
evidence to tune yet.

Conservative in every other direction, matching the loader-build
re-check: it stops at the first clean boot, crashes elsewhere corroborate
and are named in the detail, and an attempt that learned nothing --
staging failed, timed out -- leaves the crash exactly as it was.

Cost: two extra boots per contradicting crash, and only there. A true
positive still costs one boot, because a client-only mod's metadata and
its crash agree.

Teeth verified, not assumed: with the survivor lookup stubbed to null,
aVersionThatBootsCleanClearsTheCrash and
aSurvivorOutweighsAnotherVersionThatAlsoCrashed fail; with distinctBy
removed, recheckCandidatesAreTheNewestFileOfEachOtherMinecraftVersion
fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every attempt for one candidate stages into <work>/boot/<slug>-<loader>,
which staging wipes, so all of them write the same boot.log -- and the
grinder's reaper keeps exactly that one file and deletes the rest. The
reported verdict is often not the last attempt: the loader-build
re-check keeps the first crash, and the other-version re-check keeps it
across up to two further boots. The log a HIGH is diagnosed from is
therefore a different boot's console, which is worse than no log.

Three guards: the reported outcome's console is what ends up in its log
file, an outcome that never produced one leaves the file alone rather
than emptying it, and an unwritable log is swallowed -- diagnostics may
never cost a verdict, the same rule outcomeFor's own write follows.

Red as committed: Unresolved reference 'restoreDecisiveConsole' (x3),
and BootOutcome has no console to carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verdict a candidate is published on is frequently not the last boot
it ran -- both crash re-checks keep the original crash -- but every
attempt stages into the same wiped directory and therefore writes the
same boot.log, so the file held the last attempt's console. The
grinder's reaper then keeps that one file and deletes the staging around
it, so the crash behind a HIGH was diagnosed from a different boot.

BootOutcome carries the console it produced (outcomeFor already had the
lines in hand), and verify() writes the decided outcome's console back
after the re-checks. Best-effort like the write it repairs: an
unwritable log is a diagnostics problem, never a reason to lose a
verdict.

Pre-existing since the loader-build re-check landed, and the
other-version re-check makes it near-certain rather than occasional --
up to three boots now share the file.

Teeth verified: with the restore's writeText removed,
theReportedOutcomesConsoleIsWhatEndsUpInItsLogFile fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The durable facts a later session needs: that the gate is the
*contradiction* rather than the crash and why arming it wider would
spend two boots per true positive, that the predicate is shared with the
report note so the two cannot drift, that CurseForge's missing sideness
field means the claim can only come from the jar scan (so a
platform-only gate would never arm for the mod that prompted this), and
that every attempt for one candidate writes the same boot.log -- with
restoreDecisiveConsole having to stay last in verify().

The grinder's cross-cutting landmine gains the pacing consequence: one
contradicting crash can hold a worker for up to three boot budgets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 110 came from counting PASSED lines before the boot-console guards
landed, which is exactly the stale-number failure the conventions warn
about. Re-derived from serverpackcreator-clientside/build/test-results/
test/*.xml: 113 tests, 0 failures, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two operator-facing surfaces changed shape without the README saying so:
the report's Detail column now carries the re-check outcome on a crash
(corroborated elsewhere, cleared by a clean boot, or a single-version
sample), and the log gains a line when a crash contradicts the mod's
claimed server support. Both are how an operator tells 'this HIGH was
checked against other versions' from 'this HIGH is one build'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A mod published for a single Minecraft version has nothing to re-check
against, and the line said so as a count of zero -- while the verdict's
own detail said it in words. Both branches keep the "although the
metadata declares" phrase the README documents as the grep pattern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live iron-chests verdicts show the engine already held the disproof
and threw it away. One run, one project, two rows:

  Forge    HIGH ironchest- ... Forge 48.1.0 / MC 1.20.2 -> CRASHED (exit 1)
  NeoForge LOW  ironchest- ... NeoForge 21.11.45 / MC 1.21.11 -> SURVIVED

Exit 137 on the NeoForge row is a clean boot, not a kill:
ContainerServerRunner watches for the ready-line and stops the container
the moment it appears, so a SURVIVED boot always exits 137. The mod had
booted a real server minutes before being published as clientside.

It is decisive because the published artefact is a loader-agnostic
file-name stem matched with startsWith -- so the Forge HIGH publishes
"ironchest-" and strips the NeoForge build that just proved it boots.
Hence the guards key on the *entry* colliding, not merely on another
loader surviving: different stems strip nothing, and a mod really can be
client-only on one loader.

loaderDisprovingTheCrash pins what counts as disproof (only a clean boot,
only a colliding non-blank entry, never itself), supersededByLoader pins
what the verdict then says -- confidence re-derived from the metadata-only
aggregate rather than hardcoded, bootResult and excerpt kept because the
crash is a fact worth diagnosing, and the note *rebuilt* so it no longer
ends in "a strong clientside signal", which would be false.

The paging guards close the hole that made the committed other-version
re-check unreliable for exactly this shape of project: resolve() took the
newest 50 files across all loaders, so a mod that migrated Forge ->
NeoForge keeps publishing NeoForge builds until its older Forge builds
fall out of the window, leaving the re-check nothing of that loader to
boot. A project inside one page still costs one call, a totalCount that
never arrives stops at a stated cap, and a dependency stays deliberately
single-page.

Red as committed: Unresolved reference 'loaderDisprovingTheCrash' (x7),
'supersededByLoader' (x3), 'MAX_FILE_PAGES' (x2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
report() gains a second pass over the per-loader verdicts: where one
loader CRASHED and another SURVIVED deriving the *same* list-entry, the
crash stops counting as clientside evidence. Confidence drops to what
aggregate() yields for the same signals with no boot -- one ladder, not
a second one -- while bootResult and the crash excerpt stay, because the
server did crash and that is worth diagnosing. The note is rebuilt
rather than appended to: its old text ended in "a strong clientside
signal", which is the part that is no longer true.

iron-chests produced both rows in one run and the disproof was
discarded. It is decisive because the published artefact is a
loader-agnostic file-name stem matched with startsWith, so the Forge
HIGH publishes "ironchest-" and strips the NeoForge build that had just
booted a server to its ready-line. That is also why the guard keys on
the entry colliding rather than on any survival anywhere: different
stems strip nothing, and sideness can genuinely differ per loader.

CurseForge file resolution now pages instead of taking the newest 50.
Without it the other-version re-check is unreliable for exactly the
projects that produce this false positive: a mod that migrated Forge ->
NeoForge keeps publishing NeoForge builds until its older Forge builds
fall out of the single-page window, leaving that loader nothing to
re-check against. A project inside one page still costs one call
(totalCount says when to stop), the walk is capped at MAX_FILE_PAGES so
an unreachable totalCount cannot spin through the key's quota, and a
truncated read is logged rather than passed off as the whole history.
Dependencies stay deliberately single-page -- they need *a* usable file,
not a history, and paging each one would multiply a sweep's API calls.

The two guards layer: the within-loader re-check runs during the crash's
own boot, cross-loader reconciliation after every loader is in, so a
crash has to survive both.

Teeth verified: relaxing the entry-collision condition fails
aLoaderBootingUnderADifferentEntryDisprovesNothing; capping the file
walk at one page fails resolvePagesThroughEveryPublishedFile and
aTotalCountThatIsNeverReachedStopsAtTheCap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The durable facts: that the entry colliding is what makes two loaders'
verdicts contradict each other (the published stem is loader-agnostic),
that a SURVIVED row exiting 137 is normal rather than a kill to chase,
that the crash is preserved while only its standing changes, and that
the two crash guards layer with the within-loader one paying first.

Also the finding that made the earlier fix unreliable on its own: the
single-page CurseForge file window hides an older loader's builds
precisely for the migrated projects that produce the false positive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The report's Detail column can now say a crash was set aside because
another loader booted a server under the same list-entry, and the log
says so too. Also the new truncation warning, so an operator who sees it
knows a project's oldest builds were not read rather than that the
project has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
merge: stop one crashing build from being read as a clientside mod
All checks were successful
Continuous / Build JAR (push) Successful in 11m6s
Documentation / Writerside webhelp (push) Successful in 1m17s
Docker Test / build image (push) Successful in 16m31s
Test / build (pull_request) Successful in 16m10s
Qodana / scan (push) Successful in 14m0s
Test / build (push) Successful in 15m27s
Continuous / Build AppImage (x86_64) (push) Successful in 3m28s
Continuous / Build AppImage (aarch64) (push) Successful in 4m59s
Qodana / notify (push) Successful in 25s
Documentation / Help image (push) Successful in 8m21s
Continuous / Build Install4J Media (push) Successful in 12m52s
Continuous / Continuous Pre-Release (push) Successful in 6m30s
Docker Test / build image (pull_request) Successful in 14m14s
0bf11c962e
iron-chests was published HIGH -- and therefore into /as-properties, and
therefore out of every server pack built against that list -- off a
single boot: "Forge 48.1.0 / Minecraft 1.20.2 -> CRASHED (exit 1)". It
is not a clientside mod. Three things were wrong, each found by the one
before it.

One build decided a project. "This build crashes" and "this mod cannot
run on a server" produced identical evidence, and the engine broke the
tie toward the answer that silently strips a mod for everybody. A crash
that *contradicts* the metadata is now re-checked on up to two other
versions of the mod -- the newest file of each of the next two
most-recent Minecraft versions -- and one clean boot there clears it. The
gate is the contradiction, not the crash: where the metadata already
leans clientside the crash confirms it, so a true positive still costs
one boot.

The disproof was already in hand. The live verdicts showed both loaders
of the same project in one run: Forge CRASHED, NeoForge 21.11.45 /
1.21.11 SURVIVED, both deriving "ironchest-". The engine had booted a
real server with this mod, watched it reach its ready-line, and
published it as clientside anyway. It matters because the published
artefact is a loader-agnostic file-name stem matched with startsWith, so
the Forge crash strips the NeoForge build that had just proven itself --
which is why the new cross-loader pass keys on the *entry* colliding
rather than on any survival anywhere. Sideness can genuinely differ per
loader; a shared stem is what makes two verdicts contradict each other.

And the first fix would probably not have saved this mod. CurseForge
resolution took the newest 50 files across all loaders, so a project
that migrated Forge -> NeoForge keeps publishing NeoForge builds until
its older Forge builds fall out of the window -- hiding the evidence
from exactly the projects that produce the false positive. Resolution
now pages to totalCount, capped at 10 pages with a warning when it
truncates; dependencies stay single-page on purpose.

Two smaller fixes ride along. Every attempt for one candidate writes the
same boot.log, and the reported verdict is usually not the last boot, so
the console a HIGH was diagnosed from was a different boot's -- the
reaper keeps that one file and deletes the rest. And a crash never
erases its own evidence now: bootResult and the excerpt survive being
superseded, because the server did crash and that is worth diagnosing.

Suite 93 -> 126, zero failures; grinder and app suites green. Every new
guard had its teeth checked by sabotage rather than assumed: stubbing
the survivor lookup, dropping distinctBy, relaxing the entry collision,
capping the file walk at one page and removing the log restore each fail
exactly the guards that claim to cover them.

Not fixed, and still open: a distribution-locked CurseForge file is
never scanned, so it declares nothing, the other-version re-check does
not arm, and a crash there can still reach HIGH off one boot unless
another loader happens to survive under the same stem.
Griefed merged commit 68d9c57c5a into alpha 2026-08-23 18:08:11 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Griefed/ServerPackCreator!672
No description provided.