R-AM: snapshot handler and dispatch backstop answer/log untyped faults

HandleSnapshotRequestV1Async only caught the three typed selection
exceptions, unlike its siblings HandleShardLogRequestAsync/HandlePutAsync,
which catch Exception and answer a CultNetErrorMessage. An untyped fault
(e.g. a null Selection) escaped into the RUDP dispatch backstop, which
swallowed it with no log - the same backstop that hid SM-6's
ArgumentNullException regression for a whole batch.

- HandleSnapshotRequestV1Async now catches Exception like its siblings and
  answers a CultNetErrorMessage.
- RudpCultNetSchemaServer gets a Logger (ILogger/NullLogger, same pattern as
  Server/Client), and DispatchAsync's backstop always logs the fault it
  swallows, unwrapping DynamicInvoke's TargetInvocationException wrapper so
  the logged message is the handler's real exception, not the generic
  invocation-wrapper text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 07:15:43 +02:00
commit 77cd9649ec
2 changed files with 34 additions and 5 deletions

View file

@ -263,6 +263,16 @@ namespace GameCult.Networking
{
peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
}
// R-AM: an untyped fault answers like HandleShardLogRequestAsync/HandlePutAsync's own
// catch (Exception) - a CultNetErrorMessage on the wire, not silence. Before this, only
// the three typed selection exceptions were caught here; anything else propagated up into
// the dispatch backstop (CultNetRudpSchemaServer.cs), which swallowed it with no log and
// hid SM-6's ArgumentNullException regression for a whole batch.
catch (Exception ex)
{
_server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}");
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
}
return Task.CompletedTask;
}

View file

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using GameCult.Logging;
namespace GameCult.Networking
{
@ -111,6 +112,17 @@ namespace GameCult.Networking
private readonly ConcurrentDictionary<Type, Delegate> _handlers = new();
private readonly ConcurrentDictionary<Delegate, Delegate> _schemaPeerAdapters = new();
private bool _disposed;
private ILogger _logger = new NullLogger();
/// <summary>
/// Gets or sets the logger used by the server. R-AM: the dispatch backstop in DispatchAsync
/// logs every untyped fault through this instead of swallowing it silently.
/// </summary>
public ILogger Logger
{
get => _logger;
set => _logger = value ?? new NullLogger();
}
/// <summary>
/// Creates a schema-v0 RUDP server.
@ -283,11 +295,13 @@ namespace GameCult.Networking
// R-AG: a handler is expected to catch its own typed refusals (HandleSnapshotRequestV1Async
// catches the three selection exceptions and answers cursor_invalid/selection_invalid/
// reference_outside_target on the wire) - this catch is the backstop for whatever a
// reference_outside_target on the wire, R-AM) - this catch is the backstop for whatever a
// handler does not, so an untyped exception from a malformed/hostile message can never
// reach the poll loop and stall the server for every other peer. It answers nothing on the
// wire (an exception this generic carries no typed refusal to report) and simply drops the
// one malformed dispatch, exactly as an unregistered handler already does just above.
// reach the poll loop and stall the server for every other peer. It still answers nothing
// on the wire (an exception this generic carries no typed refusal to report) and drops the
// one malformed dispatch, exactly as an unregistered handler already does just above -
// R-AM: but it always logs. A backstop that swallows silently is a compensator, and an
// untested, unlogged one hid the SM-6 ArgumentNullException regression for a whole batch.
try
{
var result = handler.DynamicInvoke(message, new RudpCultNetSchemaServerPeer(this, delivered.Peer));
@ -296,8 +310,13 @@ namespace GameCult.Networking
await task.ConfigureAwait(false);
}
}
catch (Exception)
catch (Exception ex)
{
// DynamicInvoke wraps a synchronously-thrown handler fault in TargetInvocationException,
// whose own .Message is the generic "Exception has been thrown by the target of an
// invocation." - log the real fault, not that wrapper.
var reported = ex is System.Reflection.TargetInvocationException { InnerException: { } inner } ? inner : ex;
Logger.LogError($"CultNet RUDP schema dispatch failed for {message.GetType().Name}: {reported.Message}");
return false;
}
return true;