Skip to content

Commit 0958db6

Browse files
authored
Merge pull request #1331 from ably/claude/rtn23-spec-research-ca5ed0
Implement RTN23 idle-transport detection and the recovery spec points around it
2 parents 2cd9a30 + ffb5cf1 commit 0958db6

39 files changed

Lines changed: 3697 additions & 414 deletions

src/IO.Ably.Shared.MsgPack/CustomSerialisers/GeneratedSerializers/IO_Ably_ConnectionDetailsMessageSerializer.cs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public IO_Ably_ConnectionDetailsMessageSerializer(MsgPack.Serialization.Serializ
3535
}
3636

3737
protected override void PackToCore(MsgPack.Packer packer, IO.Ably.ConnectionDetails objectTree) {
38-
packer.PackMapHeader(7);
38+
packer.PackMapHeader(8);
3939
this._serializer0.PackTo(packer, "clientId");
4040
this._serializer0.PackTo(packer, objectTree.ClientId);
4141
this._serializer0.PackTo(packer, "connectionKey");
@@ -44,6 +44,8 @@ protected override void PackToCore(MsgPack.Packer packer, IO.Ably.ConnectionDeta
4444
this._serializer1.PackTo(packer, objectTree.ConnectionStateTtl);
4545
this._serializer0.PackTo(packer, "maxFrameSize");
4646
this._serializer2.PackTo(packer, objectTree.MaxFrameSize);
47+
this._serializer0.PackTo(packer, "maxIdleInterval");
48+
this._serializer1.PackTo(packer, objectTree.MaxIdleInterval);
4749
this._serializer0.PackTo(packer, "maxInboundRate");
4850
this._serializer2.PackTo(packer, objectTree.MaxInboundRate);
4951
this._serializer0.PackTo(packer, "maxMessageSize");
@@ -112,6 +114,33 @@ protected override IO.Ably.ConnectionDetails UnpackFromCore(MsgPack.Unpacker unp
112114
result.MaxFrameSize = nullable2.Value;
113115
}
114116
unpacked = (unpacked + 1);
117+
System.Nullable<System.TimeSpan> nullableMaxIdleInterval = default(System.Nullable<System.TimeSpan>);
118+
if ((unpacked < itemsCount)) {
119+
if ((unpacker.Read() == false)) {
120+
throw MsgPack.Serialization.SerializationExceptions.NewMissingItem(4);
121+
}
122+
if (((unpacker.IsArrayHeader == false)
123+
&& (unpacker.IsMapHeader == false))) {
124+
nullableMaxIdleInterval = this._serializer1.UnpackFrom(unpacker);
125+
}
126+
else {
127+
MsgPack.Unpacker disposableMaxIdleInterval = default(MsgPack.Unpacker);
128+
disposableMaxIdleInterval = unpacker.ReadSubtree();
129+
try {
130+
nullableMaxIdleInterval = this._serializer1.UnpackFrom(disposableMaxIdleInterval);
131+
}
132+
finally {
133+
if (((disposableMaxIdleInterval == null)
134+
== false)) {
135+
disposableMaxIdleInterval.Dispose();
136+
}
137+
}
138+
}
139+
}
140+
if (nullableMaxIdleInterval.HasValue) {
141+
result.MaxIdleInterval = nullableMaxIdleInterval;
142+
}
143+
unpacked = (unpacked + 1);
115144
System.Nullable<long> nullable3 = default(System.Nullable<long>);
116145
if ((unpacked < itemsCount)) {
117146
nullable3 = MsgPack.Serialization.UnpackHelpers.UnpackNullableInt64Value(unpacker, typeof(IO.Ably.ConnectionDetails), "Int64 maxInboundRate");
@@ -230,7 +259,35 @@ protected override IO.Ably.ConnectionDetails UnpackFromCore(MsgPack.Unpacker unp
230259
}
231260
}
232261
else {
233-
unpacker.Skip();
262+
if ((key == "maxIdleInterval")) {
263+
System.Nullable<System.TimeSpan> nullableMaxIdle = default(System.Nullable<System.TimeSpan>);
264+
if ((unpacker.Read() == false)) {
265+
throw MsgPack.Serialization.SerializationExceptions.NewMissingItem(i);
266+
}
267+
if (((unpacker.IsArrayHeader == false)
268+
&& (unpacker.IsMapHeader == false))) {
269+
nullableMaxIdle = this._serializer1.UnpackFrom(unpacker);
270+
}
271+
else {
272+
MsgPack.Unpacker disposableMaxIdle = default(MsgPack.Unpacker);
273+
disposableMaxIdle = unpacker.ReadSubtree();
274+
try {
275+
nullableMaxIdle = this._serializer1.UnpackFrom(disposableMaxIdle);
276+
}
277+
finally {
278+
if (((disposableMaxIdle == null)
279+
== false)) {
280+
disposableMaxIdle.Dispose();
281+
}
282+
}
283+
}
284+
if (nullableMaxIdle.HasValue) {
285+
result.MaxIdleInterval = nullableMaxIdle;
286+
}
287+
}
288+
else {
289+
unpacker.Skip();
290+
}
234291
}
235292
}
236293
}

