fix(mesh-phase5): telemetry service — graceful client-disconnect + null-safe required-string mapping
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -210,6 +210,62 @@ public sealed class TelemetryStreamGrpcServiceTests
|
||||
evt.DriverHealth.LastSuccessfulReadUtc.ToDateTime().ShouldBe(expectedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Client_disconnect_mid_stream_ends_cleanly_without_leaking_a_slot()
|
||||
{
|
||||
// A broken pipe surfaces as IOException out of WriteAsync. It must be swallowed as a routine
|
||||
// disconnect (not escape), and the finally must still decrement the counter. Looping well past
|
||||
// the cap proves no leak: a single leaked slot per pass would trip ResourceExhausted by pass 101.
|
||||
var hub = new TelemetryLocalHub();
|
||||
var service = NewService(hub);
|
||||
|
||||
// Cached snapshot ⇒ every fresh subscription is deterministically handed one item to write.
|
||||
hub.Emit(new TelemetryItem.Health(new DriverHealthChanged(
|
||||
"cluster-a", "drv-1", "Healthy", null, null, 0, DateTime.UtcNow)));
|
||||
|
||||
for (var i = 0; i < 150; i++)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var pump = service.Subscribe(
|
||||
new TelemetryStreamRequest { CorrelationId = $"disc-{i}" },
|
||||
new ThrowingStreamWriter(new IOException("connection reset")),
|
||||
Ctx(cts.Token));
|
||||
|
||||
// No exception escapes Subscribe, and no ResourceExhausted ever fires (proves the slot is freed).
|
||||
await Should.NotThrowAsync(() => pump);
|
||||
}
|
||||
|
||||
// Counter is back at its prior value: a normal stream still opens and pumps.
|
||||
using var okCts = new CancellationTokenSource();
|
||||
var writer = new CapturingStreamWriter();
|
||||
var okPump = service.Subscribe(
|
||||
new TelemetryStreamRequest { CorrelationId = "disc-after" }, writer, Ctx(okCts.Token));
|
||||
await WaitUntilAsync(() => writer.Count >= 1, "the cached snapshot to stream after the disconnect loop");
|
||||
okCts.Cancel();
|
||||
await okPump;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToProto_null_required_string_does_not_throw_and_maps_to_empty()
|
||||
{
|
||||
// Required proto string setters throw on null; the domain records carry no runtime guard.
|
||||
var alarm = new AlarmTransitionEvent(
|
||||
AlarmId: null!, EquipmentPath: null!, AlarmName: null!, TransitionKind: null!,
|
||||
Severity: 100, Message: null!, User: null!, TimestampUtc: DateTime.UtcNow,
|
||||
AlarmTypeName: null!, Comment: null, HistorizeToAveva: null, ReferencingEquipmentPaths: null);
|
||||
|
||||
AlarmTransition msg = null!;
|
||||
Should.NotThrow(() => msg = TelemetryProtoMapNode.ToProto(new TelemetryItem.Alarm(alarm), "c").AlarmTransition);
|
||||
|
||||
msg.AlarmId.ShouldBe("");
|
||||
msg.EquipmentPath.ShouldBe("");
|
||||
msg.AlarmName.ShouldBe("");
|
||||
msg.TransitionKind.ShouldBe("");
|
||||
msg.Message.ShouldBe("");
|
||||
msg.User.ShouldBe("");
|
||||
msg.AlarmTypeName.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToProto_alarm_omits_absent_nullables()
|
||||
{
|
||||
@@ -284,4 +340,18 @@ public sealed class TelemetryStreamGrpcServiceTests
|
||||
return [.. _items];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A stream writer that always throws the given exception — models a broken client pipe.</summary>
|
||||
private sealed class ThrowingStreamWriter(Exception toThrow) : IServerStreamWriter<TelemetryEvent>
|
||||
{
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
public Task WriteAsync(TelemetryEvent message) => throw toThrow;
|
||||
|
||||
public Task WriteAsync(TelemetryEvent message, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return WriteAsync(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user