Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,16 @@ internal interface IRustServerConnection : IAsyncDisposable

/// <summary>Sends a message to in-game team chat.</summary>
/// <param name="message">The message text to send.</param>
/// <param name="timeout">How long to wait for the send to be acknowledged.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when the send has been issued.</returns>
/// <remarks>Unlike the probe methods, this surfaces send failures to the caller (the supervisor maps them to a failed send result).</remarks>
Task SendTeamMessageAsync(string message, CancellationToken cancellationToken);
/// <returns>A task that completes when the server has acknowledged the send, not when it was written to the socket.</returns>
/// <remarks>
/// Unlike the probe methods, this surfaces send failures to the caller (the supervisor maps them to a
/// failed send result). <paramref name="timeout"/> is mandatory for the same reason it is on every other
/// call here: a Rust+ request whose response the server never delivers otherwise parks the caller
/// forever, and the callers are the relay loops that drive #events, #playerevents and alarms.
/// </remarks>
Task SendTeamMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>Probes the authenticated player's clan, distinguishing "no clan" from "could not ask".</summary>
/// <param name="timeout">How long to wait for the response.</param>
Expand All @@ -57,10 +63,11 @@ internal interface IRustServerConnection : IAsyncDisposable

/// <summary>Sends a message to in-game clan chat.</summary>
/// <param name="message">The message text to send.</param>
/// <param name="timeout">How long to wait for the send to be acknowledged.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A task that completes when the send has been issued.</returns>
/// <remarks>Like <see cref="SendTeamMessageAsync"/>, this surfaces failures to the caller.</remarks>
Task SendClanMessageAsync(string message, CancellationToken cancellationToken);
/// <returns>A task that completes when the server has acknowledged the send, not when it was written to the socket.</returns>
/// <remarks>Like <see cref="SendTeamMessageAsync"/>, this surfaces failures to the caller and is bounded by <paramref name="timeout"/>.</remarks>
Task SendClanMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>Sets the clan message of the day; returns true on success.</summary>
/// <param name="motd">The new message of the day.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,13 @@ public Task<HeartbeatResult> GetInfoAsync(TimeSpan timeout, CancellationToken ca
public Task<TeamInfoSnapshot?> GetTeamInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult<TeamInfoSnapshot?>(null);

public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken) =>
public Task SendTeamMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken) =>
Task.CompletedTask;

public Task<ClanProbeResult> GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(ClanProbeResult.Unavailable);

public Task SendClanMessageAsync(string message, CancellationToken cancellationToken) =>
public Task SendClanMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken) =>
Task.CompletedTask;

public Task<bool> SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken) =>
Expand Down Expand Up @@ -364,12 +364,17 @@ public async Task<HeartbeatResult> GetInfoAsync(TimeSpan timeout, CancellationTo

public event EventHandler<ClanProbeResult>? ClanChanged;

public async Task SendTeamMessageAsync(string message, CancellationToken cancellationToken)
public async Task SendTeamMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken)
{
// CONFIRMED: SendTeamMessageAsync(string, CancellationToken) in 2.0.0-beta.1 returns Task<Response<T>>.
// Awaiting it discards the response; the interface contract is bare Task.
// Intentional: send failures propagate to the caller (the supervisor classifies them), unlike the broad-catch probes.
await _rustPlus.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false);
// .WaitAsync guards the timeout even if the beta call doesn't internally honor the token (matching
// every other call here): a send the server never answers must fail, never hang the caller.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
await _rustPlus.SendTeamMessageAsync(message, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
}

public async Task<ClanProbeResult> GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken)
Expand Down Expand Up @@ -399,10 +404,14 @@ public async Task<ClanProbeResult> GetClanInfoAsync(TimeSpan timeout, Cancellati
}
}

