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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Unity.Netcode;
|
using Unity.Netcode;
|
||||||
using Unity.Services.Authentication;
|
using Unity.Services.Authentication;
|
||||||
using Unity.Services.Core;
|
using Unity.Services.Core;
|
||||||
@@ -34,6 +36,9 @@ public class OnlineSessionManager : MonoBehaviour
|
|||||||
[Tooltip("Maximum players per hosted session (host included).")]
|
[Tooltip("Maximum players per hosted session (host included).")]
|
||||||
public int maxPlayers = 8;
|
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 NetworkManager _net;
|
||||||
private ISession _session; // active session (host or client); null when not in one
|
private ISession _session; // active session (host or client); null when not in one
|
||||||
private List<ISessionInfo> _sessions = new List<ISessionInfo>();
|
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.
|
// already have separate caches; this only matters for same-machine clone testing.
|
||||||
var options = new InitializationOptions();
|
var options = new InitializationOptions();
|
||||||
options.SetProfile(ProfileForThisInstall());
|
options.SetProfile(ProfileForThisInstall());
|
||||||
await UnityServices.InitializeAsync(options);
|
await Timed(UnityServices.InitializeAsync(options));
|
||||||
}
|
}
|
||||||
if (!AuthenticationService.Instance.IsSignedIn)
|
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
|
// 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
|
// Shared busy/sign-in/error skeleton around one session operation; the body carries the
|
||||||
// op-specific statuses and state.
|
// op-specific statuses and state. ownsConnection = the op starts NGO (host/join), so a timeout that
|
||||||
private async System.Threading.Tasks.Task RunSessionOpAsync(string failLabel, Func<System.Threading.Tasks.Task> body)
|
// 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);
|
SetBusy(true);
|
||||||
try
|
try
|
||||||
@@ -159,6 +165,15 @@ public class OnlineSessionManager : MonoBehaviour
|
|||||||
await EnsureSignedInAsync();
|
await EnsureSignedInAsync();
|
||||||
await body();
|
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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
SetStatus($"{failLabel} failed: {e.Message}");
|
SetStatus($"{failLabel} failed: {e.Message}");
|
||||||
@@ -167,10 +182,67 @@ public class OnlineSessionManager : MonoBehaviour
|
|||||||
finally { SetBusy(false); }
|
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()
|
private async void HostAsync()
|
||||||
{
|
{
|
||||||
if (_busy || IsConnected) return;
|
if (_busy || IsConnected) return;
|
||||||
await RunSessionOpAsync("Host", async () =>
|
await RunSessionOpAsync("Host", true, async () =>
|
||||||
{
|
{
|
||||||
SetStatus("Creating session…");
|
SetStatus("Creating session…");
|
||||||
var options = new SessionOptions
|
var options = new SessionOptions
|
||||||
@@ -178,7 +250,7 @@ public class OnlineSessionManager : MonoBehaviour
|
|||||||
Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(),
|
Name = string.IsNullOrWhiteSpace(_hostName) ? "Chameleon game" : _hostName.Trim(),
|
||||||
MaxPlayers = Mathf.Max(2, maxPlayers)
|
MaxPlayers = Mathf.Max(2, maxPlayers)
|
||||||
}.WithRelayNetwork();
|
}.WithRelayNetwork();
|
||||||
_session = await MultiplayerService.Instance.CreateSessionAsync(options);
|
_session = await Timed(MultiplayerService.Instance.CreateSessionAsync(options), LeaveIfDisplaced);
|
||||||
SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})");
|
SetStatus($"Hosting \"{_session.Name}\" (code {_session.Code})");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -186,10 +258,10 @@ public class OnlineSessionManager : MonoBehaviour
|
|||||||
private async void RefreshAsync()
|
private async void RefreshAsync()
|
||||||
{
|
{
|
||||||
if (_busy) return;
|
if (_busy) return;
|
||||||
await RunSessionOpAsync("Refresh", async () =>
|
await RunSessionOpAsync("Refresh", false, async () =>
|
||||||
{
|
{
|
||||||
SetStatus("Querying sessions…");
|
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);
|
_sessions = new List<ISessionInfo>(results.Sessions);
|
||||||
SessionsChanged?.Invoke();
|
SessionsChanged?.Invoke();
|
||||||
SetStatus($"{_sessions.Count} session(s) found");
|
SetStatus($"{_sessions.Count} session(s) found");
|
||||||
@@ -199,10 +271,10 @@ public class OnlineSessionManager : MonoBehaviour
|
|||||||
private async void JoinAsync(string sessionId)
|
private async void JoinAsync(string sessionId)
|
||||||
{
|
{
|
||||||
if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return;
|
if (_busy || IsConnected || string.IsNullOrEmpty(sessionId)) return;
|
||||||
await RunSessionOpAsync("Join", async () =>
|
await RunSessionOpAsync("Join", true, async () =>
|
||||||
{
|
{
|
||||||
SetStatus("Joining session…");
|
SetStatus("Joining session…");
|
||||||
_session = await MultiplayerService.Instance.JoinSessionByIdAsync(sessionId);
|
_session = await Timed(MultiplayerService.Instance.JoinSessionByIdAsync(sessionId), LeaveIfDisplaced);
|
||||||
SetStatus($"Joined \"{_session.Name}\"");
|
SetStatus($"Joined \"{_session.Name}\"");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user