Repository: JasperFx/wolverine (2026-07-19T23:45:47Z)

Issue:
SignalR: ResponseToCallingWebSocket is silently dropped when the handler's context has already flushed (explicit SaveChangesAsync + MultiFlushMode.OnlyOnce)
## Symptom

A handler that receives a message over the SignalR transport and returns `msg.RespondToCallingWebSocket()` as a cascading response works — until the handler also writes through a Marten-outboxed `IDocumentSession` and calls `SaveChangesAsync()` explicitly before returning. Then the response is **silently lost**: no send, no log, no error. The calling websocket client waits forever.

## Mechanism

1. The handler's `MessageContext` is enlisted in the Marten outbox; the explicit `SaveChangesAsync()` drains it and marks the context as flushed.
2. The handler returns the `ResponseToCallingWebSocket<T>` wrapper; codegen calls `EnqueueCascadingAsync`, which invokes `ISendMyself.ApplyAsync(context)` — the wrapper sends through the **same, already-flushed context** (`context.EndpointFor(se.Destination!).SendAsync(...)`), landing the envelope in `_outstanding` with `Status=Outgoing`.
3. The pipeline's post-handler `FlushOutgoingMessagesAsync()` hits the `_hasFlushed` guard with `MultiFlushMode.OnlyOnce` and **returns without sending** (MessageContext.FlushOutgoingMessagesAsync, the `OnlyOnce: return;` arm).

The envelope is never sent and nothing reports the drop.

Verified against 6.20.0 by probing `context.Outstanding` after `ApplyAsync` — the response sits there as `Outgoing` while the incoming message is marked successful.

## Repro shape

```csharp
public static async Task<object?> Handle(SomeCommand cmd, IDocumentSession session, Envelope envelope)
{
    session.Events.Append("stream", new SomethingHappened(...));
    await session.SaveChangesAsync();               // <- drains/flushes the outboxed context

    return new Ack(...).RespondToCallingWebSocket(); // <- silently dropped
}
```

Remove the `SaveChangesAsync()` (or make the handler save nothing) and the response is delivered.

## Impact

Any request/response contract over the SignalR transport where the responder also persists — in CritterWatch this hit the operator-command dispatch acknowledgements (browser waited out a 15s timeout on every successful command; the loss was invisible server-side).

## Workaround in the consuming app

A flush-safe `ISendMyself` that sends through a **fresh** context:

```csharp
async ValueTask ISendMyself.ApplyAsync(IMessageContext context)
{
    if (context.Envelope is not SignalREnvelope se) return;
    var fresh = new MessageContext(((MessageContext)context).Runtime);
    await fresh.EndpointFor(se.Destination!).SendAsync(Message, new DeliveryOptions
    {
        RoutingInformation = new WebSocketRouting.Connection(se.ConnectionId)
    });
    await fresh.FlushOutgoingMessagesAsync();
}
```

## Suggested fixes (either/both)