public async Task SendClanMessageAsync(string message, CancellationToken cancellationToken)
public async Task SendClanMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken)
{
// Intentional: send failures propagate to the caller (the supervisor classifies them).
await _rustPlus.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false);
// Intentional: send failures propagate to the caller (the supervisor classifies them), but the
// wait is bounded — see SendTeamMessageAsync for why an unbounded send is a silent loop-killer.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
await _rustPlus.SendClanMessageAsync(message, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
}

public async Task<bool> SetClanMotdAsync(string motd, TimeSpan timeout, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,25 +102,35 @@ public async Task<ChatSendResult> SendAsync(
return ChatSendResult.NotConnected;
}

// Bound the send here, not only inside the socket wrapper: the relay loops for #events,
// #playerevents and alarms await this call inline, so an unbounded send parks the whole loop
// forever and its feature dies silently. .WaitAsync enforces the ceiling whatever the
// IRustServerConnection implementation does with the timeout it is handed.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_options.HeartbeatTimeout);
try
{
switch (kind)
{
case ChatChannelKind.Team:
await live.Connection.SendTeamMessageAsync(message, cancellationToken).ConfigureAwait(false);
await live.Connection
.SendTeamMessageAsync(message, _options.HeartbeatTimeout, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
break;
case ChatChannelKind.Clan:
await live.Connection.SendClanMessageAsync(message, cancellationToken).ConfigureAwait(false);
await live.Connection
.SendClanMessageAsync(message, _options.HeartbeatTimeout, timeoutCts.Token)
.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
break;
default:
return ChatSendResult.Failed;
}

return ChatSendResult.Sent;
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
throw; // A real shutdown: let the caller unwind.
}
#pragma warning disable CA1031 // Broad catch: a failed relay send must not crash the caller; report Failed.
catch (Exception ex)
Expand Down Expand Up @@ -287,8 +297,26 @@ public async Task<bool> PromoteToLeaderAsync(
return null;
}

return await live.Connection.GetMapDimensionsAsync(_options.HeartbeatTimeout, cancellationToken)
// 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)
{
return cached;
}

// 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)
{
live.Dimensions.Value = fetched;
}

return fetched;
}

/// <inheritdoc />
Expand Down Expand Up @@ -625,7 +653,7 @@ void OnTeamChanged(object? sender, TeamInfoSnapshot snapshot)
connection.ClanMessageReceived += OnClanMessage;
connection.ClanChanged += OnClanChanged;
connection.TeamChanged += OnTeamChanged;
_liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker);
_liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker, dims);
await PrimeDevicesAsync(key, connection, ct).ConfigureAwait(false);

// Probe once on connect so clan state is correct after a bot restart, not only after the
Expand Down Expand Up @@ -1680,7 +1708,11 @@ private readonly record struct Prepared(
ulong SteamId,
string PlayerToken);

private sealed record LiveSocket(IRustServerConnection Connection, ulong ActiveSteamId, TeamStateTracker Tracker);
private sealed record LiveSocket(
IRustServerConnection Connection,
ulong ActiveSteamId,
TeamStateTracker Tracker,
DimensionsHolder Dimensions);

