fix: bound in-game chat sends and stop refetching map dimensions - #85
Conversation
Two defects found while diagnosing a silent #events outage. 1. Unbounded in-game chat send could kill a relay loop for good. SendTeamMessageAsync/SendClanMessageAsync were the only 2 of 19 IRustServerConnection calls without a TimeSpan timeout. RustPlusApi resolves a send when the server answers it, so a response the server never delivers left the task pending forever. EventRelay, PlayerEventRelay and AlarmStateRelay all await the in-game broadcast inline, before posting to Discord. One unanswered send therefore parked the consumer loop permanently: no exception, so neither the per-event onHandlerFailure nor the outer loop catch ever fired, and #events / #playerevents / alarms went silently dead until restart. Both calls now take a timeout and guard it with timeoutCts + .WaitAsync, matching every sibling. ConnectionSupervisor.SendAsync also enforces the ceiling itself, so the guarantee holds for any IRustServerConnection, and its catch is now `when (cancellationToken.IsCancellationRequested)` so a request timeout degrades to ChatSendResult.Failed instead of propagating as cancellation. 2. Reading map dimensions downloaded the whole map. IRustServerQuery.GetMapDimensionsAsync went straight to the socket, where it is GetMap — a full map JPEG download — to read width and height. Its callers are the team panel renderer and the map composer, which re-render several times a minute, and the connected window had already resolved the same values into DimensionsHolder. Measured on a live bot: ~700 KB bursts every ~9s, ~1.1 GB over five hours. LiveSocket now carries the window's DimensionsHolder and the query serves from it, fetching once only if a reader beats the marker poll. Dimensions are fixed for a wipe, and the window is torn down on reconnect, which is exactly when they can change. Regression tests (both watched failing first): - SendAsync_reports_failure_when_the_socket_never_answers_the_send timed out after 5s before the fix. - GetMapDimensions_ServesRepeatReadsFromTheConnectedWindow_WithoutRefetching saw 6 socket fetches where 1 suffices. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The changes are targeted, reduce operational risk (hangs/bandwidth), and are backed by focused regression tests; only minor documentation wording nits were found.
Pull request overview
This PR addresses two reliability/performance defects in the Rust+ connection layer: (1) bounding in-game chat sends so relay loops can’t hang indefinitely on a missing server response, and (2) avoiding repeated full map image downloads when only map dimensions are needed by serving dimensions from the connected window cache.
Changes:
- Added request timeouts for team/clan chat sends and enforced them at the supervisor level to prevent relay-loop deadlocks.
- Cached map dimensions per connected window (and published on first fetch) to stop refetching dimensions via full map downloads.
- Added regression tests covering both the bounded-send behavior and the “no refetch” dimensions behavior.
File summaries
| File | Description |
|---|---|
| tests/RustPlusBot.Features.Connections.Tests/TeamChatSenderTests.cs | Adds regression test ensuring a non-responding send is bounded and returns Failed. |
| tests/RustPlusBot.Features.Connections.Tests/MapImageQueryTests.cs | Adds regression test ensuring repeated dimension reads don’t refetch from the socket. |
| tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs | Extends fake connection to simulate hung sends and count map-dimensions calls. |
| src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs | Enforces bounded chat sends and serves map dimensions from a per-window cache. |
| src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs | Updates send methods to accept a timeout and bound the underlying RustPlusApi call. |
| src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs | Updates the connection interface to require timeouts for chat sends. |
Review details
Suppressed comments (1)
src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs:70
- Same as the team send: the return doc says "issued" but the method is bounded by a timeout waiting for an acknowledgement/response; aligning the wording helps set correct expectations for callers.
/// <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 and is bounded by <paramref name="timeout"/>.</remarks>
Task SendClanMessageAsync(string message, TimeSpan timeout, CancellationToken cancellationToken);
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// <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> |
There was a problem hiding this comment.
Fixed in 464d698, on both methods.
Verified against the library before changing the wording — decompiling RustPlusApi.RustPlus (2.0.0-beta.8) shows both sends routed through ProcessRequestAsync:
public async Task<Response<TeamMessage?>> SendTeamMessageAsync(string message, CancellationToken cancellationToken = default)
{
AppRequest request = new AppRequest { SendTeamMessage = new AppSendMessage { Message = message } };
return await ProcessRequestAsync(request, delegate(AppMessage r) { ... });
}So the task completes when the server's AppMessage response arrives, not when the bytes are written — "issued" was wrong.
Worth noting this is more than a wording nit: "issued" is exactly the fire-and-forget reading that made the unbounded await look harmless, which is what let a single unanswered send park a relay loop forever. The docs now say:
A task that completes when the server has acknowledged the send, not when it was written to the socket.
The <returns> docs said the task completes when the send "has been issued". RustPlusApi routes both sends through ProcessRequestAsync, so the task completes when the server's AppMessage response arrives, not when the bytes are written. That wording is the misreading that made the unbounded await look safe in the first place, so it is worth stating the blocking behaviour plainly. Addresses Copilot review feedback on #85. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: resolve the Rust+ map once per connected window `GetMonumentsAsync` went straight to the socket, where it is `GetMap` — a full ~683 KB map JPEG download — to read a static monument list. The #map composer calls it on every compose, so it fired once per `MapRefreshInterval` (30s), forever. Measured on a live bot after #85: 10 bursts of ~683 KB over 309s, 77 MB/h, 99.6% of all Rust+ inbound traffic, 1.85 GB/day per server. The list was already in memory and thrown away: `GetRigPositionsAsync` fetches it once per connected window, keeps the two oil rigs and drops the rest. Fixes #86. `GetMap` answers geometry, monuments and the image in one response, so the three per-purpose wrappers on `IRustServerConnection` each paid for the whole JPEG while wanting a third of it. They collapse into one `GetServerMapAsync` returning a `ServerMapSnapshot`, and the three are deleted — leaving them is what invites this regression back. `ServerMapWindowCache` resolves that snapshot once per connected window and serves every reader from it, replacing the `DimensionsHolder` #85 added. Resolution is single-flight: on connect the marker poll and the map service's initial compose both arrive before either resolves, and each miss is a full map download. A failed fetch is not cached, and each reader fetches under its own token, so one reader cancelling never poisons another. The world size that completes `MapDimensions` comes from `GetInfo`, not `GetMap`, so it is resolved separately and retried per read. Folding it into the map fetch would let a transient `GetInfo` rate limit — likely, given the heavy call it follows on connect — latch "no dimensions" for the whole window and leave #map dark until the next reconnect. For the same reason the marker poll now reads dimensions inside its loop rather than latching one value before it. Connected-window cost drops from two `GetMap` round trips to one, and #map refreshes stop fetching entirely: ~120 full-map downloads an hour become one per window, 77 MB/h becomes ~0.3 MB/h. The trade is that a window holds its JPEG until it disconnects, whether or not the guild uses #map — one bounded ~683 KB array per connected server, against a second full download per window if the image were fetched separately. Regression tests (both watched failing first): - GetMonuments_ServesRepeatReadsFromTheConnectedWindow_WithoutRefetching saw 7 map fetches where 2 suffice. - ConnectedWindow_IssuesASingleMapFetch_ForDimensionsMonumentsAndImage saw 4 where 1 suffices. The supervisor's map queries now guard their own degradation: the collapsed socket call throws where the old dimensions and image wrappers swallowed everything, and an escaping exception tears down the consuming render loop for the rest of the process. Their existing tests staged the fault after connect, which the window cache would now serve from cache, so they stage it before connect instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: name the map-timeout test for what it actually stages `TimeoutOnMapOnce` now sinks the whole map read — geometry, monuments and the image — not just the monument list, so the test's name and summary understated its blast radius and made timeout coverage hard to find by searching. Addresses Copilot review feedback on #87. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two defects found while diagnosing a silent
#eventsoutage on a live bot. Neither is the cause of that outage (the paired server simply publishes no world markers over Rust+, verified at the wire/protobuf/mapping/model layers) — but both are real, and the first is the same failure class as #81.1. An unbounded in-game chat send could kill a relay loop permanently
SendTeamMessageAsync/SendClanMessageAsyncwere the only 2 of 19IRustServerConnectioncalls without aTimeSpan timeout. RustPlusApi resolves a send when the server answers it, so a response the server never delivers left the task pending forever.EventRelay,PlayerEventRelayandAlarmStateRelayall await the in-game broadcast inline, before posting to Discord. One unanswered send therefore parked the consumer loop for good:EventBusConsumption's per-eventonHandlerFailurenever fires;LogRelayLoopFaultedcatch never fires;Net effect:
#events,#playereventsand alarms go dead with zero log output, while the connection, heartbeat and map repaint all stay healthy — indistinguishable from "nothing is happening in game".Both calls now take a timeout and guard it with
timeoutCts+.WaitAsync, matching every sibling inRustPlusSocketSource.ConnectionSupervisor.SendAsyncadditionally enforces the ceiling itself, so the guarantee holds for anyIRustServerConnectionimplementation rather than depending on one honouring the timeout it is handed. Its catch is nowwhen (cancellationToken.IsCancellationRequested), so a request timeout degrades toChatSendResult.Failedinstead of propagating as cancellation — the trap already documented for the connect path.2. Reading map dimensions downloaded the entire map
IRustServerQuery.GetMapDimensionsAsyncwent straight to the socket, where it isGetMap— a full map JPEG download — to readWidth/Height. Its callers areServerTeamMessageRendererandMapComposer, which re-render several times a minute, and the connected window had already resolved the same values intoDimensionsHoldervia the marker poll.Measured on the running bot with
ss -tnpi: ~700 KB bursts every ~9s, ~1.1 GB over five hours.BaseMapCachealready holds the image until disconnect, so this was the only repeated caller of the full-map endpoint.LiveSocketnow carries the window'sDimensionsHolderand the query serves from it, fetching once only if a reader beats the marker poll's first resolution. Dimensions are fixed for a wipe, and the window is torn down and re-resolved on reconnect — exactly when they can change.Testing
Two regression tests, both watched failing before the fix:
SendAsync_reports_failure_when_the_socket_never_answers_the_sendTimeoutExceptionafter 5s (the real hang)GetMapDimensions_ServesRepeatReadsFromTheConnectedWindow_WithoutRefetchingExpected: 1, Actual: 6— five redundant full map downloadsFull suite: 1401 passed, 1 skipped (1399 before + the 2 new).
pre-pushReSharper formatting check passes.🤖 Generated with Claude Code