Send the event socket's close code instead of aborting the socket
ci / build and test (push) Successful in 4m22s
ci / android head (push) Successful in 5m16s
ci / api image (push) Failing after 52s
ci / desktop nightly (push) Successful in 4m12s

The events endpoint cancelled its pump token and only then wrote the close
frame. The token was also the one passed to socket.ReceiveAsync and
SendAsync, and cancelling a pending operation on the ManagedWebSocket that
Kestrel hands over aborts it. CloseAsync then saw an Aborted socket and
returned, so in production a client whose token expired got a TCP reset
rather than 4401 "reconnect with a fresh token", and a shutdown sent no
1001 either.

The tests missed it because TestServer's in-memory socket survives that
cancellation: the same scenario through TestServer closes with 4401.

PumpAsync now does what the relay's pump does. No WebSocket operation is
cancelled; the token stops only the outbound queue, the clock and the
access refresh. The sender is let finish its last write, the close goes
out, and the receiver ends on the client's answering close. Each wait is
bounded by the close timeout, and the socket is aborted only when a peer
stops answering.

ApiFixture gains ConnectEventsOverTcpAsync, which runs a second host on
real Kestrel over loopback, sharing the container and identity stub. The
new AnExpiringToken_ClosesTheSocketWith4401 fails with a reset before this
change and passes after it.
This commit is contained in:
jaap-jan committed 2026-09-28 14:53:59 +02:00
1 parent ab432e475e
commit c8848ec1e3
3 files changed
+183 -19

No files matched your search

@@ -304,6 +304,31 @@ public sealed class EventsEndpointTests(ApiFixture fixture)
socket.State.ShouldBe(WebSocketState.Open);
}
// ---- The close ----
[Fact]
public async Task AnExpiringToken_ClosesTheSocketWith4401()
{
// The code is the whole message: 4401 tells a client to reconnect at once with a fresh token,
// where anything else — or a connection that simply drops — tells it to back off. Over real
// Kestrel, because TestServer's in-memory socket survived the server cancelling its own pending
// receive, which is what hid a server that aborted the socket instead of closing it.
var (subject, _) = await SeedUserWithVaultAsync();
// Seconds, not minutes: the bearer handler's clock skew lets a token this close to expiry
// through the upgrade, and the socket's own deadline is the token's exp without skew.
var token = fixture.IdentityProvider.MintToken(
subject, expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddSeconds(2));
using var timeout = Timeout();
using var socket = await fixture.ConnectEventsOverTcpAsync(token, timeout.Token);
var closed = await ReadCloseAsync(socket, timeout.Token);
((int?)closed.CloseStatus).ShouldBe(VaultEvents.TokenExpiredCloseCode);
closed.CloseStatusDescription.ShouldNotBeNullOrEmpty();
}
// ---- Helpers ----
/// <summary>A token that gives up rather than letting a broken build hang the suite.</summary>
@@ -340,6 +365,31 @@ public sealed class EventsEndpointTests(ApiFixture fixture)
return Encoding.UTF8.GetString(buffer, 0, received.Count);
}
/// <summary>Reads past every frame to the server's close, and answers it.</summary>
/// <remarks>
/// A socket the server aborted rather than closed throws here instead of returning, which is the
/// failure this exists to catch.
/// </remarks>
private static async Task<WebSocketReceiveResult> ReadCloseAsync(
WebSocket socket,
CancellationToken cancellationToken)
{
var buffer = new byte[8 * 1024];
while (true)
{
var received = await socket.ReceiveAsync(buffer, cancellationToken);
if (received.MessageType == WebSocketMessageType.Close)
{
await socket.CloseOutputAsync(
WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken);
return received;
}
}
}
/// <summary>Reads past the frames a test does not care about — hello, and heartbeats.</summary>
private static async Task<VaultEvent> ReadUntilAsync(
WebSocket socket,