< Summary

Information
Class: dotnet_etcd.Watcher
Assembly: dotnet-etcd
File(s): /home/runner/work/dotnet-etcd/dotnet-etcd/dotnet-etcd/watchclient/WatchStream.cs
Line coverage
86%
Covered lines: 63
Uncovered lines: 10
Coverable lines: 73
Total lines: 166
Line coverage: 86.3%
Branch coverage
66%
Covered branches: 8
Total branches: 12
Branch coverage: 66.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%22100%
CreateWatchAsync()100%1169.23%
CancelWatchAsync()100%11100%
WriteAsync()100%11100%
ProcessWatchResponses()60%111081.25%
Dispose()100%11100%

File(s)

/home/runner/work/dotnet-etcd/dotnet-etcd/dotnet-etcd/watchclient/WatchStream.cs

#LineLine coverage
 1#nullable enable
 2using System;
 3using System.Collections.Concurrent;
 4using System.Threading;
 5using System.Threading.Tasks;
 6using dotnet_etcd.interfaces;
 7using Etcdserverpb;
 8using Grpc.Core;
 9
 10namespace dotnet_etcd;
 11
 12/// <summary>
 13///     Manages a bidirectional streaming connection to the etcd watch API
 14/// </summary>
 15public class Watcher : IWatcher
 16{
 7217    private readonly ConcurrentDictionary<long, Action<WatchResponse>> _callbacks = new();
 7218    private readonly CancellationTokenSource _cts = new();
 19
 20    /// <summary>
 21    ///     gRPC allows only one pending write per stream ("Only one write can be pending at a time").
 22    ///     Creates and cancels are issued from user threads and from the reconnect loop, so the writes
 23    ///     must be serialized. The lock covers the write only — never a wait for a server response, or
 24    ///     a create awaiting its acknowledgement would block every cancel and reconnect behind it.
 25    /// </summary>
 7226    private readonly SemaphoreSlim _writeLock = new(1, 1);
 27
 28    private readonly IAsyncDuplexStreamingCall<WatchRequest, WatchResponse> _streamingCall;
 29    private readonly Action? _onConnectionFailure;
 30
 31
 32    /// <summary>
 33    ///     Creates a new Watcher
 34    /// </summary>
 35    /// <param name="streamingCall">The streaming call to use</param>
 36    /// <param name="onConnectionFailure">Action to invoke when connection fails</param>
 7237    public Watcher(IAsyncDuplexStreamingCall<WatchRequest, WatchResponse> streamingCall, Action? onConnectionFailure = n
 7238    {
 7239        _streamingCall = streamingCall ?? throw new ArgumentNullException(nameof(streamingCall));
 7140        _onConnectionFailure = onConnectionFailure;
 7141        _ = ProcessWatchResponses();
 7142    }
 43
 44
 45    /// <summary>
 46    ///     Creates a watch for the specified request
 47    /// </summary>
 48    /// <param name="request">The watch request</param>
 49    /// <param name="callback">The callback to invoke when a watch event is received</param>
 50    /// <returns>A task that completes when the watch is created</returns>
 51    public async Task CreateWatchAsync(WatchRequest request, Action<WatchResponse> callback)
 8352    {
 8353        ArgumentNullException.ThrowIfNull(request);
 54
 8255        ArgumentNullException.ThrowIfNull(callback);
 56
 8157        long watchId = request.CreateRequest.WatchId;
 58
 59        // Register before writing: the server can answer before WriteAsync returns.
 8160        _callbacks[watchId] = callback;
 61
 62        try
 8163        {
 8164            await WriteAsync(request).ConfigureAwait(false);
 8165        }
 066        catch
 067        {
 68            // The create never reached the server; don't leave a callback behind for a watch that
 69            // does not exist.
 070            _callbacks.TryRemove(watchId, out _);
 071            throw;
 72        }
 8173    }
 74
 75    /// <summary>
 76    ///     Cancels a watch with the specified ID
 77    /// </summary>
 78    /// <param name="watchId">The ID of the watch to cancel</param>
 79    /// <returns>A task that completes when the watch is canceled</returns>
 80    public async Task CancelWatchAsync(long watchId)
 281    {
 82        // Send a cancel request
 283        WatchRequest request = new() { CancelRequest = new WatchCancelRequest { WatchId = watchId } };
 84
 285        await WriteAsync(request).ConfigureAwait(false);
 86
 87        // Remove the callback
 288        _callbacks.TryRemove(watchId, out _);
 289    }
 90
 91    private async Task WriteAsync(WatchRequest request)
 8392    {
 8393        await _writeLock.WaitAsync(_cts.Token).ConfigureAwait(false);
 94        try
 8395        {
 8396            await _streamingCall.RequestStream.WriteAsync(request).ConfigureAwait(false);
 8397        }
 98        finally
 8399        {
 83100            _writeLock.Release();
 83101        }
 83102    }
 103
 104    private async Task ProcessWatchResponses()
 71105    {
 106        try
 71107        {
 226108            while (await _streamingCall.ResponseStream.MoveNext(_cts.Token))
 155109            {
 155110                WatchResponse response = _streamingCall.ResponseStream.Current;
 155111                if (!_callbacks.TryGetValue(response.WatchId, out Action<WatchResponse>? cb))
 9112                {
 9113                    continue;
 114                }
 115
 146116                cb(response);
 117
 118                // If the watch was canceled, remove the callback after invoking it
 146119                if (response.Canceled)
 2120                {
 2121                    _callbacks.TryRemove(response.WatchId, out _);
 2122                }
 146123            }
 0124        }
 11125        catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
 1126        {
 127            // This is expected when the stream is canceled
 1128        }
 34129        catch (OperationCanceledException)
 34130        {
 131            // This is expected when the token is canceled
 34132        }
 10133        catch (RpcException ex)
 10134        {
 135            // Log a simplified message for expected connection failures
 10136            Console.WriteLine($"Watch stream connection lost: {ex.StatusCode} - {ex.Message}");
 10137            _onConnectionFailure?.Invoke();
 10138        }
 0139        catch (Exception ex)
 0140        {
 141            // Log the exception
 0142            await Console.Error.WriteAsync($"Error processing watch responses: {ex}");
 0143            _onConnectionFailure?.Invoke();
 144#if DEBUG
 145            // Only re-throw in debug mode to help with debugging
 0146            throw;
 147#endif
 148        }
 45149    }
 150
 151    /// <summary>
 152    ///     Disposes the watch stream
 153    /// </summary>
 154    public void Dispose()
 45155    {
 45156        _cts.Cancel();
 45157        _streamingCall.Dispose();
 158
 159        // Deliberately not disposing _writeLock/_cts: a write may be in flight, and disposing them
 160        // underneath it would surface as an ObjectDisposedException from inside the semaphore instead
 161        // of the stream's own cancellation. Neither holds an unmanaged resource here, so letting the
 162        // GC reclaim them is safe.
 163
 45164        GC.SuppressFinalize(this);
 45165    }
 166}