src/IO.Ably.Shared/AblyAuth.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,27 @@ public virtual async Task<TokenDetails> RequestTokenAsync(TokenParams tokenParam
305305
bool shouldCatch = true;
306306
try
307307
{
308-
var callbackResult = await authOptions.AuthCallback(tokenParams);
308+
// RSA4c bounds an auth attempt by realtimeRequestTimeout. This one is awaited on
309+
// the realtime workflow's single reader thread, so an authCallback that never
310+
// returns would stall channel state, inbound messages and the idle monitor.
311+
//
312+
// Task.Run rather than awaiting the delegate directly: TimeoutAfter extends an
313+
// already-created Task, so a callback whose body runs synchronously - the most
314+
// ordinary shape in C# - would block before there is anything to bound. It
315+
// cannot cancel the callback; an abandoned one runs to completion.
316+
var callbackResult = await Task.Run(() => authOptions.AuthCallback(tokenParams))
317+
.TimeoutAfter(Options.RealtimeRequestTimeout, null);
309318

310319
if (callbackResult == null)
311320
{
312-
throw new AblyException("AuthCallback returned null", ErrorCodes.ClientAuthProviderRequestFailed);
321+
// Covers a callback that returned null and one that timed out - TimeoutAfter
322+
// yields null for both, and the wrapper below replaces the message anyway.
323+
// ClientCallbackError, not ClientAuthProviderRequestFailed: the wrapper
324+
// rethrows as 80019 per RSA4c1 with this as the cause, so 80019 here would
325+
// duplicate its own parent. ably-js uses 40170 for the same case.
326+
throw new AblyException(
327+
$"AuthCallback returned null or did not return within {Options.RealtimeRequestTimeout.TotalSeconds}s",
328+
ErrorCodes.ClientCallbackError);
313329
}
314330

315331
if (callbackResult is TokenDetails)
@@ -341,12 +357,17 @@ public virtual async Task<TokenDetails> RequestTokenAsync(TokenParams tokenParam
341357
: HttpStatusCode.Unauthorized;
342358
}
343359

360+
// RSA4c1 wants the cause "set to the underlying cause". The four argument overload
361+
// assigns InnerException instead, which is not the spec's field and is not
362+
// serialised as one.
344363
throw new AblyException(
345364
new ErrorInfo(
346365
"Error calling AuthCallback, token request failed. See inner exception for details.",
347366
ErrorCodes.ClientAuthProviderRequestFailed,
348367
statusCode,
349-
ex),
368+
href: null,
369+
cause: (ex as AblyException)?.ErrorInfo ?? new ErrorInfo(ex.Message),
370+
innerException: ex),
350371
ex);
351372
}
352373
}

src/IO.Ably.Shared/AuthOptions.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ public class AuthOptions
1313
/// <summary>
1414
/// The callback used to get a new <see cref="IO.Ably.TokenDetails"/> or <see cref="IO.Ably.TokenRequest"/>.
1515
/// AuthCallback is used by internally by <see cref="IO.Ably.AblyAuth"/>.RequestTokenAsync.
16+
/// <para>
17+
/// The callback is bounded by <see cref="ClientOptions.RealtimeRequestTimeout"/> per RSA4c,
18+
/// but cannot be cancelled: one that overruns is abandoned and keeps running, so a later auth
19+
/// attempt may invoke it again concurrently and implementations must tolerate that. A result
20+
/// returned after the bound is discarded, and each abandoned invocation holds a thread pool
21+
/// worker until it returns.
22+
/// </para>
1623
/// </summary>
1724
public Func<TokenParams, Task<object>> AuthCallback { get; set; }
1825