/// <summary>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,19 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
: IRustServerConnection
{
private readonly ConcurrentQueue<IReadOnlyList<MapMarkerSnapshot>> _markerScript = new();
private int _dimensionsCallCount;
private IReadOnlyList<MapMarkerSnapshot> _lastMarkers = [];
private bool _markerScriptStarted;

/// <summary>Gets the messages sent via <see cref="SendTeamMessageAsync"/>.</summary>
public List<string> SentMessages { get; } = [];

/// <summary>
/// When set, <see cref="SendTeamMessageAsync"/> and <see cref="SendClanMessageAsync"/> return a task
/// that never completes, reproducing a Rust+ send whose response the server never delivers.
/// </summary>
public bool HangOnSend { get; set; }

/// <summary>The snapshot returned by <see cref="GetServerInfoAsync"/>. Defaults to a non-null zero snapshot.</summary>
public ServerInfoSnapshot? InfoResult { get; set; } = new(0, 0, 0, null);

Expand Down Expand Up @@ -286,6 +293,10 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
/// <summary>The dimensions returned by <see cref="GetMapDimensionsAsync"/>. Defaults to a non-null snapshot.</summary>
public MapDimensions? DimensionsResult { get; set; } = new(4000u, 4000u, 500, 4000u);

/// <summary>Number of times <see cref="GetMapDimensionsAsync"/> has been called. Each call is a full
/// map download on the real socket, so the query seam must serve repeat reads from cache.</summary>
public int DimensionsCallCount => Volatile.Read(ref _dimensionsCallCount);

/// <summary>The snapshot returned by <see cref="GetWorldAsync"/>. Defaults to null.</summary>
public WorldSnapshot? World { get; set; }

Expand Down Expand Up @@ -350,17 +361,27 @@ public Task<HeartbeatResult> GetInfoAsync(TimeSpan timeout, CancellationToken ca
return Task.FromResult(TeamResult);
}

public Task SendTeamMessageAsync(string message, CancellationToken cancellationToken)
public Task SendTeamMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken)
{
if (HangOnSend)
{
return new TaskCompletionSource().Task;
}

SentMessages.Add(message);
return Task.CompletedTask;
}

public Task<ClanProbeResult> GetClanInfoAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(ClanProbe);

public Task SendClanMessageAsync(string message, CancellationToken cancellationToken)
public Task SendClanMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken)
{
if (HangOnSend)
{
return new TaskCompletionSource().Task;
}

SentClanMessages.Add(message);
return Task.CompletedTask;
}
Expand Down Expand Up @@ -453,8 +474,11 @@ public Task<MapMarkersSnapshot> GetMapMarkersAsync(TimeSpan timeout,
}

public Task<MapDimensions?> GetMapDimensionsAsync(TimeSpan timeout,
CancellationToken cancellationToken = default) =>
Task.FromResult(DimensionsResult);
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _dimensionsCallCount);
return Task.FromResult(DimensionsResult);
}

public Task<WorldSnapshot?> GetWorldAsync(TimeSpan timeout, CancellationToken cancellationToken = default) =>
Task.FromResult(World);
Expand Down
28 changes: 28 additions & 0 deletions tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,34 @@ public async Task GetMapImage_ReturnsBytes_WhenConnected()
await supervisor.StopAllAsync();
}

[Fact]
public async Task GetMapDimensions_ServesRepeatReadsFromTheConnectedWindow_WithoutRefetching()
{
var source = new FakeRustSocketSource();
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 dimensions once for the connected window.
var connection = source.LastConnection!;
await WaitUntilAsync(() => connection.DimensionsCallCount > 0, cts.Token);
var afterConnect = connection.DimensionsCallCount;

for (var i = 0; i < 5; i++)
{
Assert.NotNull(await supervisor.GetMapDimensionsAsync(10UL, serverId, cts.Token));
}

// 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);
await supervisor.StopAllAsync();
}

[Fact]
public async Task GetMapImage_ReturnsNull_WhenNoLiveSocket()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,28 @@ public async Task SendAsync_routes_to_live_socket_when_connected()
await supervisor.StopAllAsync();
}

[Fact]
public async Task SendAsync_reports_failure_when_the_socket_never_answers_the_send()
{
var source = new FakeRustSocketSource();
var (provider, supervisor, _) = CreateHarness(source);
await using var _p = 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);
source.LastConnection!.HangOnSend = true;

// A send whose response never arrives must be bounded by the request timeout (200 ms in this
// harness). Unbounded, it parks the calling relay loop forever and the feature dies silently.
var result = await supervisor.SendAsync(ChatChannelKind.Team, 10UL, serverId, "hi", cts.Token)
.WaitAsync(TimeSpan.FromSeconds(5), cts.Token);

Assert.Equal(ChatSendResult.Failed, result);
await supervisor.StopAllAsync();
}

[Fact]
public async Task SendAsync_returns_NotConnected_when_no_socket()
{
Expand Down
Loading