Skip to content

MP-H: two-client multiplayer harness (tools/mp.mjs) - #4

Open
aurablacklight wants to merge 11 commits into
mainfrom
mp/MP-H-harness
Open

aurablacklight wants to merge 11 commits into
mainfrom
mp/MP-H-harness

Conversation

@aurablacklight

@aurablacklight aurablacklight commented Oct 4, 2026 •

Copy link
Copy Markdown

Wave 0 packet MP-H of the multiplayer campaign (docs/multiplayer/work-packets.md).

What this adds

node tools/mp.mjs up | status | down --slot <n>: one dedicated server, one or two dev clients and their Foremen, on the campaign's port block for slot n, each client with its own game directory, profile and user name.

  • tools/mp.mjs, tools/lib/mp/{config,launch,processes,harness,index}.mjs: slot and port math, capped direct JVM launches (2 GB server, 2 GB per client by default), pid plus start-time identity for every process, durable launch intents, readiness waits, rollback on a failed up, idempotent down, and a small library for tests (wait for all clients, DevBridge commands on client A or B, server and client telemetry reads).
  • tools/lib/mp/export-launch.gradle: an init script that exports Loom's real run classpaths and arguments without running either run task or editing build.gradle.
  • tools/test/mp-*.test.mjs: 51 tests. tools/README.md: usage, resource budget, recovery, the --json summary shape.

The local server binds to loopback only, offline mode, no whitelist, no RCON. Nothing outside tools/ and the worknote is touched.

Verification

  • npm test --prefix tools: 61 pass, 0 fail, 0 skipped (macOS, Node 24).
  • Live on macOS (Apple Silicon), outside any sandbox, run by the coordinator: cycles of one, two and two clients, each up started immediately after the previous down on the same slot. Every up succeeded, every client reported ready over DevBridge, and no process was left after the last down. Harness JVMs: about 2.0 GB resident with one client, about 2.6 GB with two.
  • Not run on Windows. No Windows host here.

Review

Two reviewers, and a separate checker for the six findings they rated major: 17 findings in all, 15 fixed in the commits after the first. The worknote's "Review fixes" section says what each one led to.

  • Fixed, and seen working live: up soon after a down used to wait out --timeout, because the new client's DevBridge cannot bind its port while the old connections are in TCP TIME_WAIT. The harness now reads the client's DevBridge server error from its log, waits 35 s and relaunches that client once, inside --timeout. Both restarts in the live run went through this path.
  • Fixed: telemetry is read from logs/debug.log instead of the stdout capture, which Loom's dev logging limits to INFO, so the contract's debug-level events are visible. There is a readClientEvents twin.
  • Changed but never run: the three Windows items (test fixture path separators, -ExecutionPolicy Bypass, the wrapper started through Start-Bg). Graceful server shutdown on Windows still needs stdin or a remote command; a forced stop can lose changes since the last autosave.
  • Deferred: taking over a stale control lock is not atomic, so two launchers started at once on one slot could both take it. The README asks for one controller per slot.
  • No mod change: mod/DEV.md documents why DevBridge does not set address reuse (on Windows it would let two games bind the same port). setReuseAddr(!isWindows) would remove the 35 s wait on macOS and Linux, if you want it.

🤖 Generated with Claude Code

https://claude.ai/code/session_017nnFLfHG1rVtWmS9c6mAQP

aurablacklight and others added 3 commits October 3, 2026 23:29
The packet worker could not run the live checks in its sandbox. Add what the
coordinator observed when running them outside it, including what failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UTWx6KTPBs4koTLg8uZMHX
…r review

- A client whose DevBridge cannot bind its port is detected from its log,
  and relaunched once after the TIME_WAIT window, bounded by --timeout.
- Server and client telemetry is read from logs/debug.log, so debug-level
  agentcraft.mp events are visible to harness tests.
- Gradle preparation is bounded by --timeout, the launch export ensures
  client assets, and the process identity no longer depends on the
  caller's time zone or locale.
- Fall back to the checkout's .gradle-home and check for Java 25, like the
  other launchers. Document and pin the --json summary shape.
- Windows: portable test fixture paths, -ExecutionPolicy Bypass, and the
  wrapper started through Start-Bg. None of this was run on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnFLfHG1rVtWmS9c6mAQP
@aurablacklight
aurablacklight marked this pull request as ready for review October 4, 2026 05:22
main gained the CI workflow (.github/workflows/ci.yml). Nothing else changes
on this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnFLfHG1rVtWmS9c6mAQP

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

down and status no longer read the launch environment (heap sizes, the Gradle
command), so a stale or invalid value cannot block cleanup.

The log cursor carries a generation, a digest of the log's first lines, next
to its offset, so a log that a later up replaced is read from its start even
after it has grown past the old offset.

