Files
horizon/tools/test_management_protocol_parity.py
CocofireHDandopencode 92549691c2 Management harness: run on Windows, name a server up after stop
The regression behind #9088 was #8245: a stop that doesn't come from the
supervisor's stdin or the control API left the supervisor in its stopped
phase, so `horizon-server --dir … run` outlived the management
`server/stop` and the harness's `wait(timeout=180)` raised TimeoutExpired.
#9077 fixed that (`Shared::shut_down`), on the base of this branch, and the
harness passes here again.

What the harness still left open, and what this fixes:

- It could not run on Windows at all. It wrote the absolute keystore path
  into vanilla's server.properties with single backslashes, which Java's
  properties parser reads as escapes ("C:UserscocoAppData…"), so vanilla
  stopped before the first request. Values are now escaped as vanilla writes
  them, and the harness runs here.
- A server that stays up after `server/stop` was reported as a bare
  TimeoutExpired. `wait_for_exit` waits for the process to end, which is the
  condition the issue was about (vanilla's `ServerStateService.stop` submits
  `MinecraftServer.halt(false)`, so `runServer` returns and `main` ends the
  process), and its failure names the side and the last lines it logged.
  The seconds each server took are kept in report.json: vanilla 3.4 s and
  Horizon 0.5 s here, against the 180 s budget.

Fixes #9088

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-04 21:38:07 +01:00

945 lines
68 KiB
Python

#!/usr/bin/env python3
"""Vanilla's server management protocol (JSON-RPC over a WebSocket), Horizon's against vanilla's.
The pinned vanilla server (`net.minecraft.server.Main nogui`) and Horizon's (`horizon-server run`, which
serves the protocol beside the game it supervises) start in folders with the same lists (ops.json,
whitelist.json, banned-players.json, banned-ips.json) and the same `management-server-*` settings (a fixed
secret, one allowed origin, and TLS, vanilla's default, with a PKCS12 keystore `keytool` makes). Each is then
sent the same things over TLS, and every answer is compared exactly:
- HTTP requests without the secret, with a wrong one, and with it in the WebSocket subprotocol from an
origin that isn't allowed: the status and the JSON body;
- the handshake with the secret in the subprotocol from the allowed origin: the subprotocol answered;
- over a WebSocket with the bearer token, a sequence of messages: malformed JSON, a bare number, unknown and
unparsable method names, a bad request id, wrong parameters, a notification (no answer), a batch, an
answer to nothing, `rpc.discover` (its document must also equal `reports/json-rpc-api-schema.json` from
vanilla's data generator), `server/status`, the four lists, `players`, kicking nobody, `server/save` and
`server/system_message` with no one online, the four lists changed (`add`, `remove`, `set`,
`clear`; with no profile service, players named resolve to offline players on both), the live server
message, difficulty, default and forced game mode read and changed (including saved properties), operator
permission level read and changed (including saved properties and restart), autosave read and changed
(including its reset after restart), spawn-protection radius read and changed (including invalid input,
saved properties and restart), accepts-transfers and allow-flight read and changed (including invalid input,
saved properties and restart), hidden online players read and changed (including invalid input, saved properties and restart),
status replies read and changed (including invalid input, saved properties and restart), allowlist use and
enforcement read and changed
(including saved properties and restart), max players read and changed (including saved
properties and restart), player idle timeout read and changed (including disabling it, invalid input,
saved properties and restart), the live pause-when-empty timer read and changed (including its saved
property and restart), the live view distance read and changed (including invalid input, saved
properties and restart), the live simulation distance read and changed (including invalid input, saved
properties and restart), the live entity broadcast range read and changed (including invalid input,
saved properties and restart), and `server/stop`, after which each process ends as vanilla's does (how long
it took is recorded);
- the TLS version each server agrees on.
A parse error's `data` is Gson's message in vanilla and serde's in Horizon: left out. The notifications the
servers send meanwhile (`server/stopping`...) are set aside, and their names are kept in each side's report
(`notifications`); Horizon sends `server/started` and `server/stopping` so far, not yet the rest.
"""
import base64
import json
import os
from pathlib import Path
import shutil
import socket
import ssl
import struct
import subprocess
import sys
import tempfile
import time
try:
from . import lab, oracle_harness as oh, test_rust_parity as base
from .server_bench import DONE, Server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import lab
import oracle_harness as oh
import test_rust_parity as base
from server_bench import DONE, Server
NAME = 'management-protocol-parity'
EXE = '.exe' if os.name == 'nt' else ''
SECRET = 'abcdefghijABCDEFGHIJ0123456789abcdefghij'
ORIGIN = 'http://localhost:3000'
KIM = {'uuid': '93e029a5-04a0-349d-90b2-f7e145b2644f', 'name': 'Kim0'}
LOU = {'uuid': '94d84bd8-1dc1-3f9b-88b6-c0c9dc9aa57d', 'name': 'Lou0'}
LISTS = {
'ops.json': [dict(KIM, level=3, bypassesPlayerLimit=True)],
'whitelist.json': [LOU],
'banned-players.json': [dict(LOU, created='2026-10-31 14:00:00 +0000', source='Server',
expires='2026-11-30 12:30:00 +0000', reason='griefing')],
'banned-ips.json': [{'ip': '203.0.113.7', 'created': '2026-10-31 14:00:00 +0000', 'source': 'Server',
'expires': 'forever', 'reason': 'Banned by an operator.'}],
}
KEYSTORE_PASSWORD = 'harness-keystore-password'
# Nothing listens there: neither server finds a profile service, so names resolve to offline players on both,
# whatever the network answers.
NO_SERVICES = 'http://127.0.0.1:9'
# What both servers are sent over the socket, in order; None marks what must get no answer.
MESSAGES = [
'{nope',
'5',
'{"jsonrpc":"2.0","id":1,"method":"minecraft:nope"}',
'{"jsonrpc":"2.0","id":2,"method":"Bad Name"}',
'{"jsonrpc":"2.0","id":{"a":1},"method":"minecraft:players"}',
'{"jsonrpc":"2.0","id":3,"method":"rpc.discover","params":{}}',
'{"jsonrpc":"2.0","id":4,"method":"minecraft:players","params":[1]}',
'{"jsonrpc":"2.0","id":5,"method":"minecraft:server/save"}',
'{"jsonrpc":"2.0","id":6,"method":"minecraft:server/save","params":{"other":true}}',
'{"jsonrpc":"2.0","id":7,"method":"minecraft:server/save","params":[true,false]}',
'{"jsonrpc":"2.0","method":"minecraft:server/status"}',
'{"jsonrpc":"2.0","id":8,"result":true}',
'{"jsonrpc":"2.0","id":9}',
'[{"jsonrpc":"2.0","id":10,"method":"minecraft:allowlist"},{"jsonrpc":"2.0","method":"minecraft:bans"},'
'{"jsonrpc":"2.0","id":"eleven","method":"minecraft:operators","params":[]}]',
'[]',
'{"jsonrpc":"2.0","id":12,"method":"rpc.discover"}',
'{"jsonrpc":"2.0","id":13,"method":"server/status"}',
'{"jsonrpc":"2.0","id":null,"method":"minecraft:allowlist"}',
'{"jsonrpc":"2.0","id":14,"method":"minecraft:bans"}',
'{"jsonrpc":"2.0","id":15,"method":"minecraft:ip_bans"}',
'{"jsonrpc":"2.0","id":16,"method":"minecraft:operators"}',
'{"jsonrpc":"2.0","id":17,"method":"minecraft:players"}',
'{"jsonrpc":"2.0","id":18,"method":"minecraft:players/kick","params":{"kick":[{"player":{"name":"Nobody"}}]}}',
'{"jsonrpc":"2.0","id":19,"method":"minecraft:server/save","params":[false]}',
'{"jsonrpc":"2.0","id":20,"method":"minecraft:server/system_message",'
'"params":[{"message":{"literal":"Hello"},"overlay":false}]}',
'{"jsonrpc":"2.0","id":21,"method":"minecraft:server/system_message",'
'"params":[{"message":{"translatable":"chat.type.announcement","translatableParams":["a","b"]},'
'"overlay":true,"receivingPlayers":[]}]}',
'{"jsonrpc":"2.0","id":22,"method":"minecraft:server/system_message","params":[{"message":{},"overlay":false}]}',
# The lists changed: players found by name (offline players here) or by an id the user cache knows.
'{"jsonrpc":"2.0","id":23,"method":"minecraft:allowlist/add","params":[[{"name":"Kim0"}]]}',
'{"jsonrpc":"2.0","id":24,"method":"minecraft:allowlist/remove","params":{"players":[{"name":"Lou0"}]}}',
'{"jsonrpc":"2.0","id":25,"method":"minecraft:allowlist/set","params":[[{"name":"Ann0"},'
'{"id":"93e029a5-04a0-349d-90b2-f7e145b2644f"}]]}',
'{"jsonrpc":"2.0","id":26,"method":"minecraft:allowlist/clear"}',
'{"jsonrpc":"2.0","id":27,"method":"minecraft:bans/add","params":[[{"player":{"name":"Kim0"},"reason":"spam",'
'"expires":"2027-01-01T00:00:00Z"},{"player":{"name":"Ann0"}}]]}',
'{"jsonrpc":"2.0","id":28,"method":"minecraft:bans/remove","params":[[{"name":"Lou0"}]]}',
'{"jsonrpc":"2.0","id":29,"method":"minecraft:bans/set","params":[[{"player":{"name":"Kim0"},"reason":"spam",'
'"expires":"2027-01-01T00:00:00Z"},{"player":{"name":"Ann0"},"source":"Moderator"}]]}',
'{"jsonrpc":"2.0","id":30,"method":"minecraft:bans"}',
'{"jsonrpc":"2.0","id":31,"method":"minecraft:bans/clear"}',
'{"jsonrpc":"2.0","id":32,"method":"minecraft:ip_bans/add","params":[[{"ip":"198.51.100.4","reason":"proxy",'
'"expires":"2027-01-01T00:00:00Z"},{"ip":"not an address"},{"player":{"name":"Nobody"}}]]}',
'{"jsonrpc":"2.0","id":33,"method":"minecraft:ip_bans/remove","params":[["203.0.113.7"]]}',
'{"jsonrpc":"2.0","id":34,"method":"minecraft:ip_bans/set","params":[[{"ip":"198.51.100.4","reason":"proxy",'
'"expires":"2027-01-01T00:00:00Z"},{"ip":"2001:db8::1","source":"Bot"},{"ip":"bad"}]]}',
'{"jsonrpc":"2.0","id":35,"method":"minecraft:ip_bans/clear"}',
'{"jsonrpc":"2.0","id":36,"method":"minecraft:operators/add","params":[[{"player":{"name":"Lou0"},'
'"permissionLevel":2},{"player":{"name":"Ann0"}}]]}',
'{"jsonrpc":"2.0","id":37,"method":"minecraft:operators/set","params":[[{"player":{"name":"Lou0"},'
'"permissionLevel":2,"bypassesPlayerLimit":false},{"player":{"name":"Ann0"},"bypassesPlayerLimit":true},'
'{"player":{"id":"93e029a5-04a0-349d-90b2-f7e145b2644f"}}]]}',
'{"jsonrpc":"2.0","id":38,"method":"minecraft:operators/remove","params":[[{"name":"Lou0"}]]}',
'{"jsonrpc":"2.0","id":39,"method":"minecraft:operators/clear"}',
'{"jsonrpc":"2.0","id":40,"method":"minecraft:serversettings/motd"}',
'{"jsonrpc":"2.0","id":41,"method":"minecraft:serversettings/motd/set","params":["A test server"]}',
'{"jsonrpc":"2.0","id":42,"method":"minecraft:serversettings/motd"}',
'{"jsonrpc":"2.0","id":43,"method":"minecraft:serversettings/motd/set","params":[""]}',
'{"jsonrpc":"2.0","id":44,"method":"minecraft:serversettings/motd"}',
'{"jsonrpc":"2.0","id":45,"method":"minecraft:serversettings/difficulty"}',
'{"jsonrpc":"2.0","id":46,"method":"minecraft:serversettings/difficulty/set","params":["hard"]}',
'{"jsonrpc":"2.0","id":47,"method":"minecraft:serversettings/difficulty"}',
'{"jsonrpc":"2.0","id":48,"method":"minecraft:serversettings/difficulty/set","params":["peaceful"]}',
'{"jsonrpc":"2.0","id":49,"method":"minecraft:serversettings/difficulty"}',
'{"jsonrpc":"2.0","id":50,"method":"minecraft:serversettings/game_mode"}',
'{"jsonrpc":"2.0","id":51,"method":"minecraft:serversettings/game_mode/set","params":["creative"]}',
'{"jsonrpc":"2.0","id":52,"method":"minecraft:serversettings/game_mode"}',
'{"jsonrpc":"2.0","id":53,"method":"minecraft:serversettings/game_mode/set","params":["spectator"]}',
'{"jsonrpc":"2.0","id":54,"method":"minecraft:serversettings/game_mode"}',
'{"jsonrpc":"2.0","id":180,"method":"minecraft:serversettings/entity_broadcast_range"}',
'{"jsonrpc":"2.0","id":181,"method":"minecraft:serversettings/entity_broadcast_range/set","params":[60]}',
'{"jsonrpc":"2.0","id":182,"method":"minecraft:serversettings/entity_broadcast_range"}',
'{"jsonrpc":"2.0","id":183,"method":"minecraft:serversettings/entity_broadcast_range/set","params":[1]}',
'{"jsonrpc":"2.0","id":184,"method":"minecraft:serversettings/entity_broadcast_range"}',
'{"jsonrpc":"2.0","id":185,"method":"minecraft:serversettings/entity_broadcast_range/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":186,"method":"minecraft:serversettings/entity_broadcast_range"}',
'{"jsonrpc":"2.0","id":187,"method":"minecraft:serversettings/entity_broadcast_range/set","params":[80]}',
'{"jsonrpc":"2.0","id":188,"method":"minecraft:serversettings/entity_broadcast_range"}',
'{"jsonrpc":"2.0","id":170,"method":"minecraft:serversettings/simulation_distance"}',
'{"jsonrpc":"2.0","id":171,"method":"minecraft:serversettings/simulation_distance/set","params":[6]}',
'{"jsonrpc":"2.0","id":172,"method":"minecraft:serversettings/simulation_distance"}',
'{"jsonrpc":"2.0","id":173,"method":"minecraft:serversettings/simulation_distance/set","params":[1]}',
'{"jsonrpc":"2.0","id":174,"method":"minecraft:serversettings/simulation_distance"}',
'{"jsonrpc":"2.0","id":175,"method":"minecraft:serversettings/simulation_distance/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":176,"method":"minecraft:serversettings/simulation_distance"}',
'{"jsonrpc":"2.0","id":177,"method":"minecraft:serversettings/simulation_distance/set","params":[8]}',
'{"jsonrpc":"2.0","id":178,"method":"minecraft:serversettings/simulation_distance"}',
'{"jsonrpc":"2.0","id":160,"method":"minecraft:serversettings/view_distance"}',
'{"jsonrpc":"2.0","id":161,"method":"minecraft:serversettings/view_distance/set","params":[6]}',
'{"jsonrpc":"2.0","id":162,"method":"minecraft:serversettings/view_distance"}',
'{"jsonrpc":"2.0","id":163,"method":"minecraft:serversettings/view_distance/set","params":[1]}',
'{"jsonrpc":"2.0","id":164,"method":"minecraft:serversettings/view_distance"}',
'{"jsonrpc":"2.0","id":165,"method":"minecraft:serversettings/view_distance/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":166,"method":"minecraft:serversettings/view_distance"}',
'{"jsonrpc":"2.0","id":167,"method":"minecraft:serversettings/view_distance/set","params":[8]}',
'{"jsonrpc":"2.0","id":168,"method":"minecraft:serversettings/view_distance"}',
'{"jsonrpc":"2.0","id":220,"method":"minecraft:serversettings/operator_user_permission_level"}',
'{"jsonrpc":"2.0","id":221,"method":"minecraft:serversettings/operator_user_permission_level/set","params":[2]}',
'{"jsonrpc":"2.0","id":222,"method":"minecraft:serversettings/operator_user_permission_level"}',
'{"jsonrpc":"2.0","id":223,"method":"minecraft:serversettings/operator_user_permission_level/set","params":[0]}',
'{"jsonrpc":"2.0","id":224,"method":"minecraft:serversettings/operator_user_permission_level"}',
'{"jsonrpc":"2.0","id":225,"method":"minecraft:serversettings/operator_user_permission_level/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":226,"method":"minecraft:serversettings/operator_user_permission_level"}',
'{"jsonrpc":"2.0","id":227,"method":"minecraft:serversettings/operator_user_permission_level/set","params":[2]}',
'{"jsonrpc":"2.0","id":228,"method":"minecraft:serversettings/operator_user_permission_level"}',
'{"jsonrpc":"2.0","id":55,"method":"minecraft:serversettings/force_game_mode"}',
'{"jsonrpc":"2.0","id":56,"method":"minecraft:serversettings/force_game_mode/set","params":[true]}',
'{"jsonrpc":"2.0","id":57,"method":"minecraft:serversettings/force_game_mode"}',
'{"jsonrpc":"2.0","id":58,"method":"minecraft:serversettings/force_game_mode/set","params":[false]}',
'{"jsonrpc":"2.0","id":59,"method":"minecraft:serversettings/force_game_mode"}',
'{"jsonrpc":"2.0","id":60,"method":"minecraft:serversettings/force_game_mode/set","params":[true]}',
'{"jsonrpc":"2.0","id":61,"method":"minecraft:serversettings/force_game_mode"}',
'{"jsonrpc":"2.0","id":62,"method":"minecraft:serversettings/force_game_mode/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":63,"method":"minecraft:serversettings/force_game_mode"}',
'{"jsonrpc":"2.0","id":150,"method":"minecraft:serversettings/spawn_protection_radius"}',
'{"jsonrpc":"2.0","id":151,"method":"minecraft:serversettings/spawn_protection_radius/set","params":[4]}',
'{"jsonrpc":"2.0","id":152,"method":"minecraft:serversettings/spawn_protection_radius"}',
'{"jsonrpc":"2.0","id":153,"method":"minecraft:serversettings/spawn_protection_radius/set","params":[-1]}',
'{"jsonrpc":"2.0","id":154,"method":"minecraft:serversettings/spawn_protection_radius"}',
'{"jsonrpc":"2.0","id":155,"method":"minecraft:serversettings/spawn_protection_radius/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":156,"method":"minecraft:serversettings/spawn_protection_radius"}',
'{"jsonrpc":"2.0","id":157,"method":"minecraft:serversettings/spawn_protection_radius/set","params":[8]}',
'{"jsonrpc":"2.0","id":158,"method":"minecraft:serversettings/spawn_protection_radius"}',
'{"jsonrpc":"2.0","id":140,"method":"minecraft:serversettings/allow_flight"}',
'{"jsonrpc":"2.0","id":141,"method":"minecraft:serversettings/allow_flight/set","params":[true]}',
'{"jsonrpc":"2.0","id":142,"method":"minecraft:serversettings/allow_flight"}',
'{"jsonrpc":"2.0","id":143,"method":"minecraft:serversettings/allow_flight/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":144,"method":"minecraft:serversettings/allow_flight"}',
'{"jsonrpc":"2.0","id":145,"method":"minecraft:serversettings/allow_flight/set","params":[false]}',
'{"jsonrpc":"2.0","id":146,"method":"minecraft:serversettings/allow_flight"}',
'{"jsonrpc":"2.0","id":147,"method":"minecraft:serversettings/allow_flight/set","params":[true]}',
'{"jsonrpc":"2.0","id":148,"method":"minecraft:serversettings/allow_flight"}',
'{"jsonrpc":"2.0","id":300,"method":"minecraft:serversettings/accept_transfers"}',
'{"jsonrpc":"2.0","id":301,"method":"minecraft:serversettings/accept_transfers/set","params":[true]}',
'{"jsonrpc":"2.0","id":302,"method":"minecraft:serversettings/accept_transfers"}',
'{"jsonrpc":"2.0","id":303,"method":"minecraft:serversettings/accept_transfers/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":304,"method":"minecraft:serversettings/accept_transfers"}',
'{"jsonrpc":"2.0","id":305,"method":"minecraft:serversettings/accept_transfers/set","params":[false]}',
'{"jsonrpc":"2.0","id":306,"method":"minecraft:serversettings/accept_transfers"}',
'{"jsonrpc":"2.0","id":307,"method":"minecraft:serversettings/accept_transfers/set","params":[true]}',
'{"jsonrpc":"2.0","id":190,"method":"minecraft:serversettings/hide_online_players"}',
'{"jsonrpc":"2.0","id":191,"method":"minecraft:serversettings/hide_online_players/set","params":[true]}',
'{"jsonrpc":"2.0","id":192,"method":"minecraft:serversettings/hide_online_players"}',
'{"jsonrpc":"2.0","id":193,"method":"minecraft:serversettings/hide_online_players/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":194,"method":"minecraft:serversettings/hide_online_players"}',
'{"jsonrpc":"2.0","id":195,"method":"minecraft:serversettings/hide_online_players/set","params":[false]}',
'{"jsonrpc":"2.0","id":196,"method":"minecraft:serversettings/hide_online_players"}',
'{"jsonrpc":"2.0","id":197,"method":"minecraft:serversettings/hide_online_players/set","params":[true]}',
'{"jsonrpc":"2.0","id":198,"method":"minecraft:serversettings/hide_online_players"}',
'{"jsonrpc":"2.0","id":210,"method":"minecraft:serversettings/status_replies"}',
'{"jsonrpc":"2.0","id":211,"method":"minecraft:serversettings/status_replies/set","params":[false]}',
'{"jsonrpc":"2.0","id":212,"method":"minecraft:serversettings/status_replies"}',
'{"jsonrpc":"2.0","id":213,"method":"minecraft:serversettings/status_replies/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":214,"method":"minecraft:serversettings/status_replies"}',
'{"jsonrpc":"2.0","id":215,"method":"minecraft:serversettings/status_replies/set","params":[true]}',
'{"jsonrpc":"2.0","id":216,"method":"minecraft:serversettings/status_replies"}',
'{"jsonrpc":"2.0","id":217,"method":"minecraft:serversettings/status_replies/set","params":[false]}',
'{"jsonrpc":"2.0","id":218,"method":"minecraft:serversettings/status_replies"}',
'{"jsonrpc":"2.0","id":65,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":66,"method":"minecraft:serversettings/autosave/set","params":[false]}',
'{"jsonrpc":"2.0","id":67,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":68,"method":"minecraft:serversettings/autosave/set","params":[true]}',
'{"jsonrpc":"2.0","id":69,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":70,"method":"minecraft:serversettings/autosave/set","params":[false]}',
'{"jsonrpc":"2.0","id":71,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":72,"method":"minecraft:serversettings/autosave/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":73,"method":"minecraft:serversettings/autosave"}',
'{"jsonrpc":"2.0","id":75,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":76,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":77,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":78,"method":"minecraft:serversettings/use_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":79,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":80,"method":"minecraft:serversettings/use_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":81,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":82,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":83,"method":"minecraft:serversettings/use_allowlist"}',
'{"jsonrpc":"2.0","id":84,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":85,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":86,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":87,"method":"minecraft:serversettings/enforce_allowlist/set","params":[false]}',
'{"jsonrpc":"2.0","id":88,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":89,"method":"minecraft:serversettings/enforce_allowlist/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":90,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":91,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
'{"jsonrpc":"2.0","id":92,"method":"minecraft:serversettings/enforce_allowlist"}',
'{"jsonrpc":"2.0","id":100,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":101,"method":"minecraft:serversettings/max_players/set","params":[10]}',
'{"jsonrpc":"2.0","id":102,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":103,"method":"minecraft:serversettings/max_players/set","params":[30]}',
'{"jsonrpc":"2.0","id":104,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":105,"method":"minecraft:serversettings/max_players/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":106,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":107,"method":"minecraft:serversettings/max_players/set","params":[30]}',
'{"jsonrpc":"2.0","id":108,"method":"minecraft:serversettings/max_players"}',
# The game rules: the list, a rule set (and set again to the same value), an integer rule, and what doesn't fit.
'{"jsonrpc":"2.0","id":200,"method":"minecraft:gamerules"}',
'{"jsonrpc":"2.0","id":201,"method":"minecraft:gamerules/update","params":[{"key":"minecraft:pvp","value":false}]}',
'{"jsonrpc":"2.0","id":202,"method":"minecraft:gamerules/update","params":[{"key":"minecraft:pvp","value":false}]}',
'{"jsonrpc":"2.0","id":203,"method":"minecraft:gamerules/update",'
'"params":[{"key":"minecraft:random_tick_speed","value":5}]}',
'{"jsonrpc":"2.0","id":204,"method":"minecraft:gamerules/update",'
'"params":[{"key":"minecraft:random_tick_speed","value":-1}]}',
'{"jsonrpc":"2.0","id":205,"method":"minecraft:gamerules/update","params":[{"key":"minecraft:pvp","value":"yes"}]}',
'{"jsonrpc":"2.0","id":206,"method":"minecraft:gamerules/update","params":[{"key":"minecraft:nope","value":true}]}',
'{"jsonrpc":"2.0","id":207,"method":"minecraft:gamerules/update","params":[{"value":true}]}',
'{"jsonrpc":"2.0","id":208,"method":"minecraft:gamerules"}',
'{"jsonrpc":"2.0","id":400,"method":"minecraft:serversettings/status_heartbeat_interval"}',
'{"jsonrpc":"2.0","id":401,"method":"minecraft:serversettings/status_heartbeat_interval/set","params":[30]}',
'{"jsonrpc":"2.0","id":402,"method":"minecraft:serversettings/status_heartbeat_interval"}',
'{"jsonrpc":"2.0","id":403,"method":"minecraft:serversettings/status_heartbeat_interval/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":404,"method":"minecraft:serversettings/status_heartbeat_interval"}',
# -5 cancels the running 30 s heartbeat and schedules none; ManagementServer keeps the cancelled future,
# so the set to 30 that follows is refused (cancel(true) returns false) and -5 is what is saved.
'{"jsonrpc":"2.0","id":405,"method":"minecraft:serversettings/status_heartbeat_interval/set","params":[-5]}',
'{"jsonrpc":"2.0","id":406,"method":"minecraft:serversettings/status_heartbeat_interval"}',
'{"jsonrpc":"2.0","id":407,"method":"minecraft:serversettings/status_heartbeat_interval/set","params":[30]}',
'{"jsonrpc":"2.0","id":120,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":121,"method":"minecraft:serversettings/player_idle_timeout/set","params":[10]}',
'{"jsonrpc":"2.0","id":122,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":123,"method":"minecraft:serversettings/player_idle_timeout/set","params":[0]}',
'{"jsonrpc":"2.0","id":124,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":125,"method":"minecraft:serversettings/player_idle_timeout/set","params":[-1]}',
'{"jsonrpc":"2.0","id":126,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":127,"method":"minecraft:serversettings/player_idle_timeout/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":128,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":134,"method":"minecraft:serversettings/player_idle_timeout/set","params":[1.9]}',
'{"jsonrpc":"2.0","id":135,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":136,"method":"minecraft:serversettings/player_idle_timeout/set","params":[4294967297]}',
'{"jsonrpc":"2.0","id":137,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":138,"method":"minecraft:serversettings/player_idle_timeout/set","params":[-4294967297]}',
'{"jsonrpc":"2.0","id":139,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":129,"method":"minecraft:serversettings/player_idle_timeout/set","params":[30]}',
'{"jsonrpc":"2.0","id":130,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":131,"method":"minecraft:serversettings/player_idle_timeout/set","params":[30]}',
'{"jsonrpc":"2.0","id":132,"method":"minecraft:serversettings/player_idle_timeout"}',
'{"jsonrpc":"2.0","id":110,"method":"minecraft:serversettings/pause_when_empty_seconds"}',
'{"jsonrpc":"2.0","id":111,"method":"minecraft:serversettings/pause_when_empty_seconds/set","params":[10]}',
'{"jsonrpc":"2.0","id":112,"method":"minecraft:serversettings/pause_when_empty_seconds"}',
'{"jsonrpc":"2.0","id":113,"method":"minecraft:serversettings/pause_when_empty_seconds/set","params":[30]}',
'{"jsonrpc":"2.0","id":114,"method":"minecraft:serversettings/pause_when_empty_seconds"}',
'{"jsonrpc":"2.0","id":115,"method":"minecraft:serversettings/pause_when_empty_seconds/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":116,"method":"minecraft:serversettings/pause_when_empty_seconds"}',
'{"jsonrpc":"2.0","id":117,"method":"minecraft:serversettings/pause_when_empty_seconds/set","params":[30]}',
'{"jsonrpc":"2.0","id":118,"method":"minecraft:serversettings/pause_when_empty_seconds"}',
]
# The notifications Horizon sends so far, which both servers must send for the same messages the same number of
# times (the others, `server/saving`... depend on timing, or aren't sent yet).
COMPARED_NOTIFICATIONS = {
f'minecraft:notification/{name}' for name in (
'gamerules/updated', 'operators/added', 'operators/removed', 'allowlist/added', 'allowlist/removed',
'bans/added', 'bans/removed', 'ip_bans/added', 'ip_bans/removed')}
def management_properties(port, keystore):
return {
'management-server-enabled': 'true',
'management-server-host': '127.0.0.1',
'management-server-port': str(port),
'management-server-secret': SECRET,
'management-server-tls-enabled': 'true',
'management-server-tls-keystore': str(keystore),
'management-server-tls-keystore-password': KEYSTORE_PASSWORD,
'management-server-allowed-origins': ORIGIN,
}
def properties_value(value):
"""A `server.properties` value as vanilla writes it: Java's parser reads a backslash as the start of an
escape, so a Windows path (the keystore) must have its own doubled."""
return value.replace('\\', '\\\\')
def make_keystore(work):
"""A PKCS12 keystore with a self-signed certificate for 127.0.0.1, made with the JDK's keytool, and a TLS
client context that trusts it."""
keytool = shutil.which('keytool')
if keytool is None:
java_home = os.environ.get('JAVA_HOME')
candidate = Path(java_home) / 'bin' / f'keytool{EXE}' if java_home else None
if candidate is None or not candidate.is_file():
raise RuntimeError('keytool not found; add a JDK to PATH or set JAVA_HOME')
keytool = str(candidate)
keystore, certificate = work / 'management.p12', work / 'management.pem'
common = ['-storetype', 'PKCS12', '-keystore', str(keystore), '-storepass', KEYSTORE_PASSWORD]
subprocess.run([keytool, '-genkeypair', '-alias', 'management', '-keyalg', 'RSA', '-keysize', '2048',
'-dname', 'CN=localhost', '-ext', 'SAN=dns:localhost,ip:127.0.0.1', '-validity', '2',
'-keypass', KEYSTORE_PASSWORD, *common], check=True, capture_output=True)
subprocess.run([keytool, '-exportcert', '-rfc', '-alias', 'management', '-file', str(certificate), *common],
check=True, capture_output=True)
context = ssl.create_default_context(cafile=str(certificate))
# A refused request's connection may end without TLS's close_notify.
context.options |= getattr(ssl, 'OP_IGNORE_UNEXPECTED_EOF', 0)
return keystore, context
def connect(port, context):
sock = socket.create_connection(('127.0.0.1', port), timeout=60)
return context.wrap_socket(sock, server_hostname='127.0.0.1')
def write_lists(folder):
for file, entries in LISTS.items():
(folder / file).write_text(json.dumps(entries, indent=2))
(folder / 'eula.txt').write_text('eula=true\n')
class Socket:
"""A WebSocket client: the handshake, masked text frames out, text frames in."""
def __init__(self, port, context, headers):
self.sock = connect(port, context)
key = base64.b64encode(os.urandom(16)).decode()
lines = [f'GET / HTTP/1.1', f'Host: 127.0.0.1:{port}', 'Upgrade: websocket', 'Connection: Upgrade',
f'Sec-WebSocket-Key: {key}', 'Sec-WebSocket-Version: 13', *headers]
self.sock.sendall(('\r\n'.join(lines) + '\r\n\r\n').encode())
self.buffer = b''
while b'\r\n\r\n' not in self.buffer:
data = self.sock.recv(4096)
if not data:
break
self.buffer += data
head, _, self.buffer = self.buffer.partition(b'\r\n\r\n')
self.head = head.decode('iso-8859-1').split('\r\n')
def header(self, name):
for line in self.head[1:]:
k, _, v = line.partition(':')
if k.strip().lower() == name.lower():
return v.strip()
return None
def send(self, text):
payload = text.encode()
mask = os.urandom(4)
length = len(payload)
head = bytes([0x81]) + (bytes([0x80 | length]) if length < 126 else
bytes([0x80 | 126]) + struct.pack('>H', length) if length < 65536 else
bytes([0x80 | 127]) + struct.pack('>Q', length))
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
def _read(self, n):
while len(self.buffer) < n:
data = self.sock.recv(65536)
if not data:
raise EOFError('the server closed the socket')
self.buffer += data
out, self.buffer = self.buffer[:n], self.buffer[n:]
return out
def receive(self, timeout):
"""The next text message, or None if none comes within the timeout."""
self.sock.settimeout(timeout)
try:
first = self._read(2)
except socket.timeout:
return None
self.sock.settimeout(60)
length = first[1] & 0x7f
if length == 126:
length = struct.unpack('>H', self._read(2))[0]
elif length == 127:
length = struct.unpack('>Q', self._read(8))[0]
payload = self._read(length)
if first[0] & 0x0f != 1:
raise AssertionError(f'expected a text frame, got opcode {first[0] & 0x0f}')
return payload.decode()
def http_status(port, context, headers):
"""The status line and body of a request the server must refuse."""
with connect(port, context) as sock:
lines = ['GET / HTTP/1.1', f'Host: 127.0.0.1:{port}', 'Upgrade: websocket', 'Connection: Upgrade',
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', 'Sec-WebSocket-Version: 13', *headers]
sock.sendall(('\r\n'.join(lines) + '\r\n\r\n').encode())
data = b''
while True:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
head, _, body = data.partition(b'\r\n\r\n')
if head and b'Content-Length' in head:
length = int(next(l.split(b':')[1] for l in head.split(b'\r\n')
if l.lower().startswith(b'content-length')))
if len(body) >= length:
break
head, _, body = data.partition(b'\r\n\r\n')
return head.split(b'\r\n')[0].split(b' ')[1].decode(), body.decode()
def normal(text):
"""An answer as compared: parsed, with a parse error's message left out."""
if text is None:
return None
value = json.loads(text)
if isinstance(value, dict) and value.get('error', {}).get('code') == -32700:
value['error'].pop('data', None)
# The game rules come in the order of a map keyed by identity in vanilla (it changes from run to run).
result = value.get('result') if isinstance(value, dict) else None
if isinstance(result, list) and result and all(isinstance(r, dict) and 'key' in r for r in result):
value['result'] = sorted(result, key=lambda rule: rule['key'])
return value
def session(port, context, log):
"""Everything one server answers."""
seen = {}
for label, headers in [('no secret', []), ('wrong secret', ['Authorization: Bearer nope']),
('origin not allowed', [f'Sec-WebSocket-Protocol: minecraft-v1,{SECRET}',
'Origin: http://evil.example'])]:
seen[label] = http_status(port, context, headers)
browser = Socket(port, context, [f'Sec-WebSocket-Protocol: minecraft-v1, {SECRET}', f'Origin: {ORIGIN}'])
seen['subprotocol'] = (browser.head[0].split(' ')[1], browser.header('Sec-WebSocket-Protocol'))
browser.sock.close()
ws = Socket(port, context, [f'Authorization: Bearer {SECRET}'])
if ws.head[0].split(' ')[1] != '101':
raise AssertionError(f'the handshake failed: {ws.head}')
seen['tls'] = ws.sock.version()
notifications = []
def answer(message):
"""The answer to a message, the notifications that come first set aside."""
ws.send(message)
while True:
text = ws.receive(timeout=3 if '"id"' not in message or '"result"' in message else 60)
log.write(f'> {message}\n< {text}\n')
value = normal(text)
if isinstance(value, dict) and 'method' in value and 'id' not in value:
notifications.append(value['method'])
continue
return value
seen['answers'] = [answer(message) for message in MESSAGES]
seen['stop'] = answer('{"jsonrpc":"2.0","id":99,"method":"minecraft:server/stop"}')
seen['notifications'] = notifications
return seen
def wait_for_exit(server, side, timeout):
"""The seconds the server's process took to end after `server/stop`, how long it was given.
Vanilla's exits: `net.minecraft.server.jsonrpc.methods.ServerStateService.stop` submits
`MinecraftServer.halt(false)`, `runServer` returns and `main` ends the process. A server still running is
the regression this harness exists to catch, so the failure names the side and the last lines it logged,
not only Python's `TimeoutExpired`."""
asked = time.time()
while time.time() - asked < timeout:
if server.process.poll() is not None:
return round(time.time() - asked, 1)
time.sleep(0.1)
raise AssertionError(
f'the {side} server was still running {timeout} s after server/stop, where vanilla\'s process '
f'exits:\n' + '\n'.join(server.since(0)[-10:]))
def main():
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / NAME
shutil.rmtree(build, ignore_errors=True)
build.mkdir(parents=True)
oh.run_logged(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client',
'-p', 'horizon-server', '--bin', 'horizon-server'], build, 'compile-rust')
target = lab.cargo_target() / 'fast'
work = Path(tempfile.mkdtemp(prefix='hz-management-'))
os.environ['HORIZON_API_DISCOVERY'] = NO_SERVICES
try:
keystore, context = make_keystore(work)
sides = {}
for side in ('vanilla', 'horizon'):
folder = work / side
folder.mkdir()
write_lists(folder)
port, management = lab.free_port(), lab.free_port()
props = {'server-port': str(port), 'online-mode': 'false', 'level-type': 'minecraft:flat',
'generate-structures': 'false', 'view-distance': '3', 'simulation-distance': '3',
**management_properties(management, keystore)}
if side == 'vanilla':
(folder / 'server.properties').write_text(''.join(f'{k}={properties_value(v)}\n'
for k, v in props.items()))
command = ['java', '-Xmx2g', '-Duser.timezone=UTC', f'-Dminecraft.api.discovery.host={NO_SERVICES}',
'-cp',
os.pathsep.join(map(str, [jars['server'], *libraries])), 'net.minecraft.server.Main',
'nogui']
else:
typed = ('server-port', 'online-mode', 'view-distance', 'simulation-distance')
toml = ''.join(f'"{k}" = {json.dumps(v)}\n' for k, v in props.items() if k not in typed)
(folder / 'server.toml').write_text(
f'[server]\nport = {port}\nonline_mode = false\nview_distance = 3\nsimulation_distance = 3\n\n'
f'[properties]\n{toml}\n'
f'[engine]\npath = {json.dumps(str(target / f"horizon-client{EXE}"))}\n'
f'resources = {json.dumps(str(lab.RESOURCES.resolve()))}\nrestart_on_crash = false\n\n'
'[backups]\nevery = "0"\n\n[panel]\nbind = ""\n')
command = [target / f'horizon-server{EXE}', '--dir', folder, 'run']
server = Server(command, folder, build / f'{side}.log')
try:
server.wait_for(DONE, 600)
with (build / f'{side}-session.log').open('w') as log:
sides[side] = session(management, context, log)
sides[side]['stop_seconds'] = wait_for_exit(server, side, 180)
properties = (folder / 'server.properties').read_text()
sides[side]['saved_difficulty'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('difficulty=')), None)
sides[side]['saved_game_mode'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('gamemode=')), None)
sides[side]['saved_force_game_mode'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('force-gamemode=')), None)
sides[side]['saved_use_allowlist'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('white-list=')), None)
sides[side]['saved_enforce_allowlist'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('enforce-whitelist=')), None)
sides[side]['saved_pause_when_empty_seconds'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('pause-when-empty-seconds=')), None)
sides[side]['saved_max_players'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('max-players=')), None)
sides[side]['saved_status_heartbeat_interval'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('status-heartbeat-interval=')), None)
sides[side]['saved_player_idle_timeout'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('player-idle-timeout=')), None)
sides[side]['saved_entity_broadcast_range'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('entity-broadcast-range-percentage=')), None)
sides[side]['saved_simulation_distance'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('simulation-distance=')), None)
sides[side]['saved_view_distance'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('view-distance=')), None)
sides[side]['saved_spawn_protection'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('spawn-protection=')), None)
sides[side]['saved_operator_user_permission_level'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('op-permission-level=')), None)
sides[side]['saved_allow_flight'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('allow-flight=')), None)
sides[side]['saved_accepts_transfers'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('accepts-transfers=')), None)
sides[side]['saved_hide_online_players'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('hide-online-players=')), None)
sides[side]['saved_status_replies'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('enable-status=')), None)
if side == 'horizon':
world = (folder / 'server.toml').read_text().partition('[world]')[2].split('\n[', 1)[0]
sides[side]['saved_config_difficulty'] = 'difficulty = "peaceful"' in world
sides[side]['saved_config_game_mode'] = 'game_mode = "spectator"' in world
sides[side]['saved_config_force_game_mode'] = 'force-gamemode = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_use_allowlist'] = 'whitelist = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_enforce_allowlist'] = 'enforce_whitelist = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_pause_when_empty_seconds'] = 'pause-when-empty-seconds = 30' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_max_players'] = 'max_players = 30' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_player_idle_timeout'] = 'player-idle-timeout = 30' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_entity_broadcast_range'] = 'entity-broadcast-range-percentage = 80' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_simulation_distance'] = 'simulation_distance = 8' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_view_distance'] = 'view_distance = 8' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_spawn_protection'] = 'spawn-protection = 8' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_operator_user_permission_level'] = 'op-permission-level = 2' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_allow_flight'] = 'allow-flight = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_accepts_transfers'] = 'accepts-transfers = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_hide_online_players'] = 'hide-online-players = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_status_replies'] = 'enable-status = false' in (
folder / 'server.toml').read_text()
finally:
server.stop()
restarted = Server(command, folder, build / f'{side}-restart.log')
try:
restarted.wait_for(DONE, 600)
ws = Socket(management, context, [f'Authorization: Bearer {SECRET}'])
try:
ws.send('{"jsonrpc":"2.0","id":64,"method":"minecraft:serversettings/force_game_mode"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_force_game_mode'] = reply
ws.send('{"jsonrpc":"2.0","id":74,"method":"minecraft:serversettings/autosave"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_autosave'] = reply
ws.send('{"jsonrpc":"2.0","id":119,"method":"minecraft:serversettings/pause_when_empty_seconds"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_pause_when_empty_seconds'] = reply
ws.send('{"jsonrpc":"2.0","id":93,"method":"minecraft:serversettings/use_allowlist"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_use_allowlist'] = reply
ws.send('{"jsonrpc":"2.0","id":94,"method":"minecraft:serversettings/enforce_allowlist"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_enforce_allowlist'] = reply
ws.send('{"jsonrpc":"2.0","id":109,"method":"minecraft:serversettings/max_players"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_max_players'] = reply
ws.send('{"jsonrpc":"2.0","id":408,"method":"minecraft:serversettings/status_heartbeat_interval"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_status_heartbeat_interval'] = reply
ws.send('{"jsonrpc":"2.0","id":133,"method":"minecraft:serversettings/player_idle_timeout"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_player_idle_timeout'] = reply
ws.send('{"jsonrpc":"2.0","id":149,"method":"minecraft:serversettings/allow_flight"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_allow_flight'] = reply
ws.send('{"jsonrpc":"2.0","id":308,"method":"minecraft:serversettings/accept_transfers"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_accepts_transfers'] = reply
ws.send('{"jsonrpc":"2.0","id":189,"method":"minecraft:serversettings/entity_broadcast_range"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_entity_broadcast_range'] = reply
ws.send('{"jsonrpc":"2.0","id":179,"method":"minecraft:serversettings/simulation_distance"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_simulation_distance'] = reply
ws.send('{"jsonrpc":"2.0","id":169,"method":"minecraft:serversettings/view_distance"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_view_distance'] = reply
ws.send('{"jsonrpc":"2.0","id":159,"method":"minecraft:serversettings/spawn_protection_radius"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_spawn_protection'] = reply
ws.send('{"jsonrpc":"2.0","id":199,"method":"minecraft:serversettings/hide_online_players"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_hide_online_players'] = reply
ws.send('{"jsonrpc":"2.0","id":229,"method":"minecraft:serversettings/operator_user_permission_level"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_operator_user_permission_level'] = reply
ws.send('{"jsonrpc":"2.0","id":219,"method":"minecraft:serversettings/status_replies"}')
while True:
reply = normal(ws.receive(timeout=60))
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_status_replies'] = reply
finally:
ws.sock.close()
finally:
restarted.stop()
vanilla, horizon = sides['vanilla'], sides['horizon']
failures = []
for key in ('no secret', 'wrong secret', 'origin not allowed', 'subprotocol', 'tls', 'stop'):
if vanilla[key] != horizon[key]:
failures.append(f'{key}: vanilla {vanilla[key]!r}, Horizon {horizon[key]!r}')
for message, v, h in zip(MESSAGES, vanilla['answers'], horizon['answers']):
if v != h:
failures.append(f'{message}\n vanilla {json.dumps(v)[:400]}\n Horizon {json.dumps(h)[:400]}')
told = {side: sorted(n for n in sides[side]['notifications'] if n in COMPARED_NOTIFICATIONS)
for side in ('vanilla', 'horizon')}
if told['vanilla'] != told['horizon']:
failures.append(f"notifications: vanilla {told['vanilla']}\n Horizon {told['horizon']}")
if vanilla['saved_difficulty'] != horizon['saved_difficulty'] or vanilla['saved_difficulty'] != 'peaceful':
failures.append(f"saved difficulty: vanilla {vanilla['saved_difficulty']!r}, Horizon {horizon['saved_difficulty']!r}")
if not horizon['saved_config_difficulty']:
failures.append('Horizon did not save peaceful difficulty in server.toml')
if vanilla['saved_game_mode'] != horizon['saved_game_mode'] or vanilla['saved_game_mode'] != 'spectator':
failures.append(f"saved game mode: vanilla {vanilla['saved_game_mode']!r}, Horizon {horizon['saved_game_mode']!r}")
if not horizon['saved_config_game_mode']:
failures.append('Horizon did not save spectator game mode in server.toml')
if vanilla['saved_force_game_mode'] != horizon['saved_force_game_mode'] or vanilla['saved_force_game_mode'] != 'true':
failures.append(f"saved forced game mode: vanilla {vanilla['saved_force_game_mode']!r}, Horizon {horizon['saved_force_game_mode']!r}")
if not horizon['saved_config_force_game_mode']:
failures.append('Horizon did not save forced game mode in server.toml')
if vanilla['saved_use_allowlist'] != horizon['saved_use_allowlist'] or vanilla['saved_use_allowlist'] != 'true':
failures.append(f"saved allowlist use: vanilla {vanilla['saved_use_allowlist']!r}, Horizon {horizon['saved_use_allowlist']!r}")
if vanilla['saved_enforce_allowlist'] != horizon['saved_enforce_allowlist'] or vanilla['saved_enforce_allowlist'] != 'true':
failures.append(f"saved allowlist enforcement: vanilla {vanilla['saved_enforce_allowlist']!r}, Horizon {horizon['saved_enforce_allowlist']!r}")
if not horizon['saved_config_use_allowlist']:
failures.append('Horizon did not save allowlist use in server.toml')
if not horizon['saved_config_enforce_allowlist']:
failures.append('Horizon did not save allowlist enforcement in server.toml')
if vanilla['saved_pause_when_empty_seconds'] != horizon['saved_pause_when_empty_seconds'] or \
vanilla['saved_pause_when_empty_seconds'] != '30':
failures.append(f"saved pause-when-empty seconds: vanilla {vanilla['saved_pause_when_empty_seconds']!r}, Horizon {horizon['saved_pause_when_empty_seconds']!r}")
if not horizon['saved_config_pause_when_empty_seconds']:
failures.append('Horizon did not save pause-when-empty seconds in server.toml')
if vanilla['restarted_force_game_mode'] != horizon['restarted_force_game_mode'] or \
vanilla['restarted_force_game_mode'] != {'jsonrpc': '2.0', 'id': 64, 'result': True}:
failures.append(f"restarted forced game mode: vanilla {vanilla['restarted_force_game_mode']!r}, Horizon {horizon['restarted_force_game_mode']!r}")
if vanilla['restarted_autosave'] != horizon['restarted_autosave'] or \
vanilla['restarted_autosave'] != {'jsonrpc': '2.0', 'id': 74, 'result': True}:
failures.append(f"restarted autosave: vanilla {vanilla['restarted_autosave']!r}, Horizon {horizon['restarted_autosave']!r}")
if vanilla['restarted_pause_when_empty_seconds'] != horizon['restarted_pause_when_empty_seconds'] or \
vanilla['restarted_pause_when_empty_seconds'] != {'jsonrpc': '2.0', 'id': 119, 'result': 30}:
failures.append(f"restarted pause-when-empty seconds: vanilla {vanilla['restarted_pause_when_empty_seconds']!r}, Horizon {horizon['restarted_pause_when_empty_seconds']!r}")
if vanilla['restarted_use_allowlist'] != horizon['restarted_use_allowlist'] or \
vanilla['restarted_use_allowlist'] != {'jsonrpc': '2.0', 'id': 93, 'result': True}:
failures.append(f"restarted allowlist use: vanilla {vanilla['restarted_use_allowlist']!r}, Horizon {horizon['restarted_use_allowlist']!r}")
if vanilla['restarted_enforce_allowlist'] != horizon['restarted_enforce_allowlist'] or \
vanilla['restarted_enforce_allowlist'] != {'jsonrpc': '2.0', 'id': 94, 'result': True}:
failures.append(f"restarted allowlist enforcement: vanilla {vanilla['restarted_enforce_allowlist']!r}, Horizon {horizon['restarted_enforce_allowlist']!r}")
if vanilla['saved_max_players'] != horizon['saved_max_players'] or vanilla['saved_max_players'] != '30':
failures.append(f"saved max players: vanilla {vanilla['saved_max_players']!r}, Horizon {horizon['saved_max_players']!r}")
if not horizon['saved_config_max_players']:
failures.append('Horizon did not save max players in server.toml')
if vanilla['restarted_max_players'] != horizon['restarted_max_players'] or \
vanilla['restarted_max_players'] != {'jsonrpc': '2.0', 'id': 109, 'result': 30}:
failures.append(f"restarted max players: vanilla {vanilla['restarted_max_players']!r}, Horizon {horizon['restarted_max_players']!r}")
if vanilla['saved_status_heartbeat_interval'] != horizon['saved_status_heartbeat_interval'] or \
vanilla['saved_status_heartbeat_interval'] != '-5':
failures.append(f"saved status heartbeat interval: vanilla {vanilla['saved_status_heartbeat_interval']!r}, Horizon {horizon['saved_status_heartbeat_interval']!r}")
if vanilla['restarted_status_heartbeat_interval'] != horizon['restarted_status_heartbeat_interval'] or \
vanilla['restarted_status_heartbeat_interval'] != {'jsonrpc': '2.0', 'id': 408, 'result': -5}:
failures.append(f"restarted status heartbeat interval: vanilla {vanilla['restarted_status_heartbeat_interval']!r}, Horizon {horizon['restarted_status_heartbeat_interval']!r}")
if vanilla['saved_player_idle_timeout'] != horizon['saved_player_idle_timeout'] or \
vanilla['saved_player_idle_timeout'] != '30':
failures.append(f"saved player idle timeout: vanilla {vanilla['saved_player_idle_timeout']!r}, Horizon {horizon['saved_player_idle_timeout']!r}")
if not horizon['saved_config_player_idle_timeout']:
failures.append('Horizon did not save player idle timeout in server.toml')
if vanilla['restarted_player_idle_timeout'] != horizon['restarted_player_idle_timeout'] or \
vanilla['restarted_player_idle_timeout'] != {'jsonrpc': '2.0', 'id': 133, 'result': 30}:
failures.append(f"restarted player idle timeout: vanilla {vanilla['restarted_player_idle_timeout']!r}, Horizon {horizon['restarted_player_idle_timeout']!r}")
if vanilla['saved_entity_broadcast_range'] != horizon['saved_entity_broadcast_range'] or \
vanilla['saved_entity_broadcast_range'] != '80':
failures.append(f"saved entity broadcast range: vanilla {vanilla['saved_entity_broadcast_range']!r}, Horizon {horizon['saved_entity_broadcast_range']!r}")
if not horizon['saved_config_entity_broadcast_range']:
failures.append('Horizon did not save entity broadcast range in server.toml')
if vanilla['restarted_entity_broadcast_range'] != horizon['restarted_entity_broadcast_range'] or \
vanilla['restarted_entity_broadcast_range'] != {'jsonrpc': '2.0', 'id': 189, 'result': 80}:
failures.append(f"restarted entity broadcast range: vanilla {vanilla['restarted_entity_broadcast_range']!r}, Horizon {horizon['restarted_entity_broadcast_range']!r}")
if vanilla['saved_simulation_distance'] != horizon['saved_simulation_distance'] or \
vanilla['saved_simulation_distance'] != '8':
failures.append(f"saved simulation distance: vanilla {vanilla['saved_simulation_distance']!r}, Horizon {horizon['saved_simulation_distance']!r}")
if not horizon['saved_config_simulation_distance']:
failures.append('Horizon did not save simulation distance in server.toml')
if vanilla['restarted_simulation_distance'] != horizon['restarted_simulation_distance'] or \
vanilla['restarted_simulation_distance'] != {'jsonrpc': '2.0', 'id': 179, 'result': 8}:
failures.append(f"restarted simulation distance: vanilla {vanilla['restarted_simulation_distance']!r}, Horizon {horizon['restarted_simulation_distance']!r}")
if vanilla['saved_view_distance'] != horizon['saved_view_distance'] or vanilla['saved_view_distance'] != '8':
failures.append(f"saved view distance: vanilla {vanilla['saved_view_distance']!r}, Horizon {horizon['saved_view_distance']!r}")
if not horizon['saved_config_view_distance']:
failures.append('Horizon did not save view distance in server.toml')
if vanilla['restarted_view_distance'] != horizon['restarted_view_distance'] or \
vanilla['restarted_view_distance'] != {'jsonrpc': '2.0', 'id': 169, 'result': 8}:
failures.append(f"restarted view distance: vanilla {vanilla['restarted_view_distance']!r}, Horizon {horizon['restarted_view_distance']!r}")
if vanilla['saved_spawn_protection'] != horizon['saved_spawn_protection'] or vanilla['saved_spawn_protection'] != '8':
failures.append(f"saved spawn protection: vanilla {vanilla['saved_spawn_protection']!r}, Horizon {horizon['saved_spawn_protection']!r}")
if not horizon['saved_config_spawn_protection']:
failures.append('Horizon did not save spawn protection in server.toml')
if vanilla['restarted_spawn_protection'] != horizon['restarted_spawn_protection'] or \
vanilla['restarted_spawn_protection'] != {'jsonrpc': '2.0', 'id': 159, 'result': 8}:
failures.append(f"restarted spawn protection: vanilla {vanilla['restarted_spawn_protection']!r}, Horizon {horizon['restarted_spawn_protection']!r}")
if vanilla['saved_operator_user_permission_level'] != horizon['saved_operator_user_permission_level'] or \
vanilla['saved_operator_user_permission_level'] != '2':
failures.append(f"saved operator permission level: vanilla {vanilla['saved_operator_user_permission_level']!r}, Horizon {horizon['saved_operator_user_permission_level']!r}")
if not horizon['saved_config_operator_user_permission_level']:
failures.append('Horizon did not save operator permission level in server.toml')
if vanilla['restarted_operator_user_permission_level'] != horizon['restarted_operator_user_permission_level'] or \
vanilla['restarted_operator_user_permission_level'] != {'jsonrpc': '2.0', 'id': 229, 'result': 2}:
failures.append(f"restarted operator permission level: vanilla {vanilla['restarted_operator_user_permission_level']!r}, Horizon {horizon['restarted_operator_user_permission_level']!r}")
if vanilla['saved_allow_flight'] != horizon['saved_allow_flight'] or vanilla['saved_allow_flight'] != 'true':
failures.append(f"saved allow-flight: vanilla {vanilla['saved_allow_flight']!r}, Horizon {horizon['saved_allow_flight']!r}")
if not horizon['saved_config_allow_flight']:
failures.append('Horizon did not save allow-flight in server.toml')
if vanilla['restarted_allow_flight'] != horizon['restarted_allow_flight'] or \
vanilla['restarted_allow_flight'] != {'jsonrpc': '2.0', 'id': 149, 'result': True}:
failures.append(f"restarted allow-flight: vanilla {vanilla['restarted_allow_flight']!r}, Horizon {horizon['restarted_allow_flight']!r}")
if vanilla['saved_accepts_transfers'] != horizon['saved_accepts_transfers'] or \
vanilla['saved_accepts_transfers'] != 'true':
failures.append(f"saved accepts-transfers: vanilla {vanilla['saved_accepts_transfers']!r}, Horizon {horizon['saved_accepts_transfers']!r}")
if not horizon['saved_config_accepts_transfers']:
failures.append('Horizon did not save accepts-transfers in server.toml')
if vanilla['restarted_accepts_transfers'] != horizon['restarted_accepts_transfers'] or \
vanilla['restarted_accepts_transfers'] != {'jsonrpc': '2.0', 'id': 308, 'result': True}:
failures.append(f"restarted accepts-transfers: vanilla {vanilla['restarted_accepts_transfers']!r}, Horizon {horizon['restarted_accepts_transfers']!r}")
if vanilla['saved_hide_online_players'] != horizon['saved_hide_online_players'] or \
vanilla['saved_hide_online_players'] != 'true':
failures.append(f"saved hide-online-players: vanilla {vanilla['saved_hide_online_players']!r}, Horizon {horizon['saved_hide_online_players']!r}")
if not horizon['saved_config_hide_online_players']:
failures.append('Horizon did not save hide-online-players in server.toml')
if vanilla['restarted_hide_online_players'] != horizon['restarted_hide_online_players'] or \
vanilla['restarted_hide_online_players'] != {'jsonrpc': '2.0', 'id': 199, 'result': True}:
failures.append(f"restarted hide-online-players: vanilla {vanilla['restarted_hide_online_players']!r}, Horizon {horizon['restarted_hide_online_players']!r}")
if vanilla['saved_status_replies'] != horizon['saved_status_replies'] or vanilla['saved_status_replies'] != 'false':
failures.append(f"saved status replies: vanilla {vanilla['saved_status_replies']!r}, Horizon {horizon['saved_status_replies']!r}")
if not horizon['saved_config_status_replies']:
failures.append('Horizon did not save status replies in server.toml')
if vanilla['restarted_status_replies'] != horizon['restarted_status_replies'] or \
vanilla['restarted_status_replies'] != {'jsonrpc': '2.0', 'id': 219, 'result': False}:
failures.append(f"restarted status replies: vanilla {vanilla['restarted_status_replies']!r}, Horizon {horizon['restarted_status_replies']!r}")
(build / 'report.json').write_text(json.dumps(sides, indent=1))
if failures:
sys.exit(f'{NAME}: {len(failures)} differences (logs in {build}):\n' + '\n'.join(failures))
report = lab.LOCAL / 'datagen/out/reports/json-rpc-api-schema.json'
discovered = horizon['answers'][MESSAGES.index('{"jsonrpc":"2.0","id":12,"method":"rpc.discover"}')]
if json.loads(report.read_text()) != discovered.get('result'):
failures.append(f'rpc.discover differs from {report}')
if failures:
sys.exit(f'{NAME}: {len(failures)} differences (logs in {build}):\n' + '\n'.join(failures))
finally:
shutil.rmtree(work, ignore_errors=True)
print(f'{NAME}: {len(MESSAGES) + 6} exchanges over TLS answered as vanilla answers them; rpc.discover matches '
'the report')
if __name__ == '__main__':
main()