- `ResponseToCallingWebSocket<T>.ApplyAsync` sends + flushes via a fresh `MessageContext` (it is a per-connection UI response — transactional coupling with the handler's outbox is arguably wrong for it anyway); and/or
- `FlushOutgoingMessagesAsync` under `MultiFlushMode.OnlyOnce` should at least log a warning when `_outstanding` is non-empty on the early return — a silent drop of enqueued envelopes is the worst of the options.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016b8whRArjBatjqKuaW1QCB


Discussion:

Patch:
diff --git a/src/Transports/SignalR/Wolverine.SignalR/ResponseToCallingWebSocket.cs b/src/Transports/SignalR/Wolverine.SignalR/ResponseToCallingWebSocket.cs
index 59491ce74..7907c4b0e 100644
--- a/src/Transports/SignalR/Wolverine.SignalR/ResponseToCallingWebSocket.cs
+++ b/src/Transports/SignalR/Wolverine.SignalR/ResponseToCallingWebSocket.cs
@@ -1,3 +1,4 @@
+using Wolverine.Runtime;
 using Wolverine.SignalR.Internals;
 
 namespace Wolverine.SignalR;
@@ -10,16 +11,35 @@ namespace Wolverine.SignalR;
 /// <typeparam name="T"></typeparam>
 public record ResponseToCallingWebSocket<T>(T Message) : ISendMyself
 {
-    ValueTask ISendMyself.ApplyAsync(IMessageContext context)
+    async ValueTask ISendMyself.ApplyAsync(IMessageContext context)
     {
         if (context.Envelope is SignalREnvelope se)
         {
+            var options = new DeliveryOptions
+                { RoutingInformation = new WebSocketRouting.Connection(se.ConnectionId) };
+
+            // GH-3499 -- if the handler's context has already flushed its outgoing
+            // messages (e.g. an explicit SaveChangesAsync() on an outboxed session
+            // drained it), enqueueing here would be silently dropped by the
+            // MultiFlushMode.OnlyOnce guard. Send through a fresh context instead
+            if (context is MessageContext { HasFlushed: true } flushed)
+            {
+                var fresh = new MessageContext(flushed.Runtime)
+                {
+                    TenantId = flushed.TenantId,
+                    CorrelationId = flushed.CorrelationId
+                };
+
+                await fresh.EndpointFor(se.Destination!).SendAsync(Message, options);
+                await fresh.FlushOutgoingMessagesAsync();
+                return;
+            }
+
             // This gets us back to the same SignalR Hub
-            return context
-                .EndpointFor(se.Destination!)
-                .SendAsync(Message, new DeliveryOptions { RoutingInformation = new WebSocketRouting.Connection(se.ConnectionId) });
+            await context.EndpointFor(se.Destination!).SendAsync(Message, options);
+            return;
         }
 
         throw new InvalidWolverineSignalROperationException("The current message was not received from SignalR");
     }
-}
\ No newline at end of file
+}
diff --git a/src/Transports/SignalR/Wolverine.SignalR/SignalRMessage.cs b/src/Transports/SignalR/Wolverine.SignalR/SignalRMessage.cs
index 8f5b5d250..42ff29cf3 100644
--- a/src/Transports/SignalR/Wolverine.SignalR/SignalRMessage.cs
+++ b/src/Transports/SignalR/Wolverine.SignalR/SignalRMessage.cs
@@ -1,3 +1,4 @@
+using Wolverine.Runtime;
 using Wolverine.SignalR.Internals;
 
 namespace Wolverine.SignalR;
@@ -11,9 +12,27 @@ namespace Wolverine.SignalR;
 /// <typeparam name="T"></typeparam>
 public record SignalRMessage<T>(T Message, WebSocketRouting.ILocator Locator) : ISendMyself
 {
-    ValueTask ISendMyself.ApplyAsync(IMessageContext context)
+    async ValueTask ISendMyself.ApplyAsync(IMessageContext context)
     {
-        return context.PublishAsync(Message,
-            new DeliveryOptions() { SagaId = Locator.ToString(), RoutingInformation = Locator });
+        var options = new DeliveryOptions { SagaId = Locator.ToString(), RoutingInformation = Locator };
+
+        // GH-3499 -- if the handler's context has already flushed its outgoing
+        // messages (e.g. an explicit SaveChangesAsync() on an outboxed session
+        // drained it), enqueueing here would be silently dropped by the
+        // MultiFlushMode.OnlyOnce guard. Send through a fresh context instead
+        if (context is MessageContext { HasFlushed: true } flushed)
+        {
+            var fresh = new MessageContext(flushed.Runtime)
+            {
+                TenantId = flushed.TenantId,
+                CorrelationId = flushed.CorrelationId
+            };
+
+            await fresh.PublishAsync(Message, options);
+            await fresh.FlushOutgoingMessagesAsync();
+            return;
+        }
+
+        await context.PublishAsync(Message, options);
     }
-}
\ No newline at end of file
+}
diff --git a/src/Wolverine/AssemblyAttributes.cs b/src/Wolverine/AssemblyAttributes.cs
index d274ac304..8870fab70 100644
--- a/src/Wolverine/AssemblyAttributes.cs
+++ b/src/Wolverine/AssemblyAttributes.cs
@@ -52,6 +52,7 @@
 [assembly: InternalsVisibleTo("Wolverine.Nats")]
 [assembly: InternalsVisibleTo("Wolverine.MQTT")]
 [assembly: InternalsVisibleTo("Wolverine.Redis")]
+[assembly: InternalsVisibleTo("Wolverine.SignalR")]
 [assembly: InternalsVisibleTo("Wolverine.Pubsub")]
 [assembly: InternalsVisibleTo("MetricsTests")]
 [assembly: InternalsVisibleTo("Wolverine.MySql")]
