diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs index ad5aeb0..493c3d6 100644 --- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs +++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs @@ -134,11 +134,19 @@ Task StrobeSmartSwitchAsync(ulong entityId, Task GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default); - /// Gets the static map dimensions for grid-reference rendering, or null on failure/timeout. + /// + /// Gets the whole map in one round trip: dimensions, monuments and the base-map JPEG. Throws on failure. + /// + /// + /// Deliberately one call rather than three. Rust+ answers dimensions, monuments and the image from a + /// single GetMap response that always carries the ~683 KB JPEG, so a per-purpose accessor would + /// download the entire map to read a couple of integers. Callers must cache the result for the connected + /// window (see ServerMapWindowCache) rather than calling this per read. + /// /// How long to wait for the response. /// A cancellation token. - /// The map dimensions, or null on failure/timeout. - Task GetMapDimensionsAsync(TimeSpan timeout, CancellationToken cancellationToken = default); + /// The map snapshot. + Task GetServerMapAsync(TimeSpan timeout, CancellationToken cancellationToken = default); /// Gets the world size and seed from server info, or null when unavailable. /// The per-call timeout. @@ -146,19 +154,6 @@ Task GetMapMarkersAsync(TimeSpan timeout, /// The world snapshot, or null. Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default); - /// Gets the map monuments (for locating oil rigs). Throws on failure. - /// How long to wait for the response. - /// A cancellation token. - /// The map monuments (token + position). - Task> GetMonumentsAsync(TimeSpan timeout, - CancellationToken cancellationToken = default); - - /// Gets the base map image (JPEG bytes), or null on failure/unavailable. - /// How long to wait for the response. - /// A cancellation token. - /// The base-map JPEG bytes, or null on failure/unavailable. - Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default); - /// Raised for every in-game team chat line received on this socket. event EventHandler? TeamMessageReceived; diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs index 2be6961..3f67bb8 100644 --- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs +++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs @@ -94,20 +94,13 @@ public Task GetMapMarkersAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult(MapMarkersSnapshot.Empty); - public Task GetMapDimensionsAsync(TimeSpan timeout, + public Task GetServerMapAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => - Task.FromResult(null); + Task.FromResult(new ServerMapSnapshot(null, [], null)); public Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult(null); - public Task> GetMonumentsAsync(TimeSpan timeout, - CancellationToken cancellationToken = default) => - Task.FromResult>([]); - - public Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => - Task.FromResult(null); - public event EventHandler? TeamMessageReceived { add { _ = value; } @@ -631,102 +624,33 @@ public async Task GetMapMarkersAsync( return new MapMarkersSnapshot(markers, MapVendingMachines(data.VendingMachineMarkers)); } - public async Task GetMapDimensionsAsync( + public async Task GetServerMapAsync( TimeSpan timeout, CancellationToken cancellationToken = default) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(timeout); - try - { - // CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task>. - // ServerMap has Nullable Width/Height, Nullable OceanMargin, JpgImage, Monuments. - // 2a uses dims only; if any dim is null, treat the whole thing as unavailable (return null). - var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) - .ConfigureAwait(false); - if (!response.IsSuccess || response.Data is null) - { - return null; - } - - var map = response.Data; - if (map.Width is not { } width || map.Height is not { } height || map.OceanMargin is not { } margin) - { - return null; - } - - var infoResponse = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) - .ConfigureAwait(false); - if (!infoResponse.IsSuccess || infoResponse.Data?.MapSize is not { } worldSize) - { - return null; - } - return new MapDimensions(width, height, margin, worldSize); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return null; - } -#pragma warning disable CA1031 // Broad catch: any map-query failure maps to null; never surface a token/secret. - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) -#pragma warning restore CA1031 - { - LogQueryFailed(_logger, ex); - return null; - } - } - - public async Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(timeout); - try - { - var response = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) - .ConfigureAwait(false); - if (!response.IsSuccess || response.Data is not { MapSize: { } size, Seed: { } seed }) - { - return null; - } - - return new WorldSnapshot(size, seed); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return null; - } -#pragma warning disable CA1031 // Broad catch: any map-query failure maps to null; never surface a token/secret. - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) -#pragma warning restore CA1031 - { - LogQueryFailed(_logger, ex); - return null; - } - } - - public async Task> GetMonumentsAsync( - TimeSpan timeout, - CancellationToken cancellationToken = default) - { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(timeout); // CONFIRMED (2.0.0-beta.1): GetMapAsync returns Task>. - // ServerMap.Monuments is List with Name (= protobuf token, e.g. "oil_rig_small"), - // Nullable X/Y. We surface (token, x, y) and skip monuments with incomplete coordinates. + // ServerMap carries Nullable Width/Height, Nullable OceanMargin, Monuments + // (List with Name = protobuf token, e.g. "oil_rig_small", and Nullable + // X/Y) and JpgImage (raw JPEG bytes). One response, one ~683 KB download: read all three here. + // The world size that completes MapDimensions lives on GetInfo, which is cheap and fails + // independently, so it is resolved separately rather than being able to sink this whole read. var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) .ConfigureAwait(false); if (!response.IsSuccess || response.Data is null) { - // Name the reason: the caller only ever sees this message, and "rate_limit" (three heavy - // GetMap calls land back-to-back on connect) reads very differently from "no_map". + // Name the reason: the caller only ever sees this message, and "rate_limit" reads very + // differently from "no_map". throw new InvalidOperationException( "GetMap returned no data; error: " + (response.Error?.Code.ToString() ?? "none") + " (" + (response.Error?.Message ?? "no message") + ")."); } + var map = response.Data; var monuments = new List(); - foreach (var m in response.Data.Monuments ?? []) + foreach (var m in map.Monuments ?? []) { if (m.Name is null || m.X is not { } x || m.Y is not { } y) { @@ -736,19 +660,26 @@ public async Task> GetMonumentsAsync( monuments.Add(new MonumentSnapshot(m.Name, x, y)); } - return monuments; + var geometry = map.Width is { } width && map.Height is { } height && map.OceanMargin is { } margin + ? new MapGeometry(width, height, margin) + : null; + return new ServerMapSnapshot(geometry, monuments, map.JpgImage); } - public async Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + public async Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutCts.CancelAfter(timeout); try { - // CONFIRMED (2.0.0-beta.1): GetMapAsync -> Response; ServerMap.JpgImage is byte[] (raw JPEG bytes). - var response = await _rustPlus.GetMapAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) + var response = await _rustPlus.GetInfoAsync(timeoutCts.Token).WaitAsync(timeoutCts.Token) .ConfigureAwait(false); - return response.IsSuccess ? response.Data?.JpgImage : null; + if (!response.IsSuccess || response.Data is not { MapSize: { } size, Seed: { } seed }) + { + return null; + } + + return new WorldSnapshot(size, seed); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { diff --git a/src/RustPlusBot.Features.Connections/Listening/ServerMapSnapshot.cs b/src/RustPlusBot.Features.Connections/Listening/ServerMapSnapshot.cs new file mode 100644 index 0000000..00afc67 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Listening/ServerMapSnapshot.cs @@ -0,0 +1,27 @@ +using RustPlusBot.Abstractions.Connections; + +namespace RustPlusBot.Features.Connections.Listening; + +/// +/// Everything Rust+ returns for one map query. The GetMap endpoint answers the map geometry, the +/// monument list and the base-map JPEG in a single response, so splitting them into separate calls makes +/// each reader pay for the whole ~683 KB image. This record keeps them together, letting a connected window +/// resolve the map with one round trip and serve every reader from it. +/// +/// The map's pixel geometry, or null when the response was incomplete. +/// The map monuments (token + position); empty when the server sends none. +/// The base-map JPEG bytes, or null when the server sends no image. +internal sealed record ServerMapSnapshot( + MapGeometry? Geometry, + IReadOnlyList Monuments, + byte[]? JpgImage); + +/// +/// The pixel geometry of the base-map image. Deliberately not : that also carries +/// the world size, which GetMap does not return. Pairing them here would tie the expensive map +/// download to a GetInfo round trip that fails independently of it. +/// +/// Image width in pixels. +/// Image height in pixels. +/// Ocean border baked into the image, in pixels per side. +internal sealed record MapGeometry(uint Width, uint Height, int OceanMargin); diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs index 7d67875..41f19dd 100644 --- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs +++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs @@ -282,8 +282,21 @@ public async Task PromoteToLeaderAsync( return null; } - return await live.Connection.GetMapImageAsync(_options.HeartbeatTimeout, cancellationToken) - .ConfigureAwait(false); + try + { + return await live.Map.GetImageAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } +#pragma warning disable CA1031 // Broad catch: this seam promises degradation, so a failed fetch is "no image". + catch (Exception ex) +#pragma warning restore CA1031 + { + LogMapQueryFailed(logger, ex, serverId); + return null; + } } /// @@ -297,26 +310,24 @@ public async Task PromoteToLeaderAsync( return null; } - // Serve from the connected window's resolved dimensions. On the Rust+ socket this call is - // GetMap, which downloads the entire map JPEG to read width/height — and the callers (#map - // compose, the team panel renderer) re-render several times a minute, so fetching per read - // moved hundreds of MB an hour. Dimensions are fixed for a wipe; the window is torn down and - // re-resolved on reconnect, which is exactly when they can change. - if (live.Dimensions.Value is { } cached) + try { - return cached; + return await live.Map.GetDimensionsAsync(cancellationToken).ConfigureAwait(false); } - - // Not resolved yet (a read that beat the marker poll's first fetch): fetch once and publish it - // to the window so the next reader is served from memory. - var fetched = await live.Connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, cancellationToken) - .ConfigureAwait(false); - if (fetched is not null) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - live.Dimensions.Value = fetched; + throw; + } +#pragma warning disable CA1031 // Broad catch: this seam promises degradation, so a failed fetch is "no dimensions". + catch (Exception ex) +#pragma warning restore CA1031 + { + // The map fetch throws on a failed GetMap (rate limit, no map, slow endpoint). Callers here are + // render paths — the team panel, the #map composer — that must degrade to no grid reference, + // never fault: an escaping exception tears down the consuming loop for the rest of the process. + LogMapQueryFailed(logger, ex, serverId); + return null; } - - return fetched; } /// @@ -344,8 +355,7 @@ public async Task> GetMonumentsAsync( try { - return await live.Connection.GetMonumentsAsync(_options.HeartbeatTimeout, cancellationToken) - .ConfigureAwait(false); + return await live.Map.GetMonumentsAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -637,13 +647,13 @@ void OnClanChanged(object? sender, ClanProbeResult probe) #pragma warning restore RCS1163 var tracker = new TeamStateTracker(); - var dims = new DimensionsHolder(); + var map = new ServerMapWindowCache(connection, _options.HeartbeatTimeout); #pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape. void OnTeamChanged(object? sender, TeamInfoSnapshot snapshot) { // Fire-and-forget: PublishTeamStateAsync catches everything internally. - _ = PublishTeamStateAsync(key, tracker, dims, snapshot); + _ = PublishTeamStateAsync(key, tracker, map, snapshot); } #pragma warning restore RCS1163 @@ -653,7 +663,7 @@ void OnTeamChanged(object? sender, TeamInfoSnapshot snapshot) connection.ClanMessageReceived += OnClanMessage; connection.ClanChanged += OnClanChanged; connection.TeamChanged += OnTeamChanged; - _liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker, dims); + _liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker, map); await PrimeDevicesAsync(key, connection, ct).ConfigureAwait(false); // Probe once on connect so clan state is correct after a bot restart, not only after the @@ -663,11 +673,11 @@ void OnTeamChanged(object? sender, TeamInfoSnapshot snapshot) await PublishClanStateAsync(key, clanProbe).ConfigureAwait(false); using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, dims, pollCts.Token), + var markerPoll = Task.Run(() => PollMarkersAsync(key, connection, map, pollCts.Token), CancellationToken.None); var reachabilityPoll = Task.Run(() => PollReachabilityAsync(key, connection, pollCts.Token), CancellationToken.None); - var teamPoll = Task.Run(() => PollTeamAsync(key, connection, tracker, dims, pollCts.Token), + var teamPoll = Task.Run(() => PollTeamAsync(key, connection, tracker, map, pollCts.Token), CancellationToken.None); // Race the heartbeat against a liveness watchdog: the Rust+ library raises no event when the SERVER // closes the socket, so without the watchdog a silent drop goes unnoticed until the next heartbeat @@ -770,17 +780,15 @@ await PublishStatusAsync(key, ConnectionStatus.Connected, beat.PlayerCount, cred private async Task PollMarkersAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, - DimensionsHolder dims, + ServerMapWindowCache map, CancellationToken ct) { - // Fetch map dimensions and oil-rig positions here, off the critical connect path: these are the two - // heavy full-map downloads, and on a degraded map endpoint they can stall for seconds. Doing them in - // this background poll means a slow map no longer delays the connection going live (heartbeat + chat - // relay start immediately); marker/rig detection simply activates once these resolve. Both degrade - // safely on timeout (dims -> null, rigs -> empty) without ending the poll. - var localDims = await connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); - dims.Value = localDims; - var rigs = await GetRigPositionsAsync(key.Server, connection, ct).ConfigureAwait(false); + // Resolve the window's map here, off the critical connect path: it is one heavy full-map download, + // and on a degraded map endpoint it can stall for seconds. Doing it in this background poll means a + // slow map no longer delays the connection going live, because the heartbeat and the chat relay + // start immediately, and marker/rig detection activates once it resolves. A timeout degrades safely — no dimensions + // and no rigs — without ending the poll. + var rigs = await GetRigPositionsAsync(key.Server, map, ct).ConfigureAwait(false); IReadOnlyList? previous = null; var rigsInRadius = new HashSet(); @@ -789,6 +797,11 @@ private async Task PollMarkersAsync( var anyCh47 = false; try { + // Read per poll, not once before the loop: the map itself is cached for the window, but the + // world size that completes the dimensions comes from GetInfo and can fail transiently. A + // single read up front would latch that failure and drop grid references for the whole + // connection. + var localDims = await map.GetDimensionsAsync(ct).ConfigureAwait(false); var snapshot = await connection.GetMapMarkersAsync(_options.HeartbeatTimeout, ct) .ConfigureAwait(false); var current = snapshot.Markers; @@ -844,13 +857,13 @@ await eventBus.PublishAsync( /// The (guild, server) routing key. /// The live connection to poll. /// The shared AFK/online tracker whose baseline this poll also primes/diffs. - /// The connected window's dimensions holder, read for the published event. + /// The connected window's map cache, peeked for the published event's dimensions. /// Cancels when the connected window ends. private async Task PollTeamAsync( (ulong Guild, Guid Server) key, IRustServerConnection connection, TeamStateTracker tracker, - DimensionsHolder dims, + ServerMapWindowCache map, CancellationToken ct) { while (!ct.IsCancellationRequested) @@ -858,7 +871,7 @@ private async Task PollTeamAsync( try { var team = await connection.GetTeamInfoAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); - await PublishTeamStateAsync(key, tracker, dims, team).ConfigureAwait(false); + await PublishTeamStateAsync(key, tracker, map, team).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -1077,12 +1090,14 @@ await eventBus.PublishAsync( private async Task> GetRigPositionsAsync( Guid serverId, - IRustServerConnection connection, + ServerMapWindowCache map, CancellationToken ct) { try { - var monuments = await connection.GetMonumentsAsync(_options.HeartbeatTimeout, ct).ConfigureAwait(false); + // Through the window cache, not the socket: this is the one map fetch of the connected window, + // and it also publishes the dimensions and the base-map image every other reader needs. + var monuments = await map.GetMonumentsAsync(ct).ConfigureAwait(false); var rigs = new List(); foreach (var m in monuments) { @@ -1303,7 +1318,7 @@ private async Task PublishClanStateAsync((ulong Guild, Guid Server) key, ClanPro private async Task PublishTeamStateAsync( (ulong Guild, Guid Server) key, TeamStateTracker tracker, - DimensionsHolder dims, + ServerMapWindowCache map, TeamInfoSnapshot? snapshot) { if (_disposed) @@ -1319,7 +1334,7 @@ private async Task PublishTeamStateAsync( return; } - var evt = new PlayerStateChangedEvent(key.Guild, key.Server, dims.Value, transitions); + var evt = new PlayerStateChangedEvent(key.Guild, key.Server, map.DimensionsOrNull, transitions); // Supervisor-wide shutdown token, not a per-connection ct: a pushed team change should publish // regardless of one connection's reconnect cycle (mirrors the chat/clan handlers). await eventBus.PublishAsync(evt, _shutdown.Token).ConfigureAwait(false); @@ -1651,6 +1666,10 @@ await eventBus.PublishAsync( Message = "Querying monuments for server {ServerId} failed; returning no monuments for this call.")] private static partial void LogMonumentsQueryFailed(ILogger logger, Exception exception, Guid serverId); + [LoggerMessage(Level = LogLevel.Warning, + Message = "Querying the map for server {ServerId} failed; degrading this call to no result.")] + private static partial void LogMapQueryFailed(ILogger logger, Exception exception, Guid serverId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Relaying a message to team chat for server {ServerId} failed.")] private static partial void LogSendFailed(ILogger logger, Exception exception, Guid serverId); @@ -1712,21 +1731,7 @@ private sealed record LiveSocket( IRustServerConnection Connection, ulong ActiveSteamId, TeamStateTracker Tracker, - DimensionsHolder Dimensions); - - /// Mutable, thread-visible holder for the per-connected-window map dimensions. The marker poll - /// resolves these once off the critical path; the team push handler and team poll read them (possibly - /// null before resolution — PlayerStateChangedEvent tolerates a null and renders without a grid ref). - private sealed class DimensionsHolder - { - private volatile MapDimensions? _value; - - public MapDimensions? Value - { - get => _value; - set => _value = value; - } - } + ServerMapWindowCache Map); private sealed class Handle(CancellationTokenSource cts, Task runTask) : IAsyncDisposable { diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ServerMapWindowCache.cs b/src/RustPlusBot.Features.Connections/Supervisor/ServerMapWindowCache.cs new file mode 100644 index 0000000..8c74e49 --- /dev/null +++ b/src/RustPlusBot.Features.Connections/Supervisor/ServerMapWindowCache.cs @@ -0,0 +1,106 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Connections.Listening; + +namespace RustPlusBot.Features.Connections.Supervisor; + +/// +/// Holds the map for one connected window. Rust+ serves the map geometry, the monuments and the base-map +/// JPEG from a single GetMap response that always ships the whole ~683 KB image, while the readers — +/// the #map composer, the team panel renderer, oil-rig detection — re-read several times a minute. Resolving +/// once per window and serving from memory turns tens of megabytes an hour into one fetch. +/// +/// +/// +/// Correctness rests on the window boundary: the map is fixed for a wipe, and the window is torn down and +/// re-resolved on reconnect — exactly when a new map can appear. +/// +/// +/// The window therefore holds the JPEG for as long as it is connected, whether or not the guild uses #map. +/// That is one bounded ~683 KB array per connected server, released on disconnect; the alternative — fetching +/// the image separately when #map first asks — costs a second full map download per window. +/// +/// +/// The window's live socket. +/// The per-fetch timeout. +#pragma warning disable CA1001 // The gate is never awaited via AvailableWaitHandle, so SemaphoreSlim has +// nothing to dispose; making the window cache disposable would only push lifetime plumbing into the +// supervisor's teardown path for no benefit. +internal sealed class ServerMapWindowCache(IRustServerConnection connection, TimeSpan timeout) +#pragma warning restore CA1001 +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private volatile MapDimensions? _dimensions; + private volatile ServerMapSnapshot? _snapshot; + + /// + /// The resolved dimensions, or null while the window has not resolved them yet. A non-blocking peek for + /// readers that tolerate a miss (the team-state publishers render without a grid reference). + /// + public MapDimensions? DimensionsOrNull => _dimensions; + + /// Gets the map dimensions, resolving the window's map if needed. + /// A cancellation token. + /// The dimensions, or null while either half is unavailable. + public async Task GetDimensionsAsync(CancellationToken cancellationToken) + { + if (_dimensions is { } cached) + { + return cached; + } + + var geometry = (await ResolveAsync(cancellationToken).ConfigureAwait(false)).Geometry; + if (geometry is null) + { + return null; + } + + // The world size comes from GetInfo, not GetMap. It is a cheap call with an independent failure + // mode (a rate limit on the connect burst, say), so it is retried per read until it lands rather + // than latching "no dimensions" — and failing it never costs another map download. + var world = await connection.GetWorldAsync(timeout, cancellationToken).ConfigureAwait(false); + if (world is null) + { + return null; + } + + var dimensions = new MapDimensions(geometry.Width, geometry.Height, geometry.OceanMargin, world.WorldSize); + _dimensions = dimensions; + return dimensions; + } + + /// Gets the map monuments, resolving the window's map if needed. + /// A cancellation token. + /// The monuments (token + position). + public async Task> GetMonumentsAsync(CancellationToken cancellationToken) => + (await ResolveAsync(cancellationToken).ConfigureAwait(false)).Monuments; + + /// Gets the base-map JPEG, resolving the window's map if needed. + /// A cancellation token. + /// The JPEG bytes, or null when the server sends no image. + public async Task GetImageAsync(CancellationToken cancellationToken) => + (await ResolveAsync(cancellationToken).ConfigureAwait(false)).JpgImage; + + private async Task ResolveAsync(CancellationToken cancellationToken) + { + if (_snapshot is { } cached) + { + return cached; + } + + // Single-flight: on connect the marker poll and a #map compose can both arrive before either + // resolves, and each miss costs a full map download. Taken outside the try so a reader that never + // acquires the gate — a cancelled one, say — cannot release it on the way out. + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // A throw here leaves the window unresolved, so the next reader retries rather than inheriting + // a failure. Each reader fetches under its own token, so one cancelling never poisons another. + return _snapshot ??= await connection.GetServerMapAsync(timeout, cancellationToken) + .ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs index d384f80..139033b 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ConnectionSupervisorTests.cs @@ -234,17 +234,18 @@ public async Task Heartbeat_Unreachable_ReconnectsAndRecovers() } /// - /// A per-request timeout in a connected window (here the marker poll's oil-rig monuments fetch) surfaces - /// as an even though the connection token is not cancelled. It - /// must degrade rig detection for this window, NOT terminate the whole connection loop — otherwise a - /// transient slow map endpoint permanently kills the connection with no reconnect (the real-world bug). + /// A per-request timeout in a connected window (here the map fetch the marker poll issues for oil-rig + /// detection) surfaces as an even though the connection token is + /// not cancelled. It must degrade the window — no rigs, no grid references, no base map — NOT terminate + /// the whole connection loop, otherwise a transient slow map endpoint permanently kills the connection + /// with no reconnect (the real-world bug). /// [Fact] - public async Task Connect_MonumentsTimeout_DoesNotKillLoop_AndStillReconnects() + public async Task Connect_MapFetchTimeout_DoesNotKillLoop_AndStillReconnects() { var source = new FakeRustSocketSource(); source.EnqueueConnect(SocketConnectOutcome.Connected); - source.TimeoutOnMonumentsOnce(); // the marker poll's rig fetch times out on the FIRST connection only + source.TimeoutOnMapOnce(); // the window's map resolve times out on the FIRST connection only source.EnqueueHeartbeat(HeartbeatResult.Ok(2)); // first heartbeat -> Connected source.EnqueueHeartbeat(HeartbeatResult.Unreachable); // next heartbeat -> drop source.EnqueueConnect(SocketConnectOutcome.Connected); // reconnect @@ -473,7 +474,7 @@ public async Task Marker_added_on_a_later_poll_publishes_changed_event() } }, CancellationToken.None); - // FakeConnection default DimensionsResult is new(4000u, 4000u, 500, 4000u); assert those exact values. + // FakeConnection defaults are GeometryResult new(4000u, 4000u, 500) + World size 4000u. var expectedDims = new MapDimensions(4000u, 4000u, 500, WorldSize: 4000u); // Script polls before EnsureConnectionAsync so the marker script is in the connection before @@ -545,7 +546,7 @@ public async Task PollMarkers_PublishesObservedVendingMachines() Assert.Equal(10UL, observed.GuildId); Assert.Equal(serverId, observed.ServerId); - // FakeConnection default DimensionsResult is new(4000u, 4000u, 500, 4000u); assert WorldSize + // FakeConnection defaults give WorldSize 4000u (from World); assert WorldSize // threads through from dimensions to the published event. Assert.Equal(4000u, observed.WorldSize); var machine = Assert.Single(observed.Machines); diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs index 15e1c0a..7516cdb 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs @@ -28,8 +28,8 @@ internal sealed class FakeRustSocketSource : IRustSocketSource private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0); private ClanProbeResult? _pendingClanProbe; + private bool _pendingMapTimeout; private IReadOnlyList _pendingMonuments = []; - private bool _pendingMonumentsTimeout; private IReadOnlyList _pendingVendingMachines = []; /// Number of times has been called. Safe to read from any thread. @@ -67,10 +67,10 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p connection.MonumentsResult = _pendingMonuments; _pendingMonuments = []; - // Transfer any pre-staged monuments timeout so the marker poll's rig fetch throws for the NEXT - // connection only (mimicking a real per-request timeout, which surfaces as OperationCanceledException). - connection.MonumentsTimeout = _pendingMonumentsTimeout; - _pendingMonumentsTimeout = false; + // Transfer any pre-staged map timeout so the marker poll's rig fetch throws for the NEXT connection + // only (mimicking a real per-request timeout, which surfaces as OperationCanceledException). + connection.MapTimeout = _pendingMapTimeout; + _pendingMapTimeout = false; // Transfer any pre-staged vending machines so they are available before the supervisor's marker // poll reads them. Reset after transfer so the staging applies to the NEXT connection only. @@ -139,12 +139,12 @@ public void EnqueueMarkers(IReadOnlyList markers) => public void SetMonuments(IReadOnlyList monuments) => _pendingMonuments = monuments; /// - /// Makes the NEXT connection's throw + /// Makes the NEXT connection's throw /// , simulating a per-request timeout (the outer connection /// token is NOT cancelled). The supervisor issues this fetch from its background marker poll, not the /// connect path. Applies to the next connection only. Call before . /// - public void TimeoutOnMonumentsOnce() => _pendingMonumentsTimeout = true; + public void TimeoutOnMapOnce() => _pendingMapTimeout = true; /// /// Pre-stages the vending-machine set returned as the vending half of every @@ -215,8 +215,8 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke : IRustServerConnection { private readonly ConcurrentQueue> _markerScript = new(); - private int _dimensionsCallCount; private IReadOnlyList _lastMarkers = []; + private int _mapFetchCount; private bool _markerScriptStarted; /// Gets the messages sent via . @@ -290,29 +290,32 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke /// public IReadOnlyList VendingResult { get; set; } = []; - /// The dimensions returned by . Defaults to a non-null snapshot. - public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500, 4000u); + /// The geometry half of . Defaults to a non-null snapshot. + public MapGeometry? GeometryResult { get; set; } = new(4000u, 4000u, 500); - /// Number of times has been called. Each call is a full - /// map download on the real socket, so the query seam must serve repeat reads from cache. - public int DimensionsCallCount => Volatile.Read(ref _dimensionsCallCount); + /// Number of GetMap round trips this connection has issued. Dimensions, monuments and the + /// map image are all served by the one Rust+ GetMap endpoint, which answers with the whole map JPEG + /// (~683 KB), so every one of those calls costs a full map download on the real socket. Tests assert + /// on this to pin how much a connected window actually pulls off the wire. + public int MapFetchCount => Volatile.Read(ref _mapFetchCount); - /// The snapshot returned by . Defaults to null. - public WorldSnapshot? World { get; set; } + /// The snapshot returned by , which also completes the map + /// dimensions with the world size. Defaults to a snapshot matching . + public WorldSnapshot? World { get; set; } = new(4000u, 0u); - /// The monuments returned by . Defaults to empty. + /// The monuments half of . Defaults to empty. public IReadOnlyList MonumentsResult { get; set; } = []; - /// When true, throws - /// (a per-request timeout) instead of returning . - public bool MonumentsTimeout { get; set; } + /// When true, throws + /// (a per-request timeout) instead of answering. + public bool MapTimeout { get; set; } - /// When set, throws this instead of returning - /// — models the real socket's "GetMap returned no data" throw when - /// the Rust+ endpoint answers with an error (rate limit, no map, …). - public Exception? MonumentsFault { get; set; } + /// When set, throws this instead of answering — models the + /// real socket's "GetMap returned no data" throw when the Rust+ endpoint answers with an error + /// (rate limit, no map, …). + public Exception? MapFault { get; set; } - /// The bytes returned by . Defaults to null. + /// The image half of . Defaults to null. public byte[]? MapImageResult { get; set; } /// The probe result this fake returns; defaults to no clan. @@ -473,28 +476,19 @@ public Task GetMapMarkersAsync(TimeSpan timeout, new MapMarkersSnapshot(_markerScriptStarted ? _lastMarkers : MarkersResult, VendingResult)); } - public Task GetMapDimensionsAsync(TimeSpan timeout, + public Task GetServerMapAsync(TimeSpan timeout, CancellationToken cancellationToken = default) { - Interlocked.Increment(ref _dimensionsCallCount); - return Task.FromResult(DimensionsResult); + Interlocked.Increment(ref _mapFetchCount); + var fault = MapFault ?? (MapTimeout ? new OperationCanceledException() : null); + return fault is null + ? Task.FromResult(new ServerMapSnapshot(GeometryResult, MonumentsResult, MapImageResult)) + : Task.FromException(fault); } public Task GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => Task.FromResult(World); - public Task> GetMonumentsAsync(TimeSpan timeout, - CancellationToken cancellationToken = default) - { - var fault = MonumentsFault ?? (MonumentsTimeout ? new OperationCanceledException() : null); - return fault is null - ? Task.FromResult(MonumentsResult) - : Task.FromException>(fault); - } - - public Task GetMapImageAsync(TimeSpan timeout, CancellationToken cancellationToken = default) => - Task.FromResult(MapImageResult); - public ValueTask DisposeAsync() => ValueTask.CompletedTask; /// diff --git a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs index 22b9310..22c70b4 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; +using RustPlusBot.Abstractions.Connections; using RustPlusBot.Abstractions.Credentials; using RustPlusBot.Abstractions.Events; using RustPlusBot.Abstractions.Time; @@ -90,7 +91,12 @@ private static async Task SeedServerWithActiveAsync(ServiceProvider provid [Fact] public async Task GetMapImage_ReturnsBytes_WhenConnected() { - var source = new FakeRustSocketSource(); + // Staged before connect: the connected window resolves the map once, on the marker poll's first + // pass, so anything set afterwards would lose the race against that fetch. + var source = new FakeRustSocketSource + { + LastConnectionSetup = c => c.MapImageResult = [1, 2, 3], + }; var (provider, supervisor) = CreateHarness(source); await using var _ = provider; var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); @@ -99,10 +105,6 @@ public async Task GetMapImage_ReturnsBytes_WhenConnected() await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); - source.LastConnection!.MapImageResult = - [ - 1, 2, 3 - ]; var image = await supervisor.GetMapImageAsync(10UL, serverId, cts.Token); Assert.Equal(new byte[] @@ -126,8 +128,8 @@ public async Task GetMapDimensions_ServesRepeatReadsFromTheConnectedWindow_Witho // The marker poll resolves dimensions once for the connected window. var connection = source.LastConnection!; - await WaitUntilAsync(() => connection.DimensionsCallCount > 0, cts.Token); - var afterConnect = connection.DimensionsCallCount; + await WaitUntilAsync(() => connection.MapFetchCount > 0, cts.Token); + var afterConnect = connection.MapFetchCount; for (var i = 0; i < 5; i++) { @@ -136,7 +138,66 @@ public async Task GetMapDimensions_ServesRepeatReadsFromTheConnectedWindow_Witho // On the real socket each fetch downloads the whole map JPEG just to read width/height, and the // team panel re-renders several times a minute. Repeat reads must not touch the socket. - Assert.Equal(afterConnect, connection.DimensionsCallCount); + Assert.Equal(afterConnect, connection.MapFetchCount); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task GetMonuments_ServesRepeatReadsFromTheConnectedWindow_WithoutRefetching() + { + var source = new FakeRustSocketSource(); + source.SetMonuments([new MonumentSnapshot("large_oil_rig", 100f, 200f)]); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + // The marker poll resolves monuments once for the connected window, on its way to the oil rigs. + var connection = source.LastConnection!; + await WaitUntilAsync(() => connection.MapFetchCount > 0, cts.Token); + var afterConnect = connection.MapFetchCount; + + for (var i = 0; i < 5; i++) + { + Assert.NotEmpty(await supervisor.GetMonumentsAsync(10UL, serverId, cts.Token)); + } + + // Monuments come from GetMap, which ships the whole map JPEG, and #map recomposes every 30s. + // Repeat reads must not touch the socket: monuments are fixed for a wipe. + Assert.Equal(afterConnect, connection.MapFetchCount); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task ConnectedWindow_IssuesASingleMapFetch_ForDimensionsMonumentsAndImage() + { + var source = new FakeRustSocketSource + { + LastConnectionSetup = c => c.MapImageResult = [1, 2, 3], + }; + source.SetMonuments([new MonumentSnapshot("large_oil_rig", 100f, 200f)]); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + var connection = source.LastConnection!; + await WaitUntilAsync(() => connection.MapFetchCount > 0, cts.Token); + + Assert.NotNull(await supervisor.GetMapDimensionsAsync(10UL, serverId, cts.Token)); + Assert.NotEmpty(await supervisor.GetMonumentsAsync(10UL, serverId, cts.Token)); + Assert.NotNull(await supervisor.GetMapImageAsync(10UL, serverId, cts.Token)); + + // Rust+ answers dimensions, monuments and the JPEG from one GetMap response, so a connected window + // has no reason to pull the map more than once: it is fixed for the wipe, and a reconnect (which is + // exactly when a new map can appear) tears the window down. + Assert.Equal(1, connection.MapFetchCount); await supervisor.StopAllAsync(); } diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerMapWindowCacheTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerMapWindowCacheTests.cs new file mode 100644 index 0000000..4f13c37 --- /dev/null +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerMapWindowCacheTests.cs @@ -0,0 +1,144 @@ +using RustPlusBot.Abstractions.Connections; +using RustPlusBot.Features.Connections.Supervisor; +using RustPlusBot.Features.Connections.Tests.Fakes; + +namespace RustPlusBot.Features.Connections.Tests; + +/// +/// Covers the per-connected-window map cache in isolation. Rust+ serves dimensions, monuments and the map +/// JPEG from one GetMap response (~683 KB), so the number of fetches this class issues is the whole point: +/// every extra one is a full map download. +/// +public sealed class ServerMapWindowCacheTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static FakeRustSocketSource.FakeConnection CreateConnection() + { + var source = new FakeRustSocketSource(); + source.Create("1.1.1.1", 28015, 555UL, "token"); + var connection = source.LastConnection!; + connection.MonumentsResult = [new MonumentSnapshot("large_oil_rig", 1f, 2f)]; + connection.MapImageResult = [1, 2, 3]; + return connection; + } + + private static ServerMapWindowCache CreateCache(FakeRustSocketSource.FakeConnection connection) => + new(connection, Timeout); + + [Fact] + public async Task Resolves_once_and_serves_repeat_reads_from_memory() + { + var connection = CreateConnection(); + var cache = CreateCache(connection); + + for (var i = 0; i < 5; i++) + { + Assert.NotNull(await cache.GetDimensionsAsync(CancellationToken.None)); + Assert.NotEmpty(await cache.GetMonumentsAsync(CancellationToken.None)); + Assert.NotNull(await cache.GetImageAsync(CancellationToken.None)); + } + + Assert.Equal(1, connection.MapFetchCount); + } + + [Fact] + public async Task Concurrent_readers_collapse_to_a_single_fetch() + { + var connection = CreateConnection(); + var cache = CreateCache(connection); + + var readers = Enumerable.Range(0, 8) + .Select(_ => Task.Run(() => cache.GetMonumentsAsync(CancellationToken.None))) + .ToArray(); + var results = await Task.WhenAll(readers); + + Assert.All(results, r => Assert.NotEmpty(r)); + Assert.Equal(1, connection.MapFetchCount); + } + + [Fact] + public async Task Does_not_cache_a_failed_fetch() + { + var connection = CreateConnection(); + connection.MapFault = new InvalidOperationException("GetMap returned no data."); + var cache = CreateCache(connection); + + await Assert.ThrowsAsync(() => cache.GetMonumentsAsync(CancellationToken.None)); + + connection.MapFault = null; + Assert.NotEmpty(await cache.GetMonumentsAsync(CancellationToken.None)); + } + + [Fact] + public async Task Cancelling_one_reader_leaves_the_window_resolvable() + { + var connection = CreateConnection(); + var cache = CreateCache(connection); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => cache.GetMonumentsAsync(cancelled.Token)); + + // The single-flight gate is taken outside the try/finally, so a reader that never acquires it must + // not leave the window wedged for everyone else. + Assert.NotEmpty(await cache.GetMonumentsAsync(CancellationToken.None)); + } + + [Fact] + public async Task DimensionsOrNull_peeks_without_fetching() + { + var connection = CreateConnection(); + var cache = CreateCache(connection); + + Assert.Null(cache.DimensionsOrNull); + Assert.Equal(0, connection.MapFetchCount); + + await cache.GetDimensionsAsync(CancellationToken.None); + + Assert.NotNull(cache.DimensionsOrNull); + } + + [Fact] + public async Task Retries_the_world_size_without_re_downloading_the_map() + { + var connection = CreateConnection(); + connection.World = null; // GetInfo answered with an error — a rate limit on the connect burst, say. + var cache = CreateCache(connection); + + Assert.Null(await cache.GetDimensionsAsync(CancellationToken.None)); + + // The world size comes from GetInfo, which carries no JPEG, so a transient failure there must not + // latch "no dimensions" for the window — that would leave #map dark until the next reconnect — and + // must not cost a second full map download to recover from either. + connection.World = new WorldSnapshot(4000u, 1u); + Assert.NotNull(await cache.GetDimensionsAsync(CancellationToken.None)); + Assert.Equal(1, connection.MapFetchCount); + } + + [Fact] + public async Task Serves_monuments_and_the_image_even_when_the_world_size_is_unavailable() + { + var connection = CreateConnection(); + connection.World = null; + var cache = CreateCache(connection); + + // A degraded GetInfo costs grid references, nothing else: the map response itself was fine. + Assert.NotEmpty(await cache.GetMonumentsAsync(CancellationToken.None)); + Assert.NotNull(await cache.GetImageAsync(CancellationToken.None)); + } + + [Fact] + public async Task GetImageAsync_returns_the_same_bytes_on_every_read() + { + var connection = CreateConnection(); + var cache = CreateCache(connection); + + var first = await cache.GetImageAsync(CancellationToken.None); + + // The query seam this backs is documented as an ordinary read. Handing the JPEG out once and null + // afterwards would make #map depend on the base-map cache never being evicted mid-window. + Assert.Same(first, await cache.GetImageAsync(CancellationToken.None)); + Assert.Equal(1, connection.MapFetchCount); + } +} diff --git a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs index 4a20e23..001caef 100644 --- a/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs +++ b/tests/RustPlusBot.Features.Connections.Tests/ServerQueryTests.cs @@ -263,7 +263,9 @@ public async Task GetMonumentsAsync_returns_empty_when_the_endpoint_fails() { // The query seam promises degradation ("or an empty list"), but the socket-level call throws on a // failed GetMap. Left unguarded, that throw escapes into the map pipeline and kills its event loop. - var source = new FakeRustSocketSource(); + // Staged before connect: the connected window caches the map on its first successful resolve, so a + // fault applied afterwards would be served from that cache and this would pass vacuously. + var source = FaultingSource(); var (provider, supervisor) = CreateHarness(source); await using var _ = provider; var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); @@ -271,7 +273,6 @@ public async Task GetMonumentsAsync_returns_empty_when_the_endpoint_fails() using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); - source.LastConnection!.MonumentsFault = new InvalidOperationException("GetMap returned no data."); var result = await supervisor.GetMonumentsAsync(10UL, serverId, cts.Token); @@ -279,6 +280,48 @@ public async Task GetMonumentsAsync_returns_empty_when_the_endpoint_fails() await supervisor.StopAllAsync(); } + [Fact] + public async Task GetMapImageAsync_returns_null_when_the_endpoint_fails() + { + // Same guarantee as the monuments seam. The map fetch now throws on a failed GetMap where the old + // image wrapper swallowed everything, so this path needs its own guard or the throw escapes into + // the base-map source and kills the #map refresh loop. + var source = FaultingSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + Assert.Null(await supervisor.GetMapImageAsync(10UL, serverId, cts.Token)); + await supervisor.StopAllAsync(); + } + + [Fact] + public async Task GetMapDimensionsAsync_returns_null_when_the_endpoint_fails() + { + var source = FaultingSource(); + var (provider, supervisor) = CreateHarness(source); + await using var _ = provider; + var serverId = await SeedServerWithActiveAsync(provider, steamId: 555UL); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); + await WaitUntilAsync(() => supervisor.HasLiveSocket(10UL, serverId), cts.Token); + + Assert.Null(await supervisor.GetMapDimensionsAsync(10UL, serverId, cts.Token)); + await supervisor.StopAllAsync(); + } + + /// A source whose connections fail every map query, from the very first one on connect. + private static FakeRustSocketSource FaultingSource() => + new() + { + LastConnectionSetup = c => c.MapFault = new InvalidOperationException("GetMap returned no data."), + }; + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) { while (!condition())