feat: enhance server registration handling with response envelope and error management

This commit is contained in:
HueByte
2026-04-24 15:51:01 +02:00
parent 67587dafc2
commit 6db93ecfea
@@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using System.Text.Json;
using System.Threading.Channels; using System.Threading.Channels;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
@@ -280,8 +281,8 @@ public sealed class ServerDirectoryService : BackgroundService
// ClaimToken is null on first-ever registration; otherwise the token persisted on first claim. // ClaimToken is null on first-ever registration; otherwise the token persisted on first claim.
var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken); var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken);
var result = await _connection.InvokeAsync<RegisterServerResult>("RegisterServer", dto); var envelope = await _connection.InvokeAsync<Response<RegisterServerResult>>("RegisterServer", dto);
await HandleRegistrationResultAsync(result, userCount, name, hosts); await HandleRegistrationResponseAsync(envelope, userCount, name, hosts);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -289,18 +290,73 @@ public sealed class ServerDirectoryService : BackgroundService
} }
} }
private async Task HandleRegistrationResultAsync(RegisterServerResult result, int userCount, string name, string[] hosts) private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult>? envelope, int userCount, string name, string[] hosts)
{ {
if (!result.Success) if (envelope is null)
{
_registrationPermanentlyFailed = true;
_logger.LogError("Directory returned a null envelope for RegisterServer — treating as malformed. Server will not retry until restarted.");
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
return;
}
// Pin protocol version. Spec: fail hard on mismatch — bumps are coordinated.
if (!string.Equals(envelope.Version, DirectoryProtocol.Version, StringComparison.Ordinal))
{
_registrationPermanentlyFailed = true;
_logger.LogError(
"Directory protocol version mismatch: client expects {Expected}, hub returned {Actual}. " +
"Refusing to operate. Coordinate a deploy that aligns both sides.",
DirectoryProtocol.Version, envelope.Version ?? "(null)");
_claimStore.SetFailure(DirectoryRegistrationErrors.ProtocolVersionMismatch, null);
return;
}
if (!envelope.IsSuccess)
{
await HandleRegistrationErrorAsync(envelope.Errors);
return;
}
if (envelope.Data is null)
{
_registrationPermanentlyFailed = true;
_logger.LogError("Directory returned IsSuccess=true but Data was null — treating as malformed. Server will not retry until restarted.");
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
return;
}
var data = envelope.Data;
var serverId = data.ServerId;
// Persist a freshly-issued claim token *before* anything else acks success — durability guarantee for first claim.
if (!string.IsNullOrEmpty(data.ClaimToken))
{
await _claimStore.SaveClaimAsync(data.ClaimToken, serverId);
}
else
{
// No fresh token (re-register): just keep the persisted ServerId in sync defensively.
await _claimStore.UpdateServerIdAsync(serverId);
}
_claimStore.SetSuccess(serverId);
_lastReportedUserCount = userCount;
_logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId);
}
private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors)
{ {
_registrationPermanentlyFailed = true; _registrationPermanentlyFailed = true;
var error = result.Error ?? "UnknownError"; var firstError = errors is { Length: > 0 } ? errors[0] : null;
var conflicts = result.ConflictingHosts is { Length: > 0 } var code = firstError?.Code ?? "UnknownError";
? string.Join(", ", result.ConflictingHosts) var conflictingHosts = ExtractConflictingHosts(firstError);
var conflicts = conflictingHosts is { Length: > 0 }
? string.Join(", ", conflictingHosts)
: "(none reported)"; : "(none reported)";
switch (error) switch (code)
{ {
case DirectoryRegistrationErrors.HostAlreadyClaimed: case DirectoryRegistrationErrors.HostAlreadyClaimed:
_logger.LogError( _logger.LogError(
@@ -323,40 +379,48 @@ public sealed class ServerDirectoryService : BackgroundService
conflicts); conflicts);
break; break;
case DirectoryRegistrationErrors.InvalidInput:
_logger.LogError(
"Directory rejected registration as InvalidInput ({Message}). Likely a client/hub contract drift — check Server config. Server will not retry until restarted.",
firstError?.Message ?? "(no message)");
break;
default: default:
_logger.LogError("Directory rejected registration with unknown error code: {Error}. Server will not retry until restarted.", error); _logger.LogError(
"Directory rejected registration with unknown error code: {Error} ({Message}). Server will not retry until restarted.",
code, firstError?.Message ?? "(no message)");
break; break;
} }
_claimStore.SetFailure(error, result.ConflictingHosts); _claimStore.SetFailure(code, conflictingHosts);
return; return Task.CompletedTask;
} }
// Success path /// <summary>
if (!result.ServerId.HasValue) /// Pulls <c>ConflictingHosts</c> out of an error's loosely-typed <c>Data</c> payload.
/// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on
/// the hub's serializer config and the field is typed <c>object?</c>.
/// </summary>
private static string[]? ExtractConflictingHosts(ErrorDetail? error)
{ {
_logger.LogWarning("Directory registration succeeded but ServerId was missing — treating as failure to be safe."); if (error?.Data is not JsonElement element || element.ValueKind != JsonValueKind.Object)
_registrationPermanentlyFailed = true; return null;
_claimStore.SetFailure("MissingServerId", null);
return;
}
var serverId = result.ServerId.Value; if (!element.TryGetProperty("ConflictingHosts", out var hostsProp)
&& !element.TryGetProperty("conflictingHosts", out hostsProp))
return null;
// Persist a freshly-issued claim token *before* anything else acks success — this is our durability guarantee. if (hostsProp.ValueKind != JsonValueKind.Array)
if (!string.IsNullOrEmpty(result.ClaimToken)) return null;
List<string> hosts = [];
foreach (var item in hostsProp.EnumerateArray())
{ {
await _claimStore.SaveClaimAsync(result.ClaimToken, serverId); if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
} hosts.Add(s);
else
{
// No fresh token (re-register): just keep the persisted ServerId in sync defensively.
await _claimStore.UpdateServerIdAsync(serverId);
} }
_claimStore.SetSuccess(serverId); return hosts.Count == 0 ? null : hosts.ToArray();
_lastReportedUserCount = userCount;
_logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId);
} }
private static string ResolveVersion() private static string ResolveVersion()
@@ -414,16 +478,35 @@ internal record RegisterServerDto(
string[] Tags, string[] Tags,
string? ClaimToken); string? ClaimToken);
internal record RegisterServerResult( internal record RegisterServerResult(Guid ServerId, string? ClaimToken);
bool Success,
Guid? ServerId, /// <summary>
string? ClaimToken, /// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract.
string? Error, /// </summary>
string[]? ConflictingHosts); internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version);
/// <summary>
/// Error entry inside a <see cref="Response{T}"/>. <c>Data</c> is loosely-typed because the
/// payload shape varies by error code (e.g. <c>{ ConflictingHosts: string[] }</c> for host errors).
/// </summary>
internal record ErrorDetail(string Code, string? Message, JsonElement? Data);
internal static class DirectoryProtocol
{
/// <summary>
/// Pinned envelope protocol version. Bumps are coordinated across both repos.
/// </summary>
public const string Version = "1.0";
}
internal static class DirectoryRegistrationErrors internal static class DirectoryRegistrationErrors
{ {
public const string HostAlreadyClaimed = "HostAlreadyClaimed"; public const string InvalidInput = "InvalidInput";
public const string InvalidToken = "InvalidToken"; public const string InvalidToken = "InvalidToken";
public const string HostAlreadyClaimed = "HostAlreadyClaimed";
public const string HostConflict = "HostConflict"; public const string HostConflict = "HostConflict";
// Client-side synthetic codes (never returned by hub, generated locally for status reporting)
public const string ProtocolVersionMismatch = "ProtocolVersionMismatch";
public const string MalformedResponse = "MalformedResponse";
} }