diff --git a/Assets/Scripts/Network/OnlineSessionManager.cs b/Assets/Scripts/Network/OnlineSessionManager.cs index c4bda7a..61f2530 100644 --- a/Assets/Scripts/Network/OnlineSessionManager.cs +++ b/Assets/Scripts/Network/OnlineSessionManager.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Unity.Netcode; using Unity.Services.Authentication; using Unity.Services.Core; @@ -34,6 +36,9 @@ public class OnlineSessionManager : MonoBehaviour [Tooltip("Maximum players per hosted session (host included).")] public int maxPlayers = 8; + [Tooltip("Give up on a hosting/joining/sign-in attempt that hangs longer than this many seconds (0 = wait forever).")] + public float connectTimeoutSeconds = 25f; + private NetworkManager _net; private ISession _session; // active session (host or client); null when not in one private List _sessions = new List(); @@ -128,10 +133,10 @@ public class OnlineSessionManager : MonoBehaviour // already have separate caches; this only matters for same-machine clone testing. var options = new InitializationOptions(); options.SetProfile(ProfileForThisInstall()); - await UnityServices.InitializeAsync(options); + await Timed(UnityServices.InitializeAsync(options)); } if (!AuthenticationService.Instance.IsSignedIn) - await AuthenticationService.Instance.SignInAnonymouslyAsync(); + await Timed(AuthenticationService.Instance.SignInAnonymouslyAsync()); } // Deterministic, regex-safe (^[a-zA-Z0-9_-]{1,30}$) profile derived from the install path, so the same @@ -149,8 +154,9 @@ public class OnlineSessionManager : MonoBehaviour } // Shared busy/sign-in/error skeleton around one session operation; the body carries the - // op-specific statuses and state. - private async System.Threading.Tasks.Task RunSessionOpAsync(string failLabel, Func body) + // op-specific statuses and state. ownsConnection = the op starts NGO (host/join), so a timeout that + // left the transport half-up must tear it down. + private async System.Threading.Tasks.Task RunSessionOpAsync(string failLabel, bool ownsConnection, Func body) { SetBusy(true); try @@ -159,6 +165,15 @@ public class OnlineSessionManager : MonoBehaviour await EnsureSignedInAsync(); await body(); } + catch (TimeoutException) + { + // A create/join can bring the shared transport up before it hangs; drop that partial host so we + // don't leave the user "connected" with no session. Only for host/join - a refresh may legitimately + // run while already hosting, and must not shut that host down. + if (ownsConnection && IsConnected) _net.Shutdown(); + SetStatus($"{failLabel} timed out after {connectTimeoutSeconds:0}s"); + Debug.LogWarning($"[OnlineSession] {failLabel} timed out after {connectTimeoutSeconds:0}s"); + } catch (Exception e) { SetStatus($"{failLabel} failed: {e.Message}"); @@ -167,10 +182,67 @@ public class OnlineSessionManager : MonoBehaviour finally { SetBusy(false); } } + // The UGS session calls take no CancellationToken, so a hung host/join/sign-in would otherwise block + // forever - _busy never clears and the browser UI locks permanently. WhenAny bounds each call. The + // abandoned op keeps running (hot task, no cooperative cancel), so disposeLate tears down any session + // that materializes AFTER we reported the timeout - preventing an orphaned Relay allocation / ghost host. + private async Task Timed(Task op, Func disposeLate = null) + { + if (connectTimeoutSeconds <= 0f) return await op; + using (var timer = new CancellationTokenSource()) + { + if (await Task.WhenAny(op, Task.Delay(TimeSpan.FromSeconds(connectTimeoutSeconds), timer.Token)) == op) + { + timer.Cancel(); + return await op; + } + } + DiscardLate(op, disposeLate); + throw new TimeoutException($"timed out after {connectTimeoutSeconds:0}s"); + } + + private async Task Timed(Task op) + { + if (connectTimeoutSeconds <= 0f) { await op; return; } + using (var timer = new CancellationTokenSource()) + { + if (await Task.WhenAny(op, Task.Delay(TimeSpan.FromSeconds(connectTimeoutSeconds), timer.Token)) == op) + { + timer.Cancel(); + await op; + return; + } + } + Observe(op); + throw new TimeoutException($"timed out after {connectTimeoutSeconds:0}s"); + } + + private static async void DiscardLate(Task op, Func disposeLate) + { + T result; + try { result = await op; } + catch { return; } // op faulted late: nothing was created + if (disposeLate == null) return; + try { await disposeLate(result); } + catch (Exception e) { Debug.LogWarning($"[OnlineSession] late-timeout cleanup failed: {e.Message}"); } + } + + // Observe an abandoned task so its result/exception is never left unhandled. + private static async void Observe(Task op) + { + try { await op; } catch { } + } + + // Late-cleanup for a timed-out create/join that yields a session anyway. We share ONE NetworkManager, so + // leaving `late` also tears down the live transport - only reclaim the ghost when the user hasn't since + // established a tracked session (a successful retry sets _session). Runs on the main thread: no race. + private Task LeaveIfDisplaced(ISession late) + => (late != null && _session == null) ? late.LeaveAsync() : Task.CompletedTask; + private async void HostAsync() { if (_busy || IsConnected) return; - await RunSessionOpAsync("Host", async () => + await RunSessionOpAsync("Host", true, async () => { SetStatus("Creating session…"); var options = new SessionOptions @@ -178,7 +250,7 @@ public class OnlineSessionManager : MonoBehaviour Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(), MaxPlayers = Mathf.Max(2, maxPlayers) }.WithRelayNetwork(); - _session = await MultiplayerService.Instance.CreateSessionAsync(options); + _session = await Timed(MultiplayerService.Instance.CreateSessionAsync(options), LeaveIfDisplaced); SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})"); }); } @@ -186,10 +258,10 @@ public class OnlineSessionManager : MonoBehaviour private async void RefreshAsync() { if (_busy) return; - await RunSessionOpAsync("Refresh", async () => + await RunSessionOpAsync("Refresh", false, async () => { SetStatus("Querying sessions…"); - var results = await MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions()); + var results = await Timed(MultiplayerService.Instance.QuerySessionsAsync(new QuerySessionsOptions())); _sessions = new List(results.Sessions); SessionsChanged?.Invoke(); SetStatus($"{_sessions.Count} session(s) found"); @@ -199,10 +271,10 @@ public class OnlineSessionManager : MonoBehaviour private async void JoinAsync(string sessionId) { if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return; - await RunSessionOpAsync("Join", async () => + await RunSessionOpAsync("Join", true, async () => { SetStatus("Joining session…"); - _session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId); + _session = await Timed(MultiplayerService.Instance.JoinSessionByIdAsync(sessionId), LeaveIfDisplaced); SetStatus($"Joined \"{_session.Name}\""); }); }