using System.Threading.Channels; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.LocalDb.Contracts; using ZB.MOM.WW.LocalDb.Internal; using ZB.MOM.WW.LocalDb.Replication; using ZB.MOM.WW.LocalDb.Replication.Internal; namespace ZB.MOM.WW.LocalDb.Tests; public sealed class SyncSessionTests : IDisposable { private const string OrdersSql = "CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER)"; private static readonly TimeSpan RunTimeout = TimeSpan.FromSeconds(15); private readonly List _paths = []; private readonly List _disposables = []; public void Dispose() { foreach (var d in _disposables) d.Dispose(); Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); foreach (var p in _paths) { if (File.Exists(p)) File.Delete(p); if (File.Exists(p + "-wal")) File.Delete(p + "-wal"); if (File.Exists(p + "-shm")) File.Delete(p + "-shm"); } } private sealed record Side( SqliteLocalDb Db, OplogStore Store, LwwApplier Applier, SyncSession Session); private async Task NewSideAsync( string createSql = OrdersSql, string table = "orders", ReplicationOptions? options = null, ILogger? logger = null, Func? utcNow = null) { var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db"); _paths.Add(path); var db = new SqliteLocalDb(new LocalDbOptions { Path = path }); _disposables.Add(db); await db.ExecuteAsync(createSql); db.RegisterReplicated(table); options ??= new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20) }; var store = new OplogStore(db, options, utcNow); var applier = new LwwApplier(db, utcNow); var session = new SyncSession(db, store, applier, options, logger ?? NullLogger.Instance, utcNow); return new Side(db, store, applier, session); } private static (SyncDuplex A, SyncDuplex B) DuplexPair(int? capacity = null) { Channel New() => capacity is int c ? Channel.CreateBounded(c) : Channel.CreateUnbounded(); var aToB = New(); var bToA = New(); var a = new SyncDuplex { Send = (m, ct) => aToB.Writer.WriteAsync(m, ct).AsTask(), Inbox = bToA.Reader, }; var b = new SyncDuplex { Send = (m, ct) => bToA.Writer.WriteAsync(m, ct).AsTask(), Inbox = aToB.Reader, }; return (a, b); } private static (SyncDuplex Duplex, ChannelWriter ToSession, ChannelReader FromSession) ScriptedPeer() { var toSession = Channel.CreateUnbounded(); var fromSession = Channel.CreateUnbounded(); var duplex = new SyncDuplex { Send = (m, ct) => fromSession.Writer.WriteAsync(m, ct).AsTask(), Inbox = toSession.Reader, }; return (duplex, toSession.Writer, fromSession.Reader); } private static Handshake MatchingHandshake(SqliteLocalDb sessionDb, string peerNodeId, uint version = 1, long lastAppliedRemoteSeq = 0) { var h = new Handshake { NodeId = peerNodeId, LibSchemaVersion = version, LastAppliedRemoteSeq = lastAppliedRemoteSeq }; foreach (var t in sessionDb.ReplicatedTables.Values.OrderBy(t => t.Name, StringComparer.Ordinal)) h.Tables.Add(new TableDigest { TableName = t.Name, Digest = t.Digest }); return h; } private static async Task NextAsync(ChannelReader reader, CancellationToken ct) { await reader.WaitToReadAsync(ct); reader.TryRead(out var msg); return msg!; } private static async Task NextOfCaseAsync( ChannelReader reader, SyncMessage.MsgOneofCase msgCase, CancellationToken ct) { SyncMessage msg; do { msg = await NextAsync(reader, ct); } while (msg.MsgCase != msgCase); return msg; } private static async Task SwallowAsync(Task task) { try { await task; } catch (OperationCanceledException) { } } private static async Task WaitForAsync(Func> predicate, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { if (await predicate()) return; await Task.Delay(20); } throw new TimeoutException("Condition not reached within " + timeout); } private static async Task> ReadOrders(SqliteLocalDb db) { var rows = await db.QueryAsync( "SELECT id, sku, qty FROM orders ORDER BY id", x => (x.GetInt64(0), x.IsDBNull(1) ? null : x.GetString(1), (long?)(x.IsDBNull(2) ? null : x.GetInt64(2)))); return rows.ToList(); } private static async Task OplogCount(SqliteLocalDb db) { var r = await db.QueryAsync("SELECT COUNT(*) FROM __localdb_oplog", x => x.GetInt64(0)); return r[0]; } private static async Task LastAckedSeq(SqliteLocalDb db) { var r = await db.QueryAsync("SELECT last_acked_seq FROM __localdb_peer_state WHERE id = 1", x => x.GetInt64(0)); return r[0]; } private static async Task LastAppliedRemoteSeq(SqliteLocalDb db) { var r = await db.QueryAsync("SELECT last_applied_remote_seq FROM __localdb_peer_state WHERE id = 1", x => x.GetInt64(0)); return r[0]; } private static OplogEntry ProtoEntry(long seq, long id, string sku, long hlc, string nodeId = "peer") => new() { Seq = seq, TableName = "orders", PkJson = $"{{\"id\":{id}}}", RowJson = $"{{\"id\":{id},\"sku\":\"{sku}\",\"qty\":1}}", Hlc = hlc, NodeId = nodeId, IsTombstone = false, }; private static long AheadHlc(TimeSpan ahead) => DateTimeOffset.UtcNow.Add(ahead).ToUnixTimeMilliseconds() << 16; [Fact] public async Task Handshake_Exchanged_BothProceed() { var a = await NewSideAsync(); var b = await NewSideAsync(); var (da, db) = DuplexPair(); using var cts = new CancellationTokenSource(RunTimeout); var runA = a.Session.RunAsync(da, cts.Token); var runB = b.Session.RunAsync(db, cts.Token); await WaitForAsync(() => Task.FromResult(a.Session.PeerNodeId is not null && b.Session.PeerNodeId is not null), RunTimeout); Assert.Equal(b.Db.NodeId, a.Session.PeerNodeId); Assert.Equal(a.Db.NodeId, b.Session.PeerNodeId); cts.Cancel(); await SwallowAsync(runA); await SwallowAsync(runB); } [Fact] public async Task Handshake_DigestMismatch_FailsClosed() { var a = await NewSideAsync(); var b = await NewSideAsync("CREATE TABLE orders (id INTEGER PRIMARY KEY, sku TEXT, qty INTEGER, extra TEXT)"); await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'A', 5)"); var (da, db) = DuplexPair(); using var cts = new CancellationTokenSource(RunTimeout); var runA = a.Session.RunAsync(da, cts.Token); var runB = b.Session.RunAsync(db, cts.Token); var exA = await Assert.ThrowsAsync(() => runA); var exB = await Assert.ThrowsAsync(() => runB); Assert.Contains("orders", exA.Message); Assert.Contains("orders", exB.Message); // No delta crossed the handshake: B never received A's row. Assert.Empty(await ReadOrders(b.Db)); } [Fact] public async Task Handshake_SchemaVersionMismatch_FailsClosed() { var a = await NewSideAsync(); var (duplex, toSession, _) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer", version: 2) }, cts.Token); await Assert.ThrowsAsync(() => run); } [Fact] public async Task Deltas_FlowBothDirections_AcksAdvanceWatermarks() { var a = await NewSideAsync(); var b = await NewSideAsync(); await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (1, 'FROM_A', 10)"); await b.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (2, 'FROM_B', 20)"); var (da, db) = DuplexPair(); using var cts = new CancellationTokenSource(RunTimeout); var runA = a.Session.RunAsync(da, cts.Token); var runB = b.Session.RunAsync(db, cts.Token); async Task Converged() { var ra = await ReadOrders(a.Db); var rb = await ReadOrders(b.Db); return ra.Count == 2 && rb.Count == 2 && await OplogCount(a.Db) == 0 && await OplogCount(b.Db) == 0; } await WaitForAsync(Converged, RunTimeout); var rowsA = await ReadOrders(a.Db); var rowsB = await ReadOrders(b.Db); Assert.Equal(rowsA, rowsB); Assert.Equal([(1L, "FROM_A", (long?)10), (2L, "FROM_B", (long?)20)], rowsA); Assert.True(await LastAckedSeq(a.Db) >= 1); Assert.True(await LastAckedSeq(b.Db) >= 1); cts.Cancel(); await SwallowAsync(runA); await SwallowAsync(runB); } [Fact] public async Task Backlog_DrainsWithoutWaitingFlushInterval() { var options = new ReplicationOptions { MaxBatchSize = 5, FlushInterval = TimeSpan.FromSeconds(30) }; var a = await NewSideAsync(options: options); var b = await NewSideAsync(options: options); const int rows = 15; // 3 x MaxBatchSize for (var i = 1; i <= rows; i++) await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i }); var (da, db) = DuplexPair(); using var cts = new CancellationTokenSource(RunTimeout); var runA = a.Session.RunAsync(da, cts.Token); var runB = b.Session.RunAsync(db, cts.Token); // FlushInterval is 30 s: reaching convergence in a couple seconds proves the pump loops // immediately on backlog instead of waiting a flush tick between batches. var sw = System.Diagnostics.Stopwatch.StartNew(); await WaitForAsync(async () => (await ReadOrders(b.Db)).Count == rows, TimeSpan.FromSeconds(5)); sw.Stop(); Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"drain took {sw.Elapsed}"); cts.Cancel(); await SwallowAsync(runA); await SwallowAsync(runB); } [Fact] public async Task Backpressure_BoundedDuplex_NoDeadlock() { // Capacity-1 crosswise channels with backlog in BOTH directions: under the old shared // send-gate design each side's pump held the gate while blocked on the full channel, and // each side's receive loop then blocked on the gate trying to send its ack — a circular // wait. The two-lane writer breaks it: receiving enqueues acks without ever blocking. var options = new ReplicationOptions { MaxBatchSize = 2, FlushInterval = TimeSpan.FromMilliseconds(20) }; var a = await NewSideAsync(options: options); var b = await NewSideAsync(options: options); const int rowsPerSide = 40; for (var i = 1; i <= rowsPerSide; i++) { await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'A', @id)", new { id = i }); await b.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'B', @id)", new { id = 100 + i }); } var (da, db) = DuplexPair(capacity: 1); using var cts = new CancellationTokenSource(RunTimeout); var runA = a.Session.RunAsync(da, cts.Token); var runB = b.Session.RunAsync(db, cts.Token); await WaitForAsync( async () => (await ReadOrders(a.Db)).Count == 2 * rowsPerSide && (await ReadOrders(b.Db)).Count == 2 * rowsPerSide, RunTimeout); cts.Cancel(); await SwallowAsync(runA); await SwallowAsync(runB); } [Fact] public async Task OutOfOrderAck_DoesNotRegress() { var a = await NewSideAsync(); for (var i = 1; i <= 5; i++) await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i }); var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { DeltaAck = new DeltaAck { AppliedThruSeq = 5 } }, cts.Token); await toSession.WriteAsync(new SyncMessage { DeltaAck = new DeltaAck { AppliedThruSeq = 2 } }, cts.Token); // Barrier: the session applies inbound messages in order, so its DeltaAck for this batch // proves both out-of-order acks above were already processed. await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { ProtoEntry(1, 100, "BARRIER", 5_000_000) } }, }, cts.Token); await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token); Assert.Equal(5, a.Session.PeerAckedSeq); Assert.Equal(5, await LastAckedSeq(a.Db)); var snap = await a.Db.QueryAsync("SELECT needs_snapshot FROM __localdb_peer_state WHERE id = 1", x => x.GetInt64(0)); Assert.Equal(0, snap[0]); cts.Cancel(); await SwallowAsync(run); } [Fact] public async Task Teardown_PrimaryFaultSurfaces() { var a = await NewSideAsync(); // Backlog keeps the pump loop mid-work while the receive loop faults. for (var i = 1; i <= 10; i++) await a.Db.ExecuteAsync("INSERT INTO orders (id, sku, qty) VALUES (@id, 'R', @id)", new { id = i }); var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { new OplogEntry { Seq = 1, TableName = "widgets", PkJson = "{\"id\":9}", RowJson = "{\"id\":9}", Hlc = 5_000_000, NodeId = "peer", IsTombstone = false, } }, }, }, cts.Token); // The receive loop's schema fault must surface — not an OperationCanceledException from // the torn-down pump/writer siblings. var ex = await Assert.ThrowsAsync(() => run); Assert.Contains("widgets", ex.Message); } [Fact] public async Task HlcDriftAhead_Warns() { var logger = new ListLogger(); var a = await NewSideAsync( options: new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20), FailClosedOnDrift = false }, logger: logger); var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); // Two separate drifted batches: both apply, but only ONE warning is logged per session. await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { ProtoEntry(1, 1, "AHEAD1", AheadHlc(TimeSpan.FromMinutes(10))) } }, }, cts.Token); var ack1 = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token); Assert.Equal(1, ack1.DeltaAck.AppliedThruSeq); await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { ProtoEntry(2, 2, "AHEAD2", AheadHlc(TimeSpan.FromMinutes(11))) } }, }, cts.Token); var ack2 = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token); Assert.Equal(2, ack2.DeltaAck.AppliedThruSeq); Assert.Equal(2, (await ReadOrders(a.Db)).Count); // applied despite drift Assert.Equal(1, logger.Entries.Count(e => e.Level == LogLevel.Warning)); cts.Cancel(); await SwallowAsync(run); } [Fact] public async Task HlcDriftAhead_FailClosed_DeadLetters() { var a = await NewSideAsync( options: new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20), FailClosedOnDrift = true }); var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); // One batch: seq 1 drifted (dead-lettered), seq 2 clean (applied). Watermark covers both. await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { ProtoEntry(1, 1, "DRIFTED", AheadHlc(TimeSpan.FromMinutes(10))), ProtoEntry(2, 2, "CLEAN", 5_000_000), }, }, }, cts.Token); var ack = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token); Assert.Equal(2, ack.DeltaAck.AppliedThruSeq); var rows = await ReadOrders(a.Db); Assert.Single(rows); Assert.Equal(2, rows[0].Id); var dead = await a.Db.QueryAsync( "SELECT pk_json, error FROM __localdb_dead_letter", x => (Pk: x.GetString(0), Err: x.GetString(1))); Assert.Single(dead); Assert.Equal("{\"id\":1}", dead[0].Pk); Assert.Contains("drift", dead[0].Err, StringComparison.OrdinalIgnoreCase); // The session stays alive after dead-lettering: a follow-up clean batch still applies. await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { ProtoEntry(3, 3, "AFTER", 5_000_001) } }, }, cts.Token); var ack3 = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token); Assert.Equal(3, ack3.DeltaAck.AppliedThruSeq); Assert.Equal(2, (await ReadOrders(a.Db)).Count); cts.Cancel(); await SwallowAsync(run); } [Fact] public async Task TopSeqDrifted_WatermarkStillAdvances() { // The batch's TOP seq is dead-lettered, so the applier's watermark stops below it; the // session's standalone MAX update must still advance the durable watermark and the ack. var a = await NewSideAsync( options: new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20), FailClosedOnDrift = true }); var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { ProtoEntry(1, 1, "CLEAN", 5_000_000), ProtoEntry(2, 2, "DRIFTED", AheadHlc(TimeSpan.FromMinutes(10))), }, }, }, cts.Token); var ack = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token); Assert.Equal(2, ack.DeltaAck.AppliedThruSeq); Assert.Equal(2, await LastAppliedRemoteSeq(a.Db)); var rows = await ReadOrders(a.Db); Assert.Single(rows); Assert.Equal(1, rows[0].Id); var dead = await a.Db.QueryAsync("SELECT pk_json FROM __localdb_dead_letter", x => x.GetString(0)); Assert.Equal(["{\"id\":2}"], dead); cts.Cancel(); await SwallowAsync(run); } [Fact] public async Task AllEntriesDrifted_WatermarkStillAdvances() { // Every entry dead-lettered: the applier sees an empty batch (AppliedThruSeq 0), yet the // durable watermark and the ack must still cover the batch's max seq. var a = await NewSideAsync( options: new ReplicationOptions { FlushInterval = TimeSpan.FromMilliseconds(20), FailClosedOnDrift = true }); var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { DeltaBatch = new DeltaBatch { Entries = { ProtoEntry(1, 1, "DRIFTED1", AheadHlc(TimeSpan.FromMinutes(10))), ProtoEntry(2, 2, "DRIFTED2", AheadHlc(TimeSpan.FromMinutes(11))), }, }, }, cts.Token); var ack = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.DeltaAck, cts.Token); Assert.Equal(2, ack.DeltaAck.AppliedThruSeq); Assert.Equal(2, await LastAppliedRemoteSeq(a.Db)); Assert.Empty(await ReadOrders(a.Db)); var dead = await a.Db.QueryAsync("SELECT pk_json FROM __localdb_dead_letter ORDER BY pk_json", x => x.GetString(0)); Assert.Equal(["{\"id\":1}", "{\"id\":2}"], dead); cts.Cancel(); await SwallowAsync(run); } [Fact] public async Task PeerNeedsSnapshot_InvokesSnapshotHooks() { // (a) THIS node owes the peer a snapshot (its own needs_snapshot) -> our SnapshotSender fires, // and its gated send delegate routes messages through the writer onto the wire. { var a = await NewSideAsync(); await a.Store.SetNeedsSnapshotAsync(true, default); var (duplex, toSession, fromSession) = ScriptedPeer(); var sent = new TaskCompletionSource(); a.Session.SnapshotSender = (send, token) => { sent.TrySetResult(); return send(new SyncMessage { SnapshotComplete = new SnapshotComplete { AsOfSeq = 7 } }, token); }; using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await NextAsync(fromSession, cts.Token); // session Handshake await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token); await NextAsync(fromSession, cts.Token); // session HandshakeAck await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, cts.Token); await sent.Task.WaitAsync(RunTimeout); var complete = await NextOfCaseAsync(fromSession, SyncMessage.MsgOneofCase.SnapshotComplete, cts.Token); Assert.Equal(7, complete.SnapshotComplete.AsOfSeq); cts.Cancel(); await SwallowAsync(run); } // (b) inbound SnapshotBegin -> our ISnapshotApplier.OnBeginAsync fires with the triggering message. { var a = await NewSideAsync(); var (duplex, toSession, fromSession) = ScriptedPeer(); var applier = new CapturingApplier(); a.Session.SnapshotApplier = applier; using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 42 } }, cts.Token); Assert.Equal(42, await applier.BeganAsOf.Task.WaitAsync(RunTimeout)); cts.Cancel(); await SwallowAsync(run); } // (c) this node owes a snapshot but no sender hook -> NotSupportedException. { var a = await NewSideAsync(); await a.Store.SetNeedsSnapshotAsync(true, default); a.Session.SnapshotSender = null; var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await NextAsync(fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token); await NextAsync(fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, cts.Token); await Assert.ThrowsAsync(() => run); } // (d) inbound SnapshotBegin but no applier hook -> NotSupportedException. { var a = await NewSideAsync(); a.Session.SnapshotApplier = null; var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { SnapshotBegin = new SnapshotBegin { AsOfSeq = 0 } }, cts.Token); await Assert.ThrowsAsync(() => run); } } private sealed class CapturingApplier : ISnapshotApplier { public TaskCompletionSource BeganAsOf { get; } = new(); public Task OnBeginAsync(SnapshotBegin begin, CancellationToken ct) { BeganAsOf.TrySetResult(begin.AsOfSeq); return Task.CompletedTask; } public Task OnBatchAsync(SnapshotBatch batch, CancellationToken ct) => Task.CompletedTask; public Task OnCompleteAsync(SnapshotComplete complete, CancellationToken ct) => Task.CompletedTask; } [Fact] public async Task SecondHandshake_MidStream_IsProtocolError() { var a = await NewSideAsync(); var (duplex, toSession, fromSession) = ScriptedPeer(); using var cts = new CancellationTokenSource(RunTimeout); var run = a.Session.RunAsync(duplex, cts.Token); await DriveHandshakeAsync(a.Db, toSession, fromSession, cts.Token); await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(a.Db, "peer") }, cts.Token); await Assert.ThrowsAsync(() => run); } // Completes the two-phase handshake against a real session: consumes its Handshake + HandshakeAck // and replies with a matching Handshake + a no-snapshot HandshakeAck, leaving the session in steady state. private static async Task DriveHandshakeAsync( SqliteLocalDb sessionDb, ChannelWriter toSession, ChannelReader fromSession, CancellationToken ct) { var hs = await NextAsync(fromSession, ct); Assert.Equal(SyncMessage.MsgOneofCase.Handshake, hs.MsgCase); await toSession.WriteAsync(new SyncMessage { Handshake = MatchingHandshake(sessionDb, "peer") }, ct); var ack = await NextAsync(fromSession, ct); Assert.Equal(SyncMessage.MsgOneofCase.HandshakeAck, ack.MsgCase); await toSession.WriteAsync(new SyncMessage { HandshakeAck = new HandshakeAck { NodeId = "peer", SnapshotRequired = false } }, ct); } private sealed class ListLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = []; public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (Entries) Entries.Add((logLevel, formatter(state, exception))); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } }