cultmesh-py: RUDP option rules apply only to a RUDP server; the daemon reports a refused limit as a usage error

The RUDP option rules refused a TCP-only server (enable_rudp=False) for
RUDP limits it never uses. Rust checks those options only for the RUDP
server they configure, so Python now checks rudp_resend_delay_ms,
rudp_max_pending_reliable_packets, rudp_max_fragment_bytes and the empty
snapshot's fragment rule only when enable_rudp is true. The snapshot
limits still apply to every server.

The daemon turned --max-snapshot-documents 0 into a traceback. The server
still owns the rule; the daemon reports its refusal through
parser.error: exit status 2 and the rule named, no traceback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 19:38:20 +02:00
commit ead1a25aff
3 changed files with 43 additions and 25 deletions

View file

@ -50,7 +50,7 @@ def main(argv: list[str] | None = None) -> int:
stop = threading.Event()
install_signal_handlers(stop)
server = start_server(args)
server = start_server(args, parser)
try:
ready = daemon_ready_document(server)
publish_ready(ready, ready_file=args.ready_file)
@ -61,7 +61,7 @@ def main(argv: list[str] | None = None) -> int:
return 0
def start_server(args: argparse.Namespace) -> CultMeshLocalServer:
def start_server(args: argparse.Namespace, parser: argparse.ArgumentParser) -> CultMeshLocalServer:
node = CultMesh.create_node(
args.cache_file,
runtime_id=args.runtime_id,
@ -98,17 +98,21 @@ def start_server(args: argparse.Namespace) -> CultMeshLocalServer:
quorum_ratio=args.simulation_quorum_ratio,
)
)
server = CultMesh.serve_node(
node,
verse_catalog=verse_catalog,
peer_catalog=peer_catalog,
observation_hub=observation_hub,
host=args.host,
port=args.port,
display_name=args.display_name,
max_snapshot_documents=args.max_snapshot_documents,
max_snapshot_bytes=args.max_snapshot_bytes,
)
try:
server = CultMesh.serve_node(
node,
verse_catalog=verse_catalog,
peer_catalog=peer_catalog,
observation_hub=observation_hub,
host=args.host,
port=args.port,
display_name=args.display_name,
max_snapshot_documents=args.max_snapshot_documents,
max_snapshot_bytes=args.max_snapshot_bytes,
)
except ValueError as error:
# The server owns its option rules; a limit it refuses is a usage error, not a crash.
parser.error(str(error))
advertise_self(server, verse_catalog, peer_catalog, args)
return server

View file

@ -105,23 +105,25 @@ class CultMeshLocalServer:
# The Rust document server's option rules (validate_options), for each option this
# server shares with it: every limit is greater than zero, and the limits leave room for
# the smallest response the server sends, an empty snapshot. A server that could answer
# nothing is misconfigured.
# nothing is misconfigured. The RUDP options are checked only when RUDP is served, as
# Rust checks them only for the RUDP server they configure.
if self.max_snapshot_bytes is not None and self.max_snapshot_bytes <= 0:
raise ValueError("max_snapshot_bytes must be greater than zero")
if self.max_snapshot_documents is not None and self.max_snapshot_documents <= 0:
raise ValueError("max_snapshot_documents must be greater than zero")
if self.rudp_resend_delay_ms <= 0:
raise ValueError("rudp_resend_delay_ms must be greater than zero")
if self.rudp_max_pending_reliable_packets is not None and self.rudp_max_pending_reliable_packets <= 0:
raise ValueError("rudp_max_pending_reliable_packets must be greater than zero")
if self.rudp_max_fragment_bytes <= 0:
raise ValueError("rudp_max_fragment_bytes must be greater than zero")
if self.enable_rudp:
if self.rudp_resend_delay_ms <= 0:
raise ValueError("rudp_resend_delay_ms must be greater than zero")
if self.rudp_max_pending_reliable_packets is not None and self.rudp_max_pending_reliable_packets <= 0:
raise ValueError("rudp_max_pending_reliable_packets must be greater than zero")
if self.rudp_max_fragment_bytes <= 0:
raise ValueError("rudp_max_fragment_bytes must be greater than zero")
empty = _snapshot_response_bytes(
CultNetRawSnapshotResponse(message_id=_SHORTEST_SERVED_MESSAGE_ID, documents=()).to_wire()
)
if self.max_snapshot_bytes is not None and empty > self.max_snapshot_bytes:
raise ValueError("max_snapshot_bytes cannot hold an empty snapshot response")
if -(-empty // self.rudp_max_fragment_bytes) > self._max_rudp_reply_fragments():
if self.enable_rudp and -(-empty // self.rudp_max_fragment_bytes) > self._max_rudp_reply_fragments():
raise ValueError(
"rudp_max_fragment_bytes and rudp_max_pending_reliable_packets cannot carry an empty "
"snapshot response"

View file

@ -3722,8 +3722,8 @@ class CultMeshTests(unittest.TestCase):
def test_cultmesh_daemon_refuses_a_snapshot_document_limit_of_zero_at_startup(self) -> None:
# A limit of zero is a server that could answer no snapshot, which the Rust document
# server's options also refuse; the daemon passes the flag to the server, and exits
# naming the rule instead of serving. The limit's enforcement as a peer error is pinned
# server's options also refuse; the daemon passes the flag to the server, and exits with
# a usage error naming the rule instead of serving. The limit's enforcement as a peer error is pinned
# in process by test_cultmesh_local_server_rejects_oversized_snapshot_responses.
with tempfile.TemporaryDirectory() as temp:
ready_path = Path(temp) / "ready.json"
@ -3755,8 +3755,10 @@ class CultMeshTests(unittest.TestCase):
env=env,
timeout=30,
)
self.assertNotEqual(completed.returncode, 0)
self.assertIn("max_snapshot_documents must be greater than zero", completed.stderr)
# A usage error from argparse: exit status 2, the rule named, no traceback.
self.assertEqual(completed.returncode, 2, completed.stderr)
self.assertIn("error: max_snapshot_documents must be greater than zero", completed.stderr)
self.assertNotIn("Traceback", completed.stderr)
self.assertFalse(ready_path.exists())
def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None:
@ -4719,6 +4721,16 @@ class CultMeshTests(unittest.TestCase):
accepted(rudp_resend_delay_ms=1)
self.assertIn("greater than zero", refused(rudp_max_fragment_bytes=0))
self.assertIn("greater than zero", refused(rudp_max_pending_reliable_packets=0))
# The RUDP options configure only the RUDP server: without one, none of them is checked.
accepted(enable_rudp=False, rudp_max_fragment_bytes=1, rudp_max_pending_reliable_packets=16)
accepted(
enable_rudp=False,
rudp_max_fragment_bytes=0,
rudp_max_pending_reliable_packets=0,
rudp_resend_delay_ms=0,
)
self.assertIn("greater than zero", refused(enable_rudp=False, max_snapshot_documents=0))
self.assertIn("cannot hold an empty snapshot response", refused(enable_rudp=False, max_snapshot_bytes=empty - 1))
# The configuration at the bound does answer: an empty snapshot in one-byte fragments,
# exactly as many as the queue holds.