src/IO.Ably.Shared/ClientOptions.cs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,54 @@ public bool UseBinaryProtocol
295295
/// </summary>
296296
public TimeSpan ChannelRetryTimeout { get; set; } = Defaults.ChannelRetryTimeout;
297297

298+
private TimeSpan _realtimeRequestTimeout = Defaults.RealtimeRequestTimeout;
299+
private int _heartbeatMonitorDelay = 1000;
300+
301+
/// <summary>
302+
/// How long the library waits for Ably to answer before treating a realtime request as
303+
/// having failed. Applies while establishing a connection, while awaiting a response to a
304+
/// Heartbeat, Connect, Attach, Detach or Close, and as part of the RTN23a idle timeout.
305+
/// Default: 10s. Must be at least one millisecond, and values beyond a minute or so are
306+
/// rarely useful. Disabling the timeout is not supported - the timeouts this value drives are
307+
/// all required to fire - so both Timeout.InfiniteTimeSpan and TimeSpan.MaxValue are rejected.
308+
/// TO3l11 - https://sdk.ably.com/builds/ably/specification/main/features/#TO3l11.
309+
/// </summary>
310+
/// <exception cref="ArgumentOutOfRangeException">when set below one millisecond, or to an
311+
/// interval too large for the underlying timers to fire.</exception>
312+
public TimeSpan RealtimeRequestTimeout
313+
{
314+
get => _realtimeRequestTimeout;
315+
316+
set
317+
{
318+
// The lower bound is one millisecond, not zero. CountdownTimer hands the delay to
319+
// System.Threading.Timer as (int)TotalMilliseconds, so anything under a millisecond
320+
// truncates to a zero delay timer - the same hot loop a literal zero produces, and
321+
// just as quiet. A negative value stops the timer firing at all, and
322+
// Timeout.InfiniteTimeSpan is -1ms, which matters because both Task.Delay and
323+
// System.Threading.Timer accept it as genuine infinity and it would disable RTN14c,
324+
// RTN12b, RTL4f, RTL5f and RSA4c outright.
325+
//
326+
// The upper bound is the tightest limit across the sinks this value reaches on every
327+
// framework shipped: Task.Delay allows uint.MaxValue - 1 ms on .NET 6+ but only
328+
// Int32.MaxValue on .NET Framework, Mono and Xamarin, and CountdownTimer casts to
329+
// int for System.Threading.Timer. So Int32.MaxValue ms - a bound on the arithmetic,
330+
// not a supported configuration, which is why the message names the useful range.
331+
if (value < TimeSpan.FromMilliseconds(1) || value.TotalMilliseconds > int.MaxValue)
332+
{
333+
throw new ArgumentOutOfRangeException(
334+
nameof(RealtimeRequestTimeout),
335+
value,
336+
"RealtimeRequestTimeout must be at least one millisecond. The default is 10s; " +
337+
"values beyond a minute or so are rarely useful. Disabling the timeout is " +
338+
"not supported - the connect, close, attach, detach and auth timeouts it " +
339+
"drives are all required to fire.");
340+
}
341+
342+
_realtimeRequestTimeout = value;
343+
}
344+
}
345+
298346
/// <summary>
299347
/// Timeout for opening an http request.
300348
/// Default: 4s.
@@ -390,7 +438,32 @@ public bool UseBinaryProtocol
390438
/// connection has been lost.
391439
/// Defaults: 1000.
392440
/// </summary>
393-
public int HeartbeatMonitorDelay { get; set; } = 1000;
441+
public int HeartbeatMonitorDelay
442+
{
443+
get => _heartbeatMonitorDelay;
444+
445+
set
446+
{
447+
// This is the granularity of RTN23a idle detection, and the monitor driving it is a
448+
// fire-and-forget loop, so a value it cannot wait on takes detection out for the life
449+
// of the client. Zero is a hot loop, queueing a command per scheduler tick. Minus one
450+
// is Timeout.Infinite, which Task.Delay accepts as genuine infinity, so the monitor
451+
// ticks once and is then silent for good - nothing thrown, nothing logged. Below
452+
// minus one it throws inside the loop instead. None of the three can be what a caller
453+
// meant, so this rejects rather than clamping and quietly overriding them.
454+
if (value < 1)
455+
{
456+
throw new ArgumentOutOfRangeException(
457+
nameof(HeartbeatMonitorDelay),
458+
value,
459+
"HeartbeatMonitorDelay must be at least one millisecond. The default is 1000. " +
460+
"It is how often RTN23a idle detection is evaluated, so a large value delays " +
461+
"noticing a dead connection, and turning it off is not supported.");
462+
}
463+
464+
_heartbeatMonitorDelay = value;
465+
}
466+
}
394467

