From 42520b33d57753e46c116366a9e72294b17d9bae Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 21:23:45 +0000 Subject: [PATCH] fix(join): increase join_world timeout, log dedicated server stdout/stderr, null check multiplayer --- backend/app/services/game_servers.py | 21 +++++++++++++++++++-- scripts/api_client.gd | 7 ++++--- scripts/network_client.gd | 3 ++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/app/services/game_servers.py b/backend/app/services/game_servers.py index c9af201..c49c87f 100644 --- a/backend/app/services/game_servers.py +++ b/backend/app/services/game_servers.py @@ -50,6 +50,7 @@ class _WorldServer: proc: subprocess.Popen status_path: Path token: str + log_path: Path | None = None started_at: float = field(default_factory=time.time) last_join: float = field(default_factory=time.time) @@ -150,6 +151,7 @@ class GameServerSupervisor: token = secrets.token_urlsafe(32) status_path = self._runtime_dir / f"world_{world_id}_{port}.json" status_path.unlink(missing_ok=True) + log_path = self._runtime_dir / f"world_{world_id}_{port}.log" cmd = [ settings.GODOT_BIN, "--headless", "--path", settings.GAME_PROJECT_DIR, @@ -160,9 +162,10 @@ class GameServerSupervisor: "--api-url", settings.GAME_SERVER_INTERNAL_API_URL, "--server-token", token, ] - proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + log_file = open(log_path, "w") + proc = subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT) return _WorldServer( - world_id=world_id, port=port, proc=proc, status_path=status_path, token=token + world_id=world_id, port=port, proc=proc, status_path=status_path, token=token, log_path=log_path ) def _free_port(self) -> int: @@ -199,13 +202,27 @@ class GameServerSupervisor: if srv.is_ready(): return if not srv.alive(): + log_snippet = "" + if srv.log_path and srv.log_path.exists(): + try: + log_snippet = srv.log_path.read_text()[-500:] + except OSError: + pass raise TimeoutError( f"server process for world {srv.world_id} exited before becoming ready" + + (f": {log_snippet}" if log_snippet else "") ) await asyncio.sleep(0.25) + log_snippet = "" + if srv.log_path and srv.log_path.exists(): + try: + log_snippet = srv.log_path.read_text()[-500:] + except OSError: + pass raise TimeoutError( f"server for world {srv.world_id} did not report ready within " f"{settings.GAME_SERVER_READY_TIMEOUT_SECONDS}s" + + (f": {log_snippet}" if log_snippet else "") ) async def _reap_loop(self) -> None: diff --git a/scripts/api_client.gd b/scripts/api_client.gd index 6182f3a..f7d76d1 100644 --- a/scripts/api_client.gd +++ b/scripts/api_client.gd @@ -139,10 +139,11 @@ func request( body: String = "", content_type: String = "application/json", require_auth: bool = false, - custom_headers: PackedStringArray = PackedStringArray() + custom_headers: PackedStringArray = PackedStringArray(), + custom_timeout: float = 0.0 ) -> Dictionary: var http := HTTPRequest.new() - http.timeout = timeout_seconds + http.timeout = custom_timeout if custom_timeout > 0.0 else timeout_seconds http.max_redirects = 8 http.accept_gzip = true add_child(http) @@ -268,7 +269,7 @@ func get_avatar_loadout() -> Dictionary: ## its address. On success result.data is {"world_id", "host", "port"}. ## 503 if this backend has no game-server orchestration. func join_world(world_id: int) -> Dictionary: - return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true) + return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true, PackedStringArray(), 25.0) ## Save (replace) the current user's avatar loadout (requires auth). diff --git a/scripts/network_client.gd b/scripts/network_client.gd index c036212..fc33ba5 100644 --- a/scripts/network_client.gd +++ b/scripts/network_client.gd @@ -107,7 +107,8 @@ func disconnect_from_server() -> void: peer.close() if _server_root and is_instance_valid(_server_root): _server_root.queue_free() - multiplayer.multiplayer_peer = null + if multiplayer: + multiplayer.multiplayer_peer = null _prediction_buffer.clear() _remote_states.clear()