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:
Joseph Doherty
2026-07-17 23:13:02 -04:00
parent a64fc26391
commit e92cdc6d3a
6 changed files with 148 additions and 28 deletions
@@ -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;