395468
/// <summary>
396469
/// If enabled, every REST request to Ably includes a `request_id` query string parameter.
@@ -436,8 +509,6 @@ internal Func<DateTimeOffset> NowFunc
436509

437510
internal bool SkipInternetCheck { get; set; }
438511

439-
internal TimeSpan RealtimeRequestTimeout { get; set; } = Defaults.RealtimeRequestTimeout;
440-
441512
/// <summary>
442513
/// Default constructor for ClientOptions.
443514
/// </summary>

src/IO.Ably.Shared/Realtime/Connection.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,22 @@ private void HandleNetworkStateChange(NetworkState state)
178178
/// <summary>
179179
/// Connection#CreateRecoveryKey is an attribute composed of the connectionKey, messageSerial and channelSerials (RTN16g, RTN16g1, RTN16h).
180180
/// </summary>
181-
/// <returns>recoveryKey.</returns>
181+
/// <returns>
182+
/// The recovery key, or <see cref="string.Empty"/> where RTN16g3 calls for null. This SDK
183+
/// returns empty strings rather than nulls for absent string values throughout, and callers
184+
/// pass the result straight back as ClientOptions.Recover, which treats the two alike - so
185+
/// returning null instead would break every consumer testing the result with IsNotEmpty for
186+
/// no behavioural gain. ably-js returns null here.
187+
/// </returns>
182188
public string CreateRecoveryKey()
183189
{
190+
// RTN16g3, which replaces RTN16g2 as of specification 6.1.0 - null in CLOSED, CLOSING
191+
// and FAILED, and SUSPENDED is deliberately not among them. RTN8d and RTN9d keep the
192+
// key through SUSPENDED because RTN14h always attempts a resume, so the connection is
193+
// still recoverable there and the key has to be available to hand over.
184194
if (Key.IsEmpty() || InnerState.State == Realtime.ConnectionState.Closing
185195
|| InnerState.State == Realtime.ConnectionState.Closed
186-
|| InnerState.State == Realtime.ConnectionState.Failed
187-
|| InnerState.State == Realtime.ConnectionState.Suspended)
196+
|| InnerState.State == Realtime.ConnectionState.Failed)
188197
{
189198
return string.Empty;
190199
}

src/IO.Ably.Shared/Realtime/Presence.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,11 +792,22 @@ private void SendQueuedMessages()
792792

793793
private void FailQueuedMessages(ErrorInfo reason)
794794
{
795+
// RTL11 wants "an ErrorInfo indicating the failure". TaskWrapper has no branch for
796+
// (false, null) and faults the task with a bare Exception, so the reason is coalesced
797+
// here rather than at each call site.
798+
var error = reason ?? ErrorInfo.ReasonUnknown;
799+
795800
while (!PendingPresenceQueue.IsEmpty)
796801
{
797802
if (PendingPresenceQueue.TryDequeue(out var queuedPresenceMessage))
798803
{
799-
queuedPresenceMessage.Callback?.Invoke(false, reason);
804+
// Guarded like every other callback site: a throwing application callback would
805+
// otherwise abort the rest of the queue and skip the RTP5a map clearing in
806+
// ChannelDetachedOrFailed, which RealtimeChannels then swallows per channel.
807+
ActionUtils.SafeExecute(
808+
() => queuedPresenceMessage.Callback?.Invoke(false, error),
809+
Logger,
810+
nameof(FailQueuedMessages));
800811
}
801812
}
802813
}

0 commit comments

Comments
 (0)