-
Notifications
You must be signed in to change notification settings - Fork 447
Implement #1156 by having the server send Ping messages #1161
Conversation
| disconnectButton.disabled = false; | ||
| console.log('http://' + document.location.host + '/' + hubRoute); | ||
| connection = new signalR.HubConnection(hubRoute, logger, { transport: transportType, logger: logger }); | ||
| connection = new signalR.HubConnection(hubRoute, { transport: transportType, logging: logger }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mine's better, I fixed all of them :P.
| using System.Threading.Channels; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.AspNetCore.Sockets.Features; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: sort
| public LongPollingOptions LongPolling { get; } = new LongPollingOptions(); | ||
|
|
||
| /// <summary> | ||
| /// The interval at which keep-alive messages should be sent. This will be |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exclamation point at the end This will be! ![]()
|
|
||
| namespace Microsoft.AspNetCore.SignalR.Core.Internal | ||
| { | ||
| internal class KeepAliveChannel : Channel<HubMessage> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not too sure about this..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The thing I like about it is that it keeps the consumption code clean. Ideally, you really want to put keep-alive code in the HubEndPoint.OnConnectionAsync.WriteToTransport local function, since that's the central place messages appear. We could have a background task firing pings, but the goal of the ping is to send one if the timeout elapses while waiting for a message. A fixed interval kinda achieves that, but in a weird way and requires synchronization in order to know the time since the last message.
I tried putting this logic into the consumption side (in WriteToTransport) and it gets complicated fast, because KeepAlive isn't always enabled. Though I have another idea I'm going to try.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This class as a concept or name??
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably the concept, it's a little sketchy and I'm not super happy with it myself.
|
🆙 📅 Tried a different approach to appease @davidfowl (and my own conscience) |
|
Spec update in this PR? |
|
No spec update. The only thing needed in the spec is the Ping frame. I'd argue that keep-alive behaviors are policy/config, not spec. I can look at throwing a sentence or two in though. |
|
It says "Either endpoint may send a Ping message at any time.", I don't see a good reason the client should be sending pings |
|
There are reasons for an implementation to want that. I'd rather keep the spec open. Just because it says an endpoint "may" do something doesn't mean our implementation has to do it. Also, our server code supports receiving (and ignoring) client pings. |
|
:'( easy to shoot yourself in the foot |
|
How so? There's no mechanism in our client to send a Ping message, and the server is tolerant of receiving them. |
|
If someone makes a client exactly like ours except it sends keep-alive, then that will cause the server to stop sending keep-alives and then the client could timeout since it doesn't receive a keep-alive. Not a big problem I guess |
|
Discussed with @BrennanConroy offline, he was slightly confused and we're all good 😃 |
|
To clarify the behavior to avoid confusion: The keep-alive timer is only reset when a message is sent by the server. Receiving messages from the client does not reset the timer. |
specs/HubProtocol.md
Outdated
|
|
||
| Ping messages do not have any payload, they are completely empty messages (aside from the encoding necessary to identify the message as a `Ping` message). | ||
|
|
||
| It is up to the server implementation how frequently (if at all) `Ping` frames are sent. The ASP.NET Core implementation sends `Ping` frames only when using the Server Sent Events and WebSockets transports, at a default interval of 15 seconds (configurable). However, a `Ping` frame is only sent if 15 seconds elapses since the last message was sent. Clients may choose to use the "Ping rate" to provide a timeout for the server connection. Since the Client can expect the server to send `Ping` frames at a regular intervals, even when the connection is idle, it can use that to determine if the server has left without closing the connection. The ASP.NET Core implementation (both JavaScript and C#) use a default timeout window of 30 seconds, which is twice the server ping rate interval. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"implementation to decide how frequently"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"at a regular intervals"
| { | ||
| while (output.Reader.TryRead(out var hubMessage)) | ||
| var keepAliveTask = needKeepAlive ? | ||
| Task.Delay(_hubOptions.KeepAliveInterval) : |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should I be concerned that we new up a Task.Delay every send?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not every send. Every time we need to go async to wait for the app to send. And you have to allocate a task for this no matter how you slice it.
| @@ -0,0 +1,14 @@ | |||
| using System; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
copyright
| @@ -0,0 +1,21 @@ | |||
| using System; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
copy
| public async Task ProcessRequestAsync(HttpContext context, CancellationToken token) | ||
| public async Task ProcessRequestAsync(ConnectionContext connection, HttpContext context, CancellationToken token) | ||
| { | ||
| if(connection.Features.Get<IConnectionInherentKeepAliveFeature>() == null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
space
|
|
||
| // Echo a bunch of stuff, waiting 10ms between each, until 500ms have elapsed | ||
| DateTime start = DateTime.UtcNow; | ||
| while((DateTime.UtcNow - start).TotalMilliseconds <= 500.0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
space
| // We shouldn't have any ping messages | ||
| HubMessage message; | ||
| var counter = 0; | ||
| while((message = await client.ReadAsync()) != null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
space
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OrTimeout
| counter += 1; | ||
| Assert.IsNotType<PingMessage>(message); | ||
| } | ||
| Assert.InRange(counter, 1, Int32.MaxValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe 50 would be better than max value
| using Microsoft.AspNetCore.Sockets.Internal.Transports; | ||
| using Microsoft.Extensions.Logging; | ||
| using Xunit; | ||
| using System; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sort
|
🆙 📅 |
| counter += 1; | ||
| Assert.Same(PingMessage.Instance, message); | ||
| } | ||
| Assert.InRange(counter, 1, Int32.MaxValue); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1 - 5
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm going to do 1-10, because I want to make sure this doesn't flake out occasionally due to random delays here and there. Remember that Task.Delay only guarantees that you won't be woken up before the delay has elapsed; you might be sleeping for longer than the delay.
| /// </remarks> | ||
| public interface IConnectionInherentKeepAliveFeature | ||
| { | ||
| TimeSpan KeepAliveInterval { get; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this property provide any value? It isn't used currently
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It exposes the duration of the inherent keep-alive (i.e. for Long Polling, the poll timeout value). To be honest, this is just used as a marker interface though, so we could drop it. It seemed like a useful value to have though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would cleanup the LongPolling constructor a little :P
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But marker interfaces are groooooooss
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm with @BrennanConroy, I don't think this adds any value. Maybe this interface should have a bool so that keep alives can be manually turned off.
|
🆙 📅 |
BrennanConroy
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was about to complain about AppVeyor but you saw it too :D
| using (var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken, context.RequestAborted)) | ||
| { | ||
| await poll.ProcessRequestAsync(context, cts.Token).OrTimeout(); | ||
| await poll.ProcessRequestAsync(connection, context, cts.Token).OrTimeout().OrTimeout(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove extra OrTimeout
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if the timeout times out?
davidfowl
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I want to discuss this in person before you merge
|
🆙 📅 @davidfowl - This covers some of our in-person discussions. It needs a little clean-up, but the gist is what we talked about. |
| // If it is, we send a ping frame, if not, we no-op on this tick. This means that in the worst case, the | ||
| // true "ping rate" of the server could be (_hubOptions.KeepAliveInterval + HubEndPoint.KeepAliveTimerInterval), | ||
| // because if the interval elapses right after the last tick of this timer, it won't be detected until the next tick. | ||
| void KeepAliveTick(object state) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ugh, local functions. We should avoid capturing state.
| _lastSendTimestamp = Stopwatch.GetTimestamp(); | ||
| var writingOutputTask = WriteToTransport(); | ||
|
|
||
| Timer keepAliveTimer = null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We're going to have wayyyyy too many timers. We need a connection heartbeat design here. The only thing that has a connection list is the lifetime manager though. We need to think a bit more here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought we were going to look at a single timer model.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So did I until I realized how HubEndpoint works when reviewing it this morning :). We need to rethink this quite a bit. I think we need to go back to the slightly gross idea of having the connection manager tick the heartbeat via a feature. The problem there is the config gets gross. The ping rate is always going to be off by at most the tick rate and that gets weird.
|
🆙 📅 after discussions with @davidfowl, tests don't pass yet, but the pings are indeed sent. |
|
Pushed some test fixes, but I'm not happy with it. I think the HubLifetimeManager tests are too low-level and brittle. I want to rewrite them to use HubEndpoint but I don't have the time to do that today, I'll look at it tomorrow. |
|
I gave up trying to make the tests less brittle. It meant refactoring a huge amount of unit tests (because I needed to make TestClient changes to make it clean). I'm sure I could spend more energy and figure out an easier refactor, but I just don't have that kind of time :). 🆙 📅 ready for review again. Tests pass, and we're now using a single heartbeat tick for all connections. |
- This change moves more onto the HubConnectionContext and cleans up the model a bit. The HubConnectionContext is responsible for reading from the transport and exposing a Channel<HubMessage> for both input and output. - This cleans up the HubEndPoint a bit as it's solely responsible for invoking hub messages instead of parsing the bytes off the wire.
162c677 to
84f8279
Compare
@davidfowl said to just merge it :)
This change teaches
HubEndpointto sendPingMessage.Instanceto the client at regular intervals, if no other messages have been sent. But only for SSE and WebSockets.I found a sneaky and possibly ingenious (or possibly terrible) way to implement keep-alive. Since the HubEndpoint already creates a Channel that it uses to marshal writes from multiple writers, I just wrap it in a special
ChannelReaderthat emitsPingMessage.InstanceifWaitToReadAsyncblocks for more than the desired interval.There is a feature,
IConnectionInherentKeepAliveFeature, that indicates if a transport has "inherent keep-alive", like Long Polling (for which the poll itself serves as a way for the client to tell if the server is gone)Notes:
package-lock.jsongot changed, but I sense it will keep changing if we don't check in the changes, they're just ordering changes, no new packages.Fixes #1156
Also fixes #715