The launch-metadata build carries its run marker on the Gradle command line,
so down can find and stop it when the launcher died before the PID was saved.

Each has a test that failed before the fix. 64 tools tests pass. Not run: the
harness itself, and nothing on Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLkzPTC3pm16WCmLm9dqyp

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Process recovery and shutdown still have safety issues, and readiness can bypass cancellation.

Review effort: Balanced
Findings: 2 High severity · 2 Medium severity

Open (4)

Comment thread tools/lib/mp/processes.mjs Outdated
const startsWithExe = executable => executable && (p.command.startsWith(`${executable} `) || p.command.startsWith(`"${executable}" `));
// gradlew execs Java, so a build has no stable executable prefix: require its init script too.
const direct = startsWithExe(entry.executable) || (entry.kind === 'foreman' && p.command.trim() === entry.marker) ||
(entry.kind === 'build' && p.command.includes(exportScript(root)));
Comment thread tools/lib/mp/processes.mjs Outdated
const until = Math.min(deadline, now() + timeoutMs);
while (now() < until && members.some(member => sameProcess(member, root, stamp))) await delay(Math.min(100, Math.max(0, deadline - now())));
for (const member of members.filter(member => sameProcess(member, root, stamp))) {
if (platform === 'win32') shell(root, `Stop-OwnTree ${member.pid} ${psQuote(member.startTime)} | Out-Null`);
Comment thread tools/lib/mp/index.mjs
}));
scan();
if (now() >= deadline) break;
if (replies.every(reply => reply.status === 'fulfilled' && reply.value)) return states;
Comment thread tools/lib/mp/processes.mjs Outdated
} else {
// Direct POSIX launches own a detached group. Signal its tools too, while the leader's
// PID/start identity still matches, then retain member identities across leader exit.
try { kill(record.groupPid === record.pid ? -record.pid : record.pid, 'SIGTERM'); }
…tops (MP-H)

withLock: two controllers that both judged a lock stale could both take it,
because the stale lock was deleted and recreated. A takeover now claims the
next generation with an exclusive mkdir and stands down unless its own owner
record is the live one.

Windows: the dedicated server was killed with no wait, so the reused world
was never saved. up now seeds a loopback RCON port with a random password for
the run, down sends stop through it and waits for the exit before any kill.
Whenever a server is killed, down says that it had no final save.

A .bat or .cmd Gradle override on Windows runs through PowerShell (Node
refuses to spawn one without a shell). Ports are checked again right before
the processes start. The game clients are launched with
AGENTCRAFT_DEV_REMOTE=1, the opt-in the remote dev commands now need; the
server and the Foremen are not.

From the pull-request review. 69 tools tests pass. Nothing here was run on
Windows or against a real server: the RCON stop rests on the 26.3 sources and
a test listener.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLkzPTC3pm16WCmLm9dqyp
aurablacklight and others added 4 commits October 4, 2026 11:07
… (MP-H)

On POSIX the start time from ps has whole seconds, so a PID reused within
that second passed the identity check and could be signalled. Recovery and
stop now also require that the process's command line still carries the
entry's launch marker (a recorded child: that it is still in its leader's
process group), read right before each signal. Windows is unchanged: its start
times have 100 ns.

From the pull-request review. 70 tools tests pass, and real child processes
were recovered and stopped on Linux. Not run on macOS: down there depends on
ps showing the marker for every harness process, which the live macOS run
should confirm before this merges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLkzPTC3pm16WCmLm9dqyp
From the second automated review of the pull request, each claim checked against the code first:

- An unfinished build is recovered only from a process shaped like the Gradle launch itself (the default launcher, or Java running Gradle with the harness's arguments). A command that merely quotes that command line, such as a grep for it, carried the marker and the init script path too and could be signalled.
- On Windows, stopping an unfinished build kills its process tree except a Gradle daemon the build started (opt-in switch on Stop-OwnTree; other callers unchanged). Not yet run on Windows.
- waitForClients checks for an interruption or a lost process again after the dev.state requests, before it accepts readiness.
- On POSIX a client that acknowledged dev.quit gets up to five seconds to exit by itself before SIGTERM; one that did not answer is signalled at once.

Four new tests, each failing before its fix; tools tests 74 of 74.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLkzPTC3pm16WCmLm9dqyp
… (MP-H)

Two up/down cycles with two clients on Windows: the server saved over RCON and exited by itself, nothing was left running, the shared Gradle daemon stayed, and the second cycle reused the world and its plots. The worknote lists what the run reached and what is still unit-covered only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLkzPTC3pm16WCmLm9dqyp
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLkzPTC3pm16WCmLm9dqyp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants