@@ -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>
0 commit comments