diff --git a/src/Wolverine/Runtime/MessageContext.cs b/src/Wolverine/Runtime/MessageContext.cs
index a832800c3..ef4d6f232 100644
--- a/src/Wolverine/Runtime/MessageContext.cs
+++ b/src/Wolverine/Runtime/MessageContext.cs
@@ -43,6 +43,15 @@ public class MessageContext : MessageBus, IMessageContext, IHasTenantId, IEnvelo
     private bool _hasFlushed;
     private object? _sagaId;
 
+    /// <summary>
+    /// Has this context already flushed its outgoing messages? When true, any
+    /// envelope enqueued afterward will be dropped by the default
+    /// MultiFlushMode.OnlyOnce guard in FlushOutgoingMessagesAsync(). Cascading
+    /// wrappers that must deliver regardless (e.g. SignalR responses, GH-3499)
+    /// check this to reroute through a fresh context
+    /// </summary>
+    internal bool HasFlushed => _hasFlushed;
+
     public MessageContext(IWolverineRuntime runtime) : base(runtime)
     {
     }
@@ -142,6 +151,18 @@ public async Task FlushOutgoingMessagesAsync()
             switch (MultiFlushMode)
             {
                 case MultiFlushMode.OnlyOnce:
+                    lock (_outstandingLock)
+                    {
+                        if (_outstanding.Count > 0)
+                        {
+                            // GH-3499 -- a silent drop of enqueued envelopes is the worst outcome,
+                            // so at least make the loss visible
+                            Runtime.Logger.LogWarning(
+                                "MessageContext for {MessageType} has already flushed its outgoing messages, so {Count} envelope(s) enqueued after that flush will not be sent. This can happen when messages are enqueued after an explicit transaction commit (e.g. SaveChangesAsync()) has already drained the context. Consider MultiFlushMode.AllowMultiples if multiple flushes are expected",
+                                Envelope?.MessageType, _outstanding.Count);
+                        }
+                    }
+
                     return;
 
                 case MultiFlushMode.AllowMultiples:


Test patch:
diff --git a/src/Transports/SignalR/Wolverine.SignalR.Tests/respond_after_context_has_flushed.cs b/src/Transports/SignalR/Wolverine.SignalR.Tests/respond_after_context_has_flushed.cs
new file mode 100644
index 000000000..59d3cd31b
--- /dev/null
+++ b/src/Transports/SignalR/Wolverine.SignalR.Tests/respond_after_context_has_flushed.cs
@@ -0,0 +1,51 @@
+using System.Diagnostics;
+using Shouldly;
+using Wolverine.Runtime;
+using Wolverine.Tracking;
+
+namespace Wolverine.SignalR.Tests;
+
+public class respond_after_context_has_flushed : WebSocketTestContext
+{
+    // GH-3499 -- a handler whose MessageContext has already flushed (e.g. an
+    // explicit SaveChangesAsync() on an outboxed session drains the context)
+    // was silently dropping the ResponseToCallingWebSocket cascading response
+    [Fact]
+    public async Task response_is_still_delivered_when_the_context_already_flushed()
+    {
+        var green = await StartClientHost("green");
+        var red = await StartClientHost("red");
+
+        var tracked = await red.TrackActivity()
+            .IncludeExternalTransports()
+            .AlsoTrack(theWebApp)
+            .SendMessageAndWaitAsync(new RequiresResponseAfterFlush("Josh Allen"));
+
+        var record = tracked.Executed.SingleRecord<ResponseAfterFlush>();
+
+        // Verify that the response still went to the original calling client
+        record.ServiceName.ShouldBe("red");
+        record.Message.ShouldBeOfType<ResponseAfterFlush>().Name.ShouldBe("Josh Allen");
+    }
+}
+
+public record RequiresResponseAfterFlush(string Name) : WebSocketMessage;
+public record ResponseAfterFlush(string Name) : WebSocketMessage;
+
+public static class ResponseAfterFlushHandler
+{
+    public static async Task<ResponseToCallingWebSocket<ResponseAfterFlush>> Handle(
+        RequiresResponseAfterFlush msg, IMessageContext context)
+    {
+        // Simulate what an explicit SaveChangesAsync() on an outboxed session
+        // does to the context: enqueue an outgoing message, then drain the
+        // context so its OnlyOnce flush guard latches before the cascading
+        // response is applied
+        await context.PublishAsync(new FromFirst(msg.Name));
+        await ((MessageContext)context).FlushOutgoingMessagesAsync();
+
+        return new ResponseAfterFlush(msg.Name).RespondToCallingWebSocket();
+    }
+
+    public static void Handle(ResponseAfterFlush msg) => Debug.WriteLine(msg);
+}