fix(localdb): adapter review fixes — deterministic pump teardown, always-hosted initiator, backoff clamp
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -148,6 +148,31 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
await bg.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Shipped variant: in-proc against GrpcSyncDuplex with a flow-control-stalled fake writer
|
||||
// (WriteAsync never completes until its token fires) — pins requirement 2 (Send honors its
|
||||
// CancellationToken even when transport-blocked) deterministically; a true stalled-gRPC-peer
|
||||
// rig in TestHost cannot force flow-control blockage reliably.
|
||||
[Fact]
|
||||
public async Task Send_StalledOnFlowControl_CancelUnblocksPromptly()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var (duplex, readerTask) = Replication.Internal.GrpcSyncDuplex.Create(
|
||||
new HangingReader(), new HangingWriter(), cts.Token);
|
||||
|
||||
var send = duplex.Send(new SyncMessage(), cts.Token);
|
||||
Assert.False(send.IsCompleted);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
cts.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => send);
|
||||
// The reader pump must also tear down: its fault travels into the inbox completion.
|
||||
await readerTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await duplex.Inbox.Completion);
|
||||
sw.Stop();
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(3), $"teardown took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
// ---- validator -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
@@ -194,10 +219,23 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_ReconnectBackoffMax_CappedAtOneDay()
|
||||
{
|
||||
var atCeiling = new ReplicationOptionsValidator().Validate(
|
||||
null, new ReplicationOptions { ReconnectBackoffMax = TimeSpan.FromDays(1) });
|
||||
Assert.True(atCeiling.Succeeded);
|
||||
|
||||
var overCeiling = new ReplicationOptionsValidator().Validate(
|
||||
null, new ReplicationOptions { ReconnectBackoffMax = TimeSpan.FromDays(1) + TimeSpan.FromTicks(1) });
|
||||
Assert.True(overCeiling.Failed);
|
||||
Assert.Contains(overCeiling.Failures, f => f.Contains("ReconnectBackoffMax", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// ---- DI --------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddZbLocalDbReplication_PassiveConfig_NoHostedService()
|
||||
public async Task AddZbLocalDbReplication_PassiveConfig_InitiatorIdles()
|
||||
{
|
||||
var passive = new ServiceCollection();
|
||||
passive.AddLogging();
|
||||
@@ -207,9 +245,17 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
var p1 = passive.BuildServiceProvider();
|
||||
_providers.Add(p1);
|
||||
|
||||
Assert.DoesNotContain(p1.GetServices<IHostedService>(), s => s is SyncBackgroundService);
|
||||
Assert.NotNull(p1.GetService<LocalDbSyncService>());
|
||||
|
||||
// The hosted service is always registered; with no PeerAddress it starts, idles, and
|
||||
// completes without dialing anything.
|
||||
var bg = p1.GetServices<IHostedService>().OfType<SyncBackgroundService>().Single();
|
||||
await bg.StartAsync(CancellationToken.None);
|
||||
await bg.ExecuteTask!.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.True(bg.ExecuteTask.IsCompletedSuccessfully);
|
||||
Assert.Equal(0, bg.ConnectionAttempts);
|
||||
await bg.StopAsync(CancellationToken.None);
|
||||
|
||||
var initiator = new ServiceCollection();
|
||||
initiator.AddLogging();
|
||||
var initiatorCfg = ConfigFor(NewDbPath(), peerAddress: "http://localhost", apiKey: null);
|
||||
@@ -323,6 +369,27 @@ public sealed class GrpcAdapterTests : IAsyncLifetime
|
||||
throw new TimeoutException("Condition not reached within " + timeout);
|
||||
}
|
||||
|
||||
private sealed class HangingReader : IAsyncStreamReader<SyncMessage>
|
||||
{
|
||||
public SyncMessage Current => throw new InvalidOperationException("no message");
|
||||
|
||||
public async Task<bool> MoveNext(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HangingWriter : IAsyncStreamWriter<SyncMessage>
|
||||
{
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
public Task WriteAsync(SyncMessage message) => Task.Delay(Timeout.Infinite);
|
||||
|
||||
public Task WriteAsync(SyncMessage message, CancellationToken cancellationToken) =>
|
||||
Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class AuthCaptureSink
|
||||
{
|
||||
public volatile string? Authorization;
|
||||
|
||||
Reference in New Issue
Block a user