From cea84a468eea6acfb0aca7b3a304a99057dd3127 Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 21 Jul 2026 09:42:58 +0200 Subject: [PATCH] fix: resolve channel.Send() hanging on unacknowledged broadcast pushes --- Realtime/RealtimeChannel.cs | 52 +++++++++++++++++++++----- RealtimeTests/ChannelBroadcastTests.cs | 21 ++++++++++- 2 files changed, 61 insertions(+), 12 deletions(-) diff --git a/Realtime/RealtimeChannel.cs b/Realtime/RealtimeChannel.cs index 4d12390..f02782b 100644 --- a/Realtime/RealtimeChannel.cs +++ b/Realtime/RealtimeChannel.cs @@ -541,21 +541,53 @@ public Push Push(string eventName, string? type = null, object? payload = null, /// public Task Send(ChannelEventName eventName, string? type, object payload, int timeoutMs = DefaultTimeout) { - var tsc = new TaskCompletionSource(); + var push = Push(Core.Helpers.GetMappedToAttr(eventName).Mapping, type, payload, timeoutMs); + // The server only sends a `phx_reply` for a `broadcast` push when the channel joined + // with `config.broadcast.ack = true`. Without that, no reply will ever arrive, so + // waiting for one would hang forever - resolve as soon as the push has been dispatched. + return eventName == ChannelEventName.Broadcast && BroadcastOptions?.BroadcastAck != true + ? Task.FromResult(true) + : PushAwaiter.Await(push); + } + + /// + /// Awaits a reply for a given , resolving with whether a known reply was + /// received, or faulting with a if the push times out first. + /// + /// Kept as instance methods (rather than local lambdas referencing each other) so there's no + /// self/mutually-referencing closure over locals that get assigned after the closure is created. + /// + private sealed class PushAwaiter + { + private readonly Push push; + private readonly TaskCompletionSource taskCompletion = new(); + + private PushAwaiter(Push push) + { + this.push = push; + this.push.AddMessageReceivedHandler(HandleMessageReceived); + this.push.OnTimeout += HandleTimeout; + } - var ev = Core.Helpers.GetMappedToAttr(eventName).Mapping; - var push = Push(ev, type, payload, timeoutMs); + public static Task Await(Push push) => new PushAwaiter(push).taskCompletion.Task; - IRealtimePush.MessageEventHandler? messageCallback = null; + private void HandleMessageReceived(IRealtimePush sender, SocketResponse message) + { + Detach(); + taskCompletion.TrySetResult(message.Event != EventType.Unknown); + } - messageCallback = (_, message) => + private void HandleTimeout(object sender, EventArgs e) { - tsc.SetResult(message.Event != EventType.Unknown); - push.RemoveMessageReceivedHandler(messageCallback!); - }; + Detach(); + taskCompletion.TrySetException(new RealtimeException("Push Timeout") { Reason = FailureHint.Reason.PushTimeout }); + } - push.AddMessageReceivedHandler(messageCallback); - return tsc.Task; + private void Detach() + { + push.RemoveMessageReceivedHandler(HandleMessageReceived); + push.OnTimeout -= HandleTimeout; + } } /// diff --git a/RealtimeTests/ChannelBroadcastTests.cs b/RealtimeTests/ChannelBroadcastTests.cs index aa8b3b6..35cc481 100644 --- a/RealtimeTests/ChannelBroadcastTests.cs +++ b/RealtimeTests/ChannelBroadcastTests.cs @@ -37,7 +37,7 @@ public void CleanupTest() _socketClient!.Disconnect(); } - [TestMethod("Channel: Can listen for broadcast")] + [TestMethod(DisplayName = "Channel: Can listen for broadcast")] public async Task ClientCanListenForBroadcast() { var tsc = new TaskCompletionSource(); @@ -75,7 +75,24 @@ public async Task ClientCanListenForBroadcast() await Task.WhenAll(new[] { tsc.Task, tsc2.Task }); } - [TestMethod("Channel: Payload returns a modeled response (if possible)")] + [TestMethod(DisplayName = "Channel: Send resolves when broadcast ack is not explicitly enabled")] + public async Task ChannelSendResolvesWithoutExplicitAck() + { + // Mirrors the most natural usage: `client.Channel(name)` -> `Subscribe()` -> `Send()`, + // with no call to `Register(broadcastAck: true)`. See supabase-community/realtime-csharp#38. + var channel = _socketClient!.Channel("no-ack-broadcast"); + await channel.Subscribe(); + + var sendTask = channel.Send(Supabase.Realtime.Constants.ChannelEventName.Broadcast, "test_event", + new BroadcastExample { UserId = Guid.NewGuid().ToString() }); + + var winner = await Task.WhenAny(sendTask, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.AreSame(sendTask, winner, "channel.Send() did not complete within 5s (issue #38: awaited broadcast send hangs)."); + Assert.IsTrue(await sendTask); + } + + [TestMethod(DisplayName = "Channel: Payload returns a modeled response (if possible)")] public async Task ChannelPayloadReturnsModel() { var tsc = new TaskCompletionSource();