Skip to content

A2 layer 2: one relay for every server operation - #81

Merged
Pelotrio merged 4 commits into
1.21.1from
claude/relay
Sep 28, 2026
Merged

Pelotrio merged 4 commits into
1.21.1from
claude/relay

Conversation

@Pelotrio

Copy link
Copy Markdown
Member

A2 layer 2 (see docs/MOD_SIDES.md): one generic path from Companion to the server.

What changes

  • One relay. Companion wraps any server-bound message in TO_SERVER (correlation + game session). The game client checks only the envelope and forwards it in ToServer NeoForge chunks without reading it. The server decodes it with the same protocol classes and dispatches through ServerOperations. Answers travel back as ToCompanion chunks → FROM_SERVER, which Companion unwraps onto its normal message bus.
  • RELAY_FAILED names an undeliverable request: world left since the target was inspected, no world joined, or the server lacks TotalDebug.
  • Server manifest handshake moves to Companion. It sends MANIFEST_REQUEST when PLAYING reports singleplayer, or multiplayer with TotalDebug. The client's manifest cache and replay are gone. A new manifest baseline (offset 0) ends the runs on the server.
  • Size limits: chunks stay below Minecraft's payload limits (30,000 bytes to the server, 1,000,000 to the client), so server script bytecode is no longer capped at one payload. The server holds at most 2 MiB per player across 4 transfers.
  • Removed: the Forwarded* payloads, RunServerScriptPayload, StopServerScriptPayload, ServerSourceRequestPayload, ServerManifestPayload, ServerScriptTransport and their tests. ClientScriptService runs client scripts only.

Notes

  • Companion protocol 32, network protocol 7. Set the current world's game rules, live or in level.dat #75's rework takes the next protocol version.
  • About 1,100 lines added and 1,500 removed. That is over the usual stack size, but the protocol switch is atomic: a half-way state would have two server paths.
  • Not in scope: an SCNet codec API that would let the relay skip the body copy. That is a candidate for F2.

Tests

:protocol:test :mod:test :companion:test pass. They cover chunk splitting and reassembly, dropped or oversized transfers, envelope round trips, the refusal of unknown ids and trailing bytes, run stop and relay-failure routing by side, and server-session end.

🤖 Generated with Claude Code

Companion wraps a message for the server in TO_SERVER; the game client checks
only the envelope (the game session it is valid in, whether the server has
TotalDebug) and forwards it in NeoForge chunks unread. The server decodes it
with the same protocol classes and hands it to ServerOperations; answers come
back as FROM_SERVER. RELAY_FAILED names an undeliverable request.

Companion now asks for the server's class manifest with MANIFEST_REQUEST once
the game plays a world or a server with TotalDebug, so the client no longer
caches and replays it. A new manifest baseline ends the runs on the server.

Replaces the per-feature forwarded payloads, the server-source request payload
and the client's manifest cache. Protocol 32, network protocol 7.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-28T19:49:50.160234Z 72aaf08 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a2835d84a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
runner.runScript(payload.scriptId(), payload.bytecode(), payload.environment(), subject,
payload.subjectExpectedId());
runner.runScript(payload.scriptId(), payload.bytecode(), environment, subject, payload.subjectExpectedId());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve server execution IDs across Companion restarts

When Companion is restarted while a server-side script remains active, the new process restarts its run counter at 1, while the server's ScriptRunner survives because it is scoped to the still-connected player. Passing payload.scriptId() directly therefore reuses the old execution ID: the new run can be rejected as a duplicate, and subsequent results from the old run can be attributed to the new run. Preserve a game-lifetime execution ID translation for server runs, as the removed client transport did.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 23d67d1. Both findings had one cause: the manifest session bounds the server's runs, and only Companion's side acted on that. Now the server closes the previous runner when a new manifest session starts, and drops that runner's late results, so a restarted Companion's ids cannot collide with it. Companion also counts a result only from the side the run went to, so a stale server result cannot end a client run with the same id.

