Add timeout to relay session operations

Relay host/join/refresh/sign-in could hang forever: none of the UGS
Sessions 2.2.4 calls accept a CancellationToken, so a stuck op left _busy
true and locked the whole browser UI with no recovery.

Wrap every hangable call in a Timed() helper (Task.WhenAny + Task.Delay,
the only option the API allows) driven by a new connectTimeoutSeconds
inspector field (default 25s, 0 = wait forever). On timeout the status is
reported, _busy is cleared and the event-driven UI recovers immediately.

Guard the two ways a late/partial op can corrupt shared state:
- LeaveIfDisplaced: an aborted CreateSessionAsync keeps running and may
  materialise a ghost host seconds later; leave it, but only when the user
  has no tracked session (_session == null), else it would tear down the
  shared NetworkManager and kill a fresh host.
- Partial-start shutdown: only for connection-owning ops (host/join), so a
  refresh timeout during hosting can't shut down the user's own host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-07-02 20:09:28 +02:00
1 parent a59a5e53ab
commit 3e6191ead9
1 file changed
+82 -10
+82 -10
View File
@@ -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<ISessionInfo> _sessions = new List<ISessionInfo>();
@@ -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<System.Threading.Tasks.Task> 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<System.Threading.Tasks.Task> 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<T> Timed<T>(Task<T> op, Func<T, Task> 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<T>(Task<T> op, Func<T, Task> 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<ISessionInfo>(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}\"");
});
}