diff --git a/packages/cultmesh-py/src/cultmesh_py/daemon.py b/packages/cultmesh-py/src/cultmesh_py/daemon.py index d21d42d8..c98063f7 100644 --- a/packages/cultmesh-py/src/cultmesh_py/daemon.py +++ b/packages/cultmesh-py/src/cultmesh_py/daemon.py @@ -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 diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultmesh-py/src/cultmesh_py/server.py index 25deefd7..f6453d23 100644 --- a/packages/cultmesh-py/src/cultmesh_py/server.py +++ b/packages/cultmesh-py/src/cultmesh_py/server.py @@ -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" diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index 163e08be..cb4b767d 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -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.