this.manifestRequestedFor = null;
return;
}
if (target.equals(this.manifestRequestedFor)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the manifest target when the game server disconnects

When the player disconnects and rejoins the same address between the ChangePublisher's one-second samples, the intermediate menu state is never published and the next PlayingPayload.Multiplayer equals the previously published value. ClientRelay.serverLeft() only sends an unavailable manifest, so manifestRequestedFor still names that address and this guard suppresses the new manifest request; the server removed the old manifest session on logout, leaving server-side scripts unavailable until another location change or Companion reconnect. Clear this key on the server-left notification or force the playing state to republish.

AGENTS.md reference: companion/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 23d67d1 (same cause as the P1). The client now publishes Menu at once when it leaves a server, instead of waiting for the next one-second check. A rejoin of the same address is then a change, and Companion asks the new server session for its manifest.

A restarted Companion counts its run ids from 1 again, while the server's runner
of the previous session could still run and report under the same ids. The
server now closes the previous runner when it starts a new manifest session and
drops its late results, and Companion counts a result only from the side the run
went to.

Leaving a server is published to Companion at once, so a rejoin of the same
server between two checks still asks the new server session for its manifest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Took 2 minutes

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 23d67d1a7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +256 to +257
sendResult(server, sessionPlayer, scriptId, ExecutionResult.fromStatus(ExecutionStatus.COMPILATION_FAILED, message),
() -> true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard queued failures by the server session

When Companion restarts after a server request is rejected but before the single-threaded result encoder drains, this unconditional predicate lets the old terminal failure be delivered after sendManifest has replaced the session. Because the new Companion reuses run IDs from 1, that failure can then terminate an unrelated new run with the same ID. The new runner-session guard is fresh evidence that the prior ID-collision fix remains incomplete: it protects runner-produced results, but not asynchronously queued policy, session, environment, or runner-setup failures. Bind these failures to the manifest session that received the request, or retain game-lifetime execution IDs.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3dd835e. This had the same cause as the Companion-close finding: a server run was not bound to the Companion session that asked for it. Now every run belongs to the manifest session that took its request, and everything sent about it is guarded by that session still being current. That covers the policy, session, environment and runner-setup failures queued before the run started, as well as the runner's own results.

Comment on lines 185 to 189
public synchronized void close() {
this.forwardedResults.clear();
var detachedRuns = new ArrayList<>(this.activeRuns.values());
// Detach observers before cancellation can synchronously deliver a result.
this.activeRuns.clear();
for (Run run : detachedRuns) {
if (run.side() != Side.SERVER) {
continue;
}
try {
if (this.serverTransport.availability().available()) {
this.serverTransport.stop(new StopServerScriptPayload(run.executionId()));
}
} catch (RuntimeException exception) {
TotalDebug.LOGGER.debug("Unable to stop server script {} while closing", run.executionId(), exception);
}
}
if (this.runner != null) {
this.runner.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Notify the server when the Companion session closes

When Companion disconnects or exits while a server-side script is active but the player remains in the game, the session-closed handler reaches this method, which now only closes the client runner. The removed implementation explicitly stopped tracked server executions, while the new relay sends no equivalent session-close signal, so the server's RunnerSession can keep the script running until the player logs out, the server stops, or another Companion connects and requests a manifest. Add server-session cleanup through the relay instead of abandoning active server runs.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3dd835e. When the Companion session closes, the client now sends COMPANION_LEFT through the relay; it is the one relayed message the client writes itself. The server ends that player's manifest session and closes its runner. The same method, ServerScriptService.endSession, runs on logout.

Comment on lines +315 to +316
public void sendFromServer(RelayedMessage message) {
send(new FromServerMessage(message));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reserve frame space for the server relay envelope

When a server script produces an execution result near ExecutionResultCodec.MAX_WIRE_BYTES, the result is already sized to consume the SCNet frame budget for a direct ExecutionResultMessage. Wrapping that encoded body in RelayedMessage and then FromServerMessage adds correlation, session, message-ID, and length fields, so the game-to-Companion frame exceeds DEFAULT_MAX_FRAME_SIZE and the terminal result cannot be delivered. Budget server results for the extra envelope bytes before forwarding them, otherwise sufficiently large logs or values leave the run without its result.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3dd835e. RelayedMessage.MAX_BODY_BYTES is now the Companion frame size minus the worst-case envelope. ExecutionResultCodec.MAX_WIRE_BYTES is budgeted from that, so a server result fits both sent directly and relayed. A test writes the largest body with the longest session and checks that it fits one frame.

Comment on lines +21 to +23
/** What the client takes from the server, whose class manifest is its largest message. */
public static RelayAssembler toClient() {
return new RelayAssembler(RelayedMessage.MAX_BODY_BYTES, 8);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Cap aggregate bytes across in-flight transfers

When the client is connected to a hostile or malfunctioning TotalDebug server, that server can start eight transfer UUIDs and send almost 33 MiB into each without ever sending their final chunks. The size check is per Transfer, while incomplete entries have no timeout and remain until eviction or disconnect, so this assembler can retain roughly 264 MiB of payload data, potentially substantially more because each ByteArrayOutputStream over-allocates; the server-facing assembler similarly permits four separate 2 MiB allocations per player. Enforce a shared byte budget across all in-flight transfers rather than multiplying the nominal cap by maxTransfers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3dd835e. All transfers in progress on a connection now share one byte budget: 2 MiB per player on the server, and one relay body on the client. Chunks are held as they arrived and copied once when the message completes, so there is no ByteArrayOutputStream over-allocation.

…elay

A run now belongs to the manifest session that took its request. Everything
the server sends about it, including failures queued before the run started,
is dropped once that session ended. The session ends when Companion asks for a
new one, when the player leaves, or when Companion's connection closes: the
client then sends COMPANION_LEFT, the one relayed message it writes itself.

A relayed message and its envelope fit one Companion frame, and execution
results are budgeted for it. The transfers in progress on one connection share
one byte budget and are held as the chunks that arrived.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3dd835e660

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

public ServerOperations(ServerScriptService scripts) {
Objects.requireNonNull(scripts, "scripts");
this.operations = Map.of(
ManifestRequestMessage.class, (player, message) -> scripts.sendManifest(player),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bound repeated manifest requests

A modified client can send this empty request repeatedly, and every message starts a fresh session and calls sendManifest; while the shared manifest is building this accumulates unbounded completion callbacks, and after it is ready each request can amplify into a baseline response of up to 32 MiB. This bypasses the relay's byte-budget protections and lets any connected player consume server memory and outbound bandwidth, so reject, coalesce, or rate-limit requests while that player's manifest session is already current.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 72aaf08. The manifest serves only server scripts, so the server now applies the script policy (enableScripts, operator-only) before it starts a session. A refused player gets the reason as an unavailable manifest, not a session, so their repeated requests cost a few bytes each. A player the policy admits can already run arbitrary code on the server, so rate-limiting their requests would not bound anything. As a side effect, Companion now shows the policy reason before you compile, instead of refusing the run afterwards.

The manifest serves only server scripts. A request is a few bytes and its
answer up to 32 MiB, so the server now checks the script policy first and
answers a refused player with the reason instead of a session. A player the
policy admits can already run code on the server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Pelotrio
Pelotrio merged commit 04fca9c into 1.21.1 Sep 28, 2026
2 of 3 checks passed
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.

1 participant