Skip to content

fix: bound in-game chat sends and stop refetching map dimensions - #85

Merged
HandyS11 merged 2 commits into
developfrom
fix/chat-send-timeout-and-map-dims-cache
Sep 7, 2026
Merged

fix: bound in-game chat sends and stop refetching map dimensions#85
HandyS11 merged 2 commits into
developfrom
fix/chat-send-timeout-and-map-dims-cache

Conversation

@HandyS11

@HandyS11 HandyS11 commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Two defects found while diagnosing a silent #events outage 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 / 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 for good:

  • nothing throws, so EventBusConsumption's per-event onHandlerFailure never fires;
  • nothing throws, so the outer LogRelayLoopFaulted catch never fires;
  • the unbounded channel silently accumulates every later event.

Net effect: #events, #playerevents and 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 in RustPlusSocketSource. ConnectionSupervisor.SendAsync additionally enforces the ceiling itself, so the guarantee holds for any IRustServerConnection implementation rather than depending on one honouring the timeout it is handed. Its catch is now when (cancellationToken.IsCancellationRequested), so a request timeout degrades to ChatSendResult.Failed instead of propagating as cancellation — the trap already documented for the connect path.

2. Reading map dimensions downloaded the entire map

IRustServerQuery.GetMapDimensionsAsync went straight to the socket, where it is GetMap — a full map JPEG download — to read Width/Height. Its callers are ServerTeamMessageRenderer and MapComposer, which re-render several times a minute, and the connected window had already resolved the same values into DimensionsHolder via the marker poll.

Measured on the running bot with ss -tnpi: ~700 KB bursts every ~9s, ~1.1 GB over five hours. BaseMapCache already holds the image until disconnect, so this was the only repeated caller of the full-map endpoint.

LiveSocket now carries the window's DimensionsHolder and 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:

Test Failure before fix
SendAsync_reports_failure_when_the_socket_never_answers_the_send TimeoutException after 5s (the real hang)
GetMapDimensions_ServesRepeatReadsFromTheConnectedWindow_WithoutRefetching Expected: 1, Actual: 6 — five redundant full map downloads

Full suite: 1401 passed, 1 skipped (1399 before + the 2 new). pre-push ReSharper formatting check passes.

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings September 7, 2026 20:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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>

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@HandyS11
HandyS11 merged commit ff7ef7f into develop Sep 7, 2026
3 checks passed
@HandyS11
HandyS11 deleted the fix/chat-send-timeout-and-map-dims-cache branch September 7, 2026 20:56
HandyS11 added a commit that referenced this pull request Sep 7, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants