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:
1 parent
a59a5e53ab
commit
3e6191ead9
1 file changed
+82
-10
@@ -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}\"");
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user