You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by gk...@apache.org on 2026/08/17 18:37:00 UTC

(iggy) branch master updated: feat(csharp): add heartbeat mechanism (#3894)

This is an automated email from the ASF dual-hosted git repository.

numinnex pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 1890c35f3 feat(csharp): add heartbeat mechanism (#3894)
1890c35f3 is described below

commit 1890c35f3a0cdd7766d68eef0fc297d33941f505
Author: Ɓukasz Zborek <lu...@gmail.com>
AuthorDate: Mon Aug 17 20:36:55 2026 +0200

    feat(csharp): add heartbeat mechanism (#3894)
    
    - TCP client heartbeat: PeriodicTimer loop pings server every
    HeartbeatInterval (default 5s, new option on IggyClientConfigurator).
    Ping goes through normal request path, so reconnect + auto login repair
    dead session. Skips ping when connecting or when reconnect/auto-login
    disabled and connection dead. Started under connect gate, no duplicate
    loops.
    - AutoLoginSettings: init-only, PersonalAccessToken support, public
    For(user, pwd) / ForPersonalAccessToken(token) factories.
    - Consumer/Publisher builders: heartbeat + auto-login wiring, warning
    when reconnect enabled without auto login.
    - Enable reconnection by default
---
 core/server/config.toml                            |   5 +-
 .../Fixtures/IggyServerFixture.cs                  |  18 +-
 .../Iggy_SDK.Tests.Integration/HeartbeatTests.cs   | 163 +++++++++++++++++
 .../Iggy_SDK/Configuration/AutoLoginSettings.cs    |  40 +++-
 .../Configuration/IggyClientConfigurator.cs        |   9 +
 .../Iggy_SDK/Configuration/ReconnectionSettings.cs |   8 +-
 .../Iggy_SDK/Consumers/IggyConsumerBuilder.cs      |  56 +++++-
 .../Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs   |   3 +-
 .../Iggy_SDK/Consumers/IggyConsumerConfig.cs       |  15 +-
 .../csharp/Iggy_SDK/Factory/IggyClientFactory.cs   |   9 +
 .../IggyClient/Implementations/TcpMessageStream.cs | 203 ++++++++++++++++++---
 .../Iggy_SDK/Publishers/IggyPublisherBuilder.cs    |  56 +++++-
 .../Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs |   3 +-
 .../Iggy_SDK/Publishers/IggyPublisherConfig.cs     |  15 +-
 .../ClientTests/IggyClientFactoryTests.cs          |  46 +++++
 .../ConsumerTests/IggyConsumerBuilderTests.cs      |  22 +++
 foreign/csharp/README.md                           |  62 ++++---
 17 files changed, 644 insertions(+), 89 deletions(-)

diff --git a/core/server/config.toml b/core/server/config.toml
index 9168ccf26..5f37e36c3 100644
--- a/core/server/config.toml
+++ b/core/server/config.toml
@@ -313,10 +313,7 @@ interval = "1 m"
 # rebalance off it. Only a connection that still holds a group
 # membership is evicted; one that holds none is left alone and is
 # reaped when its socket closes.
-# TODO(hubcio): flip the default to true once every SDK heartbeats
-# automatically; today the blocking Java and C# SDKs never ping, so an
-# enabled default would evict their idle consumer-group members.
-enabled = false
+enabled = true
 # Interval for expected client heartbeats. The Rust, Go, Python, Node and
 # async Java SDKs ping automatically every 5 s, well inside the resulting 36 s
 # staleness window, but only from a connected high-level client: the Rust and
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
index 1670cbcdf..4d3138eab 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
@@ -56,11 +56,24 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
     /// </summary>
     protected bool EnabledServerTraceLogs => true;
 
+    /// <summary>
+    ///     Server-side heartbeat interval. Short so a client that stops pinging is evicted within a few seconds
+    ///     instead of the default five. Clients from <see cref="CreateClient" /> ping at half this interval;
+    ///     any client built by hand must set <see cref="IggyClientConfigurator.HeartbeatInterval" /> below it or
+    ///     be evicted once idle past 1.2 intervals.
+    /// </summary>
+    public static readonly TimeSpan ServerHeartbeatInterval = TimeSpan.FromSeconds(2);
+
     /// <summary>
     ///     Extra environment variables for every node, layered over the cluster's base configuration.
-    ///     Override in subclasses to customize.
+    ///     Heartbeat verification is on for every fixture so the eviction tests share the general server
+    ///     instead of starting their own. Override in subclasses to customize.
     /// </summary>
-    protected virtual Dictionary<string, string> EnvironmentVariables => [];
+    protected virtual Dictionary<string, string> EnvironmentVariables => new()
+    {
+        { "IGGY_HEARTBEAT_ENABLED", "true" },
+        { "IGGY_HEARTBEAT_INTERVAL", $"{ServerHeartbeatInterval.TotalSeconds}s" }
+    };
 
     /// <summary>
     ///     Resource mappings (certificates, etc.) mounted into every node. Override in subclasses.
@@ -131,6 +144,7 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
             BaseAddress = address ?? await GetIggyAddressAsync(protocol),
             Protocol = protocol,
             ReconnectionSettings = new ReconnectionSettings { Enabled = true },
+            HeartbeatInterval = ServerHeartbeatInterval / 2,
             AutoLoginSettings = new AutoLoginSettings
             {
                 Enabled = autoLogin,
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/HeartbeatTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/HeartbeatTests.cs
new file mode 100644
index 000000000..7a9de7fb6
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/HeartbeatTests.cs
@@ -0,0 +1,163 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Factory;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+
+namespace Apache.Iggy.Tests.Integrations;
+
+/// <summary>
+///     Runs against a server that evicts consumer-group members it has not heard from. Only a group member is
+///     evicted, so every test joins a group before going idle.
+/// </summary>
+public class HeartbeatTests
+{
+    private const string TopicName = "heartbeat-topic";
+    private const string GroupName = "heartbeat-group";
+
+    // Comfortably past the server's stale threshold of 1.2 intervals, with room for the verifier's own tick.
+    private static readonly TimeSpan IdleFor = IggyServerFixture.ServerHeartbeatInterval * 3;
+
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    public async Task IdleGroupMember_WithHeartbeat_Should_StayMember()
+    {
+        using var client = await CreateClient(TimeSpan.FromSeconds(1));
+        var (streamName, groupId) = await JoinFreshGroup(client);
+
+        await Task.Delay(IdleFor);
+
+        using var observer = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var group = await observer.GetConsumerGroupByIdAsync(Identifier.String(streamName),
+            Identifier.String(TopicName), Identifier.Numeric(groupId));
+        group.ShouldNotBeNull();
+        group.MembersCount.ShouldBe(1u);
+
+        var me = await client.GetMeAsync();
+        me.ShouldNotBeNull();
+        me.ConsumerGroupsCount.ShouldBe(1);
+    }
+
+    [Test]
+    public async Task IdleGroupMember_WithSlowHeartbeat_Should_BeEvicted_And_Reconnect()
+    {
+        // Heartbeat cannot be turned off, so a ping interval far past the server's threshold stands in for one.
+        using var client = await CreateClient(TimeSpan.FromHours(1));
+        var (streamName, groupId) = await JoinFreshGroup(client);
+
+        await Task.Delay(IdleFor);
+
+        using var observer = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var group = await observer.GetConsumerGroupByIdAsync(Identifier.String(streamName),
+            Identifier.String(TopicName), Identifier.Numeric(groupId));
+        group.ShouldNotBeNull();
+        group.MembersCount.ShouldBe(0u);
+
+        // The eviction reaches the client with its next request, which the default reconnection replays over a
+        // fresh, auto-logged-in session.
+        var me = await client.GetMeAsync();
+        me.ShouldNotBeNull();
+        me.ConsumerGroupsCount.ShouldBe(0);
+    }
+
+    [Test]
+    public async Task EvictedClient_WithPersonalAccessTokenAutoLogin_Should_Reconnect()
+    {
+        using var issuer = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var pat = await issuer.CreatePersonalAccessTokenAsync($"heartbeat-{Guid.NewGuid():N}",
+            TimeSpan.FromHours(1));
+
+        using var client = await CreateClient(TimeSpan.FromHours(1),
+            AutoLoginSettings.ForPersonalAccessToken(pat!.Token));
+        var (streamName, groupId) = await JoinFreshGroup(client);
+
+        await Task.Delay(IdleFor);
+
+        var group = await issuer.GetConsumerGroupByIdAsync(Identifier.String(streamName),
+            Identifier.String(TopicName), Identifier.Numeric(groupId));
+        group.ShouldNotBeNull();
+        group.MembersCount.ShouldBe(0u);
+
+        var me = await client.GetMeAsync();
+        me.ShouldNotBeNull();
+        me.ConsumerGroupsCount.ShouldBe(0);
+    }
+
+    [Test]
+    public async Task EvictedClient_WithoutAutoLogin_Should_FailFast_And_NotReconnect()
+    {
+        using var client = await CreateClient(TimeSpan.FromHours(1), false);
+        await client.LoginUserAsync("iggy", "iggy");
+        var (streamName, _) = await JoinFreshGroup(client);
+
+        var reconnected = false;
+        client.SubscribeConnectionEvents(args =>
+        {
+            reconnected |= args.CurrentState == ConnectionState.Connecting;
+            return Task.CompletedTask;
+        });
+
+        await Task.Delay(IdleFor);
+
+        // A reconnect could not bring the session back, so the request surfaces the loss instead of coming back
+        // over an unauthenticated connection.
+        await Should.ThrowAsync<Exception>(() => client.GetMeAsync());
+        reconnected.ShouldBeFalse();
+        await Should.ThrowAsync<NotConnectedException>(() =>
+            client.GetStreamByIdAsync(Identifier.String(streamName)));
+    }
+
+    private Task<IIggyClient> CreateClient(TimeSpan heartbeatInterval, bool autoLogin = true)
+    {
+        return CreateClient(heartbeatInterval,
+            autoLogin ? AutoLoginSettings.For("iggy", "iggy") : new AutoLoginSettings());
+    }
+
+    private async Task<IIggyClient> CreateClient(TimeSpan heartbeatInterval, AutoLoginSettings autoLogin)
+    {
+        var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
+        {
+            BaseAddress = await Fixture.GetIggyAddressAsync(Protocol.Tcp),
+            Protocol = Protocol.Tcp,
+            HeartbeatInterval = heartbeatInterval,
+            AutoLoginSettings = autoLogin
+        });
+        await client.ConnectAsync();
+
+        return client;
+    }
+
+    private static async Task<(string streamName, uint groupId)> JoinFreshGroup(IIggyClient client)
+    {
+        var streamName = $"heartbeat-stream-{Guid.NewGuid():N}";
+        await client.CreateStreamAsync(streamName);
+        await client.CreateTopicAsync(Identifier.String(streamName), TopicName, 2);
+        var group = await client.CreateConsumerGroupAsync(Identifier.String(streamName),
+            Identifier.String(TopicName), GroupName);
+        await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName),
+            Identifier.Numeric(group!.Id));
+
+        return (streamName, group.Id);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs b/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
index 3c88446ec..cdf020bf8 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
@@ -23,32 +23,54 @@ namespace Apache.Iggy.Configuration;
 public class AutoLoginSettings
 {
     /// <summary>
-    ///     Enable automatic login on connection establishment
+    ///     Enable automatic login on connection establishment.
     /// </summary>
-    public bool Enabled { get; set; }
+    public bool Enabled { get; init; }
 
     /// <summary>
     ///     Specifies the username for auto-login configuration.
     /// </summary>
-    public string Username { get; set; } = string.Empty;
+    public string Username { get; init; } = string.Empty;
 
     /// <summary>
     ///     Specifies the password for auto-login authentication
     /// </summary>
-    public string Password { get; set; } = string.Empty;
+    public string Password { get; init; } = string.Empty;
 
     /// <summary>
-    ///     Settings for a builder-owned client that signs in with the given credentials. The credentials must
-    ///     reach the client and not only the explicit login the wrapper performs at startup: a reconnect or a
-    ///     leader redirect drops the session, and without them the client comes back unauthenticated.
+    ///     Personal access token to sign in with instead of a username and password. Takes precedence over them
+    ///     when set.
     /// </summary>
-    internal static AutoLoginSettings For(string username, string password)
+    public string PersonalAccessToken { get; init; } = string.Empty;
+
+    /// <summary>
+    ///     Auto login with a username and password.
+    /// </summary>
+    /// <exception cref="ArgumentException">Thrown when the username is empty.</exception>
+    public static AutoLoginSettings For(string username, string password)
     {
+        ArgumentException.ThrowIfNullOrEmpty(username);
+
         return new AutoLoginSettings
         {
-            Enabled = !string.IsNullOrEmpty(username),
+            Enabled = true,
             Username = username,
             Password = password
         };
     }
+
+    /// <summary>
+    ///     Auto login with a personal access token.
+    /// </summary>
+    /// <exception cref="ArgumentException">Thrown when the token is empty.</exception>
+    public static AutoLoginSettings ForPersonalAccessToken(string token)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(token);
+
+        return new AutoLoginSettings
+        {
+            Enabled = true,
+            PersonalAccessToken = token
+        };
+    }
 }
diff --git a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
index fad8604a6..48e50d3b7 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
@@ -53,6 +53,15 @@ public sealed class IggyClientConfigurator
     /// </summary>
     public int SendBufferSize { get; set; } = 4096;
 
+    /// <summary>
+    ///     Interval between the pings the <see cref="Enums.Protocol.Tcp" /> client sends on its own while
+    ///     connected, so an idle session survives the server's heartbeat verification and consumer-group
+    ///     assignments stay fresh. Default is 5 seconds; must be between 1 millisecond and about 49 days.
+    ///     Init-only: the client validates the interval once and hands it to a timer that would fault on an
+    ///     out-of-range value.
+    /// </summary>
+    public TimeSpan HeartbeatInterval { get; init; } = TimeSpan.FromSeconds(5);
+
     /// <summary>
     ///     TLS settings
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Configuration/ReconnectionSettings.cs b/foreign/csharp/Iggy_SDK/Configuration/ReconnectionSettings.cs
index 01986bd3c..f8cf495c4 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/ReconnectionSettings.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/ReconnectionSettings.cs
@@ -23,14 +23,14 @@ namespace Apache.Iggy.Configuration;
 public sealed class ReconnectionSettings
 {
     /// <summary>
-    ///     Enable automatic reconnection when connection is lost
+    ///     Enable automatic reconnection when connection is lost. Enabled by default.
     /// </summary>
-    public bool Enabled { get; set; } = false;
+    public bool Enabled { get; set; } = true;
 
     /// <summary>
-    ///     Maximum number of reconnection attempts (0 = infinite)
+    ///     Maximum number of reconnection attempts (0 = infinite). Default is 0.
     /// </summary>
-    public int MaxRetries { get; set; } = 3;
+    public int MaxRetries { get; set; }
 
     /// <summary>
     ///     Initial delay before first reconnection attempt
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
index fc768662b..400cc7234 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
@@ -108,6 +108,29 @@ public class IggyConsumerBuilder
         return this;
     }
 
+    /// <summary>
+    ///     Configures the connection settings using a personal access token instead of a username and password.
+    /// </summary>
+    /// <param name="protocol">The protocol to use for the connection (e.g., TCP, UDP).</param>
+    /// <param name="address">The address of the server to connect to.</param>
+    /// <param name="personalAccessToken">The personal access token to authenticate with.</param>
+    /// <param name="receiveBufferSize">The size of the receive buffer.</param>
+    /// <param name="sendBufferSize">The size of the send buffer.</param>
+    /// <param name="reconnectionSettings">Reconnection settings for the client.</param>
+    /// <returns>The current instance of <see cref="IggyConsumerBuilder" /> to allow method chaining.</returns>
+    public IggyConsumerBuilder WithConnection(Protocol protocol, string address, string personalAccessToken,
+        int receiveBufferSize = 4096, int sendBufferSize = 4096, ReconnectionSettings? reconnectionSettings = null)
+    {
+        Config.Protocol = protocol;
+        Config.Address = address;
+        Config.PersonalAccessToken = personalAccessToken;
+        Config.ReceiveBufferSize = receiveBufferSize;
+        Config.SendBufferSize = sendBufferSize;
+        Config.ReconnectionSettings = reconnectionSettings;
+
+        return this;
+    }
+
     /// <summary>
     ///     Configures message encryption on the client this builder creates. Encryption is a client-level concern:
     ///     the encryptor decrypts on poll (and encrypts on send) for the whole connection. Only valid when the
@@ -245,7 +268,8 @@ public class IggyConsumerBuilder
                 ReceiveBufferSize = Config.ReceiveBufferSize,
                 SendBufferSize = Config.SendBufferSize,
                 ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
-                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
+                HeartbeatInterval = Config.HeartbeatInterval,
+                AutoLoginSettings = AutoLogin(),
                 LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
                 MessageEncryptor = _encryptor
             });
@@ -262,6 +286,18 @@ public class IggyConsumerBuilder
         return consumer;
     }
 
+    /// <summary>
+    ///     The credentials given to WithConnection must reach the client and not only the explicit login
+    ///     performed at startup: a reconnect or a leader redirect drops the session, and without them the
+    ///     client would come back unauthenticated.
+    /// </summary>
+    private protected AutoLoginSettings AutoLogin()
+    {
+        return string.IsNullOrEmpty(Config.PersonalAccessToken)
+            ? AutoLoginSettings.For(Config.Login, Config.Password)
+            : AutoLoginSettings.ForPersonalAccessToken(Config.PersonalAccessToken);
+    }
+
     /// <summary>
     ///     Validates the consumer configuration and throws if invalid.
     /// </summary>
@@ -275,14 +311,18 @@ public class IggyConsumerBuilder
                 throw new InvalidOperationException("Address must be provided when CreateIggyClient is true.");
             }
 
-            if (string.IsNullOrWhiteSpace(Config.Login))
-            {
-                throw new InvalidOperationException("Login must be provided when CreateIggyClient is true.");
-            }
-
-            if (string.IsNullOrWhiteSpace(Config.Password))
+            if (string.IsNullOrWhiteSpace(Config.PersonalAccessToken))
             {
-                throw new InvalidOperationException("Password must be provided when CreateIggyClient is true.");
+                if (string.IsNullOrWhiteSpace(Config.Login))
+                {
+                    throw new InvalidOperationException(
+                        "Login or PersonalAccessToken must be provided when CreateIggyClient is true.");
+                }
+
+                if (string.IsNullOrWhiteSpace(Config.Password))
+                {
+                    throw new InvalidOperationException("Password must be provided when CreateIggyClient is true.");
+                }
             }
         }
         else
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs
index ad640ebef..3800f72f6 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs
@@ -96,7 +96,8 @@ public class IggyConsumerBuilder<T> : IggyConsumerBuilder
                 ReceiveBufferSize = Config.ReceiveBufferSize,
                 SendBufferSize = Config.SendBufferSize,
                 ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
-                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
+                HeartbeatInterval = Config.HeartbeatInterval,
+                AutoLoginSettings = AutoLogin(),
                 LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
                 MessageEncryptor = _encryptor
             });
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
index 1418282d3..d81430110 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
@@ -69,6 +69,12 @@ public class IggyConsumerConfig
     /// </summary>
     public string Password { get; set; } = string.Empty;
 
+    /// <summary>
+    ///     Personal access token used instead of <see cref="Login" /> and <see cref="Password" /> when creating
+    ///     the client. Takes precedence over them when set.
+    /// </summary>
+    public string PersonalAccessToken { get; set; } = string.Empty;
+
     /// <summary>
     ///     The size of the receive buffer in bytes. Default is 4096.
     /// </summary>
@@ -148,7 +154,14 @@ public class IggyConsumerConfig
     /// <summary>
     ///     Gets or sets the reconnection settings to control the behavior of the iggy client
     ///     in case of a disconnect or network failure.
-    ///     This property is optional and can be null. If null, reconnection will be disabled.
+    ///     This property is optional and can be null. If null, the default <see cref="Configuration.ReconnectionSettings" />
+    ///     apply.
     /// </summary>
     public ReconnectionSettings? ReconnectionSettings { get; set; }
+
+    /// <summary>
+    ///     Interval between the pings the created client sends to keep an idle session alive.
+    ///     See <see cref="IggyClientConfigurator.HeartbeatInterval" />.
+    /// </summary>
+    public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
 }
diff --git a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
index ef965f34b..515a06dd8 100644
--- a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
+++ b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
@@ -67,6 +67,15 @@ public static class IggyClientFactory
             throw new ArgumentOutOfRangeException(nameof(options), options.MaxResponseFrameSize,
                 $"MaxResponseFrameSize must be at least {VsrHeader.HEADER_SIZE} bytes.");
         }
+
+        // The bounds PeriodicTimer accepts; anything outside them would fault the heartbeat task at start
+        // instead of failing the caller here.
+        if (options.HeartbeatInterval < TimeSpan.FromMilliseconds(1) ||
+            options.HeartbeatInterval > TimeSpan.FromMilliseconds(uint.MaxValue - 1))
+        {
+            throw new ArgumentOutOfRangeException(nameof(options), options.HeartbeatInterval,
+                "HeartbeatInterval must be between 1 millisecond and about 49 days.");
+        }
     }
 
     private static IIggyClient CreateIggyTcpClient(IggyClientConfigurator options)
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
index 75189e84c..32ea6e03e 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
@@ -50,6 +50,9 @@ public sealed partial class TcpMessageStream : IIggyClient
 {
     private const int InvalidCommandStatus = 3;
 
+    // Ping carries no body, so the frame is constant and shared by the heartbeat and PingAsync.
+    private static readonly byte[] PingPayload = CreatePingPayload();
+
     private static readonly HashSet<uint> SessionControlCodes =
     [
         CommandCodes.LOGIN_USER_CODE,
@@ -63,6 +66,7 @@ public sealed partial class TcpMessageStream : IIggyClient
     private readonly SemaphoreSlim _connectGate = new(1, 1);
     private readonly EventAggregator<ConnectionStateChangedEventArgs> _connectionEvents;
     private readonly SemaphoreSlim _connectionSemaphore;
+    private readonly CancellationTokenSource _heartbeatCancellation = new();
     private readonly ILogger<TcpMessageStream> _logger;
     private readonly SemaphoreSlim _sendingSemaphore;
     private string _currentAddress = string.Empty;
@@ -74,6 +78,7 @@ public sealed partial class TcpMessageStream : IIggyClient
     private string _currentRemoteAddress = string.Empty;
     private X509Certificate2Collection _customCaStore = [];
     private volatile bool _disposed;
+    private Task? _heartbeatTask;
     private int _isConnecting;
     private DateTimeOffset _lastConnectionTime;
     private int _leaderRedirectCount;
@@ -99,18 +104,26 @@ public sealed partial class TcpMessageStream : IIggyClient
     }
 
     /// <summary>
-    ///     Closes the underlying stream and releases the connection's semaphores.
+    ///     Closes the underlying stream and stops the heartbeat. The semaphores are left alone on purpose: a
+    ///     heartbeat or request still in flight releases them from its finally block, and a disposed
+    ///     <see cref="SemaphoreSlim" /> turns that release into an <see cref="ObjectDisposedException" /> that
+    ///     replaces the real error and skips the connection drop. They own no unmanaged handle, so there is
+    ///     nothing to leak. The heartbeat token source is kept undisposed for the same reason: the loop and
+    ///     <see cref="StartHeartbeat" /> read its token after this method may already have run.
     /// </summary>
     public void Dispose()
     {
+        if (_disposed)
+        {
+            return;
+        }
+
         _disposed = true;
+        _heartbeatCancellation.Cancel();
         _stream?.Close();
         _stream?.Dispose();
 
         SetConnectionStateAsync(ConnectionState.Disconnected);
-        _sendingSemaphore.Dispose();
-        _connectionSemaphore.Dispose();
-        _connectGate.Dispose();
         _connectionEvents.Clear();
     }
 
@@ -618,12 +631,7 @@ public sealed partial class TcpMessageStream : IIggyClient
     /// <inheritdoc />
     public async Task PingAsync(CancellationToken token = default)
     {
-        var message = Array.Empty<byte>();
-        var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length];
-        TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.PING_CODE);
-
-        await SendAckAsync(payload, token);
-
+        await SendAckAsync(PingPayload, token);
         await RefreshGroupAssignmentsAsync(token);
     }
 
@@ -660,6 +668,12 @@ public sealed partial class TcpMessageStream : IIggyClient
     /// <inheritdoc />
     public Task ConnectAsync(CancellationToken token = default)
     {
+        if (_configuration.ReconnectionSettings.Enabled && !_configuration.AutoLoginSettings.Enabled)
+        {
+            _logger.LogWarning(
+                "Reconnection is enabled without auto login: a lost session cannot be restored, requests will fail until the client logs in again");
+        }
+
         return ConnectAsync(true, token);
     }
 
@@ -898,6 +912,8 @@ public sealed partial class TcpMessageStream : IIggyClient
         Interlocked.Exchange(ref _isConnecting, 1);
         try
         {
+            ObjectDisposedException.ThrowIf(_disposed, this);
+
             if (_state is ConnectionState.Connected
                 or ConnectionState.Authenticating
                 or ConnectionState.Authenticated)
@@ -912,6 +928,18 @@ public sealed partial class TcpMessageStream : IIggyClient
 
             SetConnectionStateAsync(ConnectionState.Connecting);
             await TryEstablishConnectionAsync(autoLogin, token);
+
+            // Dispose is synchronous and cannot take the sending semaphore, so a Dispose that ran while this
+            // connect was dialing already read a stream that did not exist yet. Reading the flag after the
+            // stream is installed leaves no window: either Dispose sees the new stream and closes it, or it is
+            // seen here and the stream is dropped.
+            if (_disposed)
+            {
+                await DropStreamAsync();
+                throw new ObjectDisposedException(nameof(TcpMessageStream));
+            }
+
+            StartHeartbeat();
         }
         finally
         {
@@ -920,6 +948,67 @@ public sealed partial class TcpMessageStream : IIggyClient
         }
     }
 
+    /// <summary>
+    ///     Starts the idle ping loop. Called under <see cref="_connectGate" />, so a reconnect finds the loop
+    ///     already running and does not start a second one. A loop that ended anyway - an interval the caller
+    ///     mutated out of range faults <see cref="PeriodicTimer" />'s constructor - is started again rather than
+    ///     leaving the client without a heartbeat for the rest of its life.
+    /// </summary>
+    private void StartHeartbeat()
+    {
+        if (_disposed)
+        {
+            return;
+        }
+
+        if (_heartbeatTask is null or { IsCompleted: true })
+        {
+            _heartbeatTask = RunHeartbeatAsync(_configuration.HeartbeatInterval, _heartbeatCancellation.Token);
+        }
+    }
+
+    /// <summary>
+    ///     Pings on a timer so the server keeps hearing from an otherwise idle connection. The ping goes through
+    ///     the regular request path, so a lost session is repaired by the same reconnect and auto login every
+    ///     other request would trigger; the request deadline bounds a stalled server. Failures are logged and
+    ///     the loop keeps going until the client is disposed.
+    /// </summary>
+    private async Task RunHeartbeatAsync(TimeSpan interval, CancellationToken token)
+    {
+        try
+        {
+            using var timer = new PeriodicTimer(interval);
+            while (await timer.WaitForNextTickAsync(token))
+            {
+                // Without reconnection and auto login a ping on a dead connection can only fail; with them,
+                // the ping is what brings an idle client back.
+                var unrecoverable = _state is ConnectionState.Disconnected or ConnectionState.Connecting
+                                    && !(_configuration.ReconnectionSettings.Enabled
+                                         && _configuration.AutoLoginSettings.Enabled);
+                if (IsConnecting || unrecoverable)
+                {
+                    continue;
+                }
+
+                try
+                {
+                    await PingAsync(token);
+                }
+                catch (Exception e) when (!token.IsCancellationRequested && !_disposed)
+                {
+                    _logger.LogWarning(e, "Heartbeat failed");
+                }
+            }
+        }
+        catch (Exception) when (token.IsCancellationRequested || _disposed)
+        {
+        }
+        catch (Exception e)
+        {
+            _logger.LogError(e, "Heartbeat stopped");
+        }
+    }
+
     private async Task<PolledMessagesRental> PollPartitionMessagesRentedAsync(Identifier streamId, Identifier topicId,
         uint? partitionId, Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
         CancellationToken token)
@@ -1043,19 +1132,7 @@ public sealed partial class TcpMessageStream : IIggyClient
         var delay = _configuration.ReconnectionSettings.InitialDelay;
         do
         {
-            // The sending semaphore owns every write to _stream, so an in-flight request never observes the
-            // field changing between its write and its reply.
-            await _sendingSemaphore.WaitAsync(token);
-            try
-            {
-                _stream?.Dispose();
-
-                ResetConsensusSession();
-            }
-            finally
-            {
-                _sendingSemaphore.Release();
-            }
+            await DropStreamAsync();
 
             if (string.IsNullOrEmpty(_currentAddress))
             {
@@ -1068,6 +1145,7 @@ public sealed partial class TcpMessageStream : IIggyClient
             }
 
             Socket? socket = null;
+            var dialed = false;
             try
             {
                 socket = new Socket(ServerAddress.AddressFamilyOf(host), SocketType.Stream, ProtocolType.Tcp);
@@ -1080,6 +1158,7 @@ public sealed partial class TcpMessageStream : IIggyClient
                 socket.NoDelay = true;
 
                 await socket.ConnectAsync(host, port, token);
+                dialed = true;
 
                 _currentRemoteAddress = socket.RemoteEndPoint is IPEndPoint remote
                     ? ServerAddress.HostPort(remote.Address.ToString(), (ushort)remote.Port)
@@ -1114,10 +1193,7 @@ public sealed partial class TcpMessageStream : IIggyClient
                 // forwards the register to the primary.
                 if (autoLogin && _configuration.AutoLoginSettings.Enabled && !ConsumeSkipAutoLogin())
                 {
-                    _logger.LogInformation("Auto login enabled. Trying to login with credentials: {Username}",
-                        _configuration.AutoLoginSettings.Username);
-                    await LoginUserAsync(_configuration.AutoLoginSettings.Username,
-                        _configuration.AutoLoginSettings.Password, token);
+                    await AutoLoginAsync(token);
 
                     if (await RedirectAsync(token))
                     {
@@ -1128,6 +1204,21 @@ public sealed partial class TcpMessageStream : IIggyClient
 
                 break;
             }
+            // Only a failed dial is worth another attempt. Everything past it - a rejected certificate, bad
+            // credentials, a leader that cannot be found - fails the same way every time, and with unlimited
+            // retries a caller would otherwise never get the error back.
+            catch (Exception e) when (dialed || e is OperationCanceledException || _disposed)
+            {
+                socket?.Dispose();
+
+                // The stream is already installed by the time auto login, a redirect or the leader lookup can
+                // fail, and this path leaves the loop for good, so nothing else would ever close it.
+                await DropStreamAsync();
+
+                _logger.LogError(e, "Failed to establish connection");
+                SetConnectionStateAsync(ConnectionState.Disconnected);
+                throw;
+            }
             catch (Exception e)
             {
                 socket?.Dispose();
@@ -1180,6 +1271,43 @@ public sealed partial class TcpMessageStream : IIggyClient
         }
     }
 
+    /// <summary>
+    ///     Closes the current connection and forgets the consensus session bound to it. Takes the sending
+    ///     semaphore, which owns every write to <see cref="_stream" />, so an in-flight request never observes
+    ///     the field changing between its write and its reply. Never cancellable: a caller giving up is exactly
+    ///     when the stream has to be released.
+    /// </summary>
+    private async Task DropStreamAsync()
+    {
+        await _sendingSemaphore.WaitAsync(CancellationToken.None);
+        try
+        {
+            // Left pointing at the disposed stream rather than nulled: a request that raced this drop gets an
+            // ObjectDisposedException, which the reconnect path already understands, instead of an NRE.
+            _stream?.Dispose();
+
+            ResetConsensusSession();
+        }
+        finally
+        {
+            _sendingSemaphore.Release();
+        }
+    }
+
+    private async Task AutoLoginAsync(CancellationToken token)
+    {
+        var settings = _configuration.AutoLoginSettings;
+        if (!string.IsNullOrEmpty(settings.PersonalAccessToken))
+        {
+            _logger.LogInformation("Auto login enabled. Trying to login with a personal access token");
+            await LoginWithPersonalAccessTokenAsync(settings.PersonalAccessToken, token);
+            return;
+        }
+
+        _logger.LogInformation("Auto login enabled. Trying to login with credentials: {Username}", settings.Username);
+        await LoginUserAsync(settings.Username, settings.Password, token);
+    }
+
     /// <summary>
     ///     Whether this connect was triggered by a login or register request that will re-authenticate itself,
     ///     so the auto-login must sit this one out. Consumes the flag.
@@ -1232,6 +1360,15 @@ public sealed partial class TcpMessageStream : IIggyClient
                 throw;
             }
 
+            // Without auto login a reconnect cannot re-establish the session, so the request would only come
+            // back unauthenticated. Login and register are the exception: they re-authenticate themselves.
+            if (!_configuration.AutoLoginSettings.Enabled && Volatile.Read(ref _skipAutoLoginOnce) == 0)
+            {
+                _logger.LogWarning("Auto login is disabled, the session cannot be re-established");
+                SetConnectionStateAsync(ConnectionState.Disconnected);
+                throw;
+            }
+
             return await HandleReconnectionAsync(payload, token);
         }
     }
@@ -1279,13 +1416,23 @@ public sealed partial class TcpMessageStream : IIggyClient
         return SendRawVsrAsync(payload, token);
     }
 
+    // A stale-client eviction is the server telling this connection its session is gone: the transport already
+    // dropped the stream, so the request is replayed over a fresh connection like any other lost one.
     private static bool IsConnectionException(Exception ex)
     {
         return ex is IggyZeroBytesException or
             NotConnectedException or
             SocketException or
             IOException or
-            ObjectDisposedException;
+            ObjectDisposedException or
+            IggyInvalidStatusCodeException { StatusCode: VsrError.STALE_CLIENT, FromServer: true };
+    }
+
+    private static byte[] CreatePingPayload()
+    {
+        var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH];
+        TcpMessageStreamHelpers.CreatePayload(payload, Array.Empty<byte>(), CommandCodes.PING_CODE);
+        return payload;
     }
 
     private static int CalculatePayloadBufferSize(int messageBufferSize)
diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs
index 270bba930..81ec6c297 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs
@@ -127,6 +127,29 @@ public class IggyPublisherBuilder
         return this;
     }
 
+    /// <summary>
+    ///     Configures the connection settings using a personal access token instead of a username and password.
+    /// </summary>
+    /// <param name="protocol">The protocol to use for the connection (e.g., TCP, UDP).</param>
+    /// <param name="address">The address of the server to connect to.</param>
+    /// <param name="personalAccessToken">The personal access token to authenticate with.</param>
+    /// <param name="receiveBufferSize">The size of the receive buffer.</param>
+    /// <param name="sendBufferSize">The size of the send buffer.</param>
+    /// <param name="reconnectionSettings">Reconnection settings for the client.</param>
+    /// <returns>The current instance of <see cref="IggyPublisherBuilder" /> to allow method chaining.</returns>
+    public IggyPublisherBuilder WithConnection(Protocol protocol, string address, string personalAccessToken,
+        int receiveBufferSize = 4096, int sendBufferSize = 4096, ReconnectionSettings? reconnectionSettings = null)
+    {
+        Config.Protocol = protocol;
+        Config.Address = address;
+        Config.PersonalAccessToken = personalAccessToken;
+        Config.ReceiveBufferSize = receiveBufferSize;
+        Config.SendBufferSize = sendBufferSize;
+        Config.ReconnectionSettings = reconnectionSettings;
+
+        return this;
+    }
+
     /// <summary>
     ///     Configures the partitioning strategy for messages sent by the publisher.
     ///     Determines how messages are distributed across topic partitions.
@@ -303,7 +326,8 @@ public class IggyPublisherBuilder
                 ReceiveBufferSize = Config.ReceiveBufferSize,
                 SendBufferSize = Config.SendBufferSize,
                 ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
-                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
+                HeartbeatInterval = Config.HeartbeatInterval,
+                AutoLoginSettings = AutoLogin(),
                 LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
                 MessageEncryptor = _encryptor
             });
@@ -326,6 +350,18 @@ public class IggyPublisherBuilder
         return publisher;
     }
 
+    /// <summary>
+    ///     The credentials given to WithConnection must reach the client and not only the explicit login
+    ///     performed at startup: a reconnect or a leader redirect drops the session, and without them the
+    ///     client would come back unauthenticated.
+    /// </summary>
+    private protected AutoLoginSettings AutoLogin()
+    {
+        return string.IsNullOrEmpty(Config.PersonalAccessToken)
+            ? AutoLoginSettings.For(Config.Login, Config.Password)
+            : AutoLoginSettings.ForPersonalAccessToken(Config.PersonalAccessToken);
+    }
+
     /// <summary>
     ///     Validates the publisher configuration and throws if invalid.
     /// </summary>
@@ -339,14 +375,18 @@ public class IggyPublisherBuilder
                 throw new InvalidOperationException("Address must be provided when CreateIggyClient is true.");
             }
 
-            if (string.IsNullOrWhiteSpace(Config.Login))
-            {
-                throw new InvalidOperationException("Login must be provided when CreateIggyClient is true.");
-            }
-
-            if (string.IsNullOrWhiteSpace(Config.Password))
+            if (string.IsNullOrWhiteSpace(Config.PersonalAccessToken))
             {
-                throw new InvalidOperationException("Password must be provided when CreateIggyClient is true.");
+                if (string.IsNullOrWhiteSpace(Config.Login))
+                {
+                    throw new InvalidOperationException(
+                        "Login or PersonalAccessToken must be provided when CreateIggyClient is true.");
+                }
+
+                if (string.IsNullOrWhiteSpace(Config.Password))
+                {
+                    throw new InvalidOperationException("Password must be provided when CreateIggyClient is true.");
+                }
             }
         }
         else
diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs
index 6198c7643..97db2487a 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs
@@ -92,7 +92,8 @@ public class IggyPublisherBuilder<T> : IggyPublisherBuilder
                 ReceiveBufferSize = Config.ReceiveBufferSize,
                 SendBufferSize = Config.SendBufferSize,
                 ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
-                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
+                HeartbeatInterval = Config.HeartbeatInterval,
+                AutoLoginSettings = AutoLogin(),
                 LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
                 MessageEncryptor = _encryptor
             });
diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs
index a4ef3571f..36604663b 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs
@@ -74,6 +74,12 @@ public class IggyPublisherConfig
     /// </summary>
     public string Password { get; set; } = string.Empty;
 
+    /// <summary>
+    ///     Personal access token used instead of <see cref="Login" /> and <see cref="Password" /> when creating
+    ///     the client. Takes precedence over them when set.
+    /// </summary>
+    public string PersonalAccessToken { get; set; } = string.Empty;
+
     /// <summary>
     ///     Gets or sets the identifier of the target stream.
     ///     Can be either a numeric ID or a string name.
@@ -252,7 +258,14 @@ public class IggyPublisherConfig
     /// <summary>
     ///     Gets or sets the reconnection settings to control the behavior of the iggy client
     ///     in case of a disconnect or network failure.
-    ///     This property is optional and can be null. If null, reconnection will be disabled.
+    ///     This property is optional and can be null. If null, the default <see cref="Configuration.ReconnectionSettings" />
+    ///     apply.
     /// </summary>
     public ReconnectionSettings? ReconnectionSettings { get; set; }
+
+    /// <summary>
+    ///     Interval between the pings the created client sends to keep an idle session alive.
+    ///     See <see cref="IggyClientConfigurator.HeartbeatInterval" />.
+    /// </summary>
+    public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);
 }
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
index de665603d..68f32d264 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
@@ -51,6 +51,52 @@ public sealed class IggyClientFactoryTests
         Assert.Throws<ArgumentOutOfRangeException>(() => IggyClientFactory.CreateClient(options));
     }
 
+    [Theory]
+    [InlineData(0)]
+    [InlineData(-1000)]
+    [InlineData(0.5)]
+    [InlineData(uint.MaxValue)]
+    public void CreateClient_RejectsHeartbeatIntervalOutsideTimerBounds(double milliseconds)
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "127.0.0.1:8090",
+            Protocol = Protocol.Tcp,
+            HeartbeatInterval = TimeSpan.FromMilliseconds(milliseconds)
+        };
+
+        Assert.Throws<ArgumentOutOfRangeException>(() => IggyClientFactory.CreateClient(options));
+    }
+
+    [Fact]
+    public void CreateClient_TcpClientDisposesTwice()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "127.0.0.1:8090",
+            Protocol = Protocol.Tcp
+        };
+
+        var client = (IDisposable)IggyClientFactory.CreateClient(options);
+
+        client.Dispose();
+        client.Dispose();
+    }
+
+    [Fact]
+    public void Defaults_KeepIdleSessionsAlive()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "127.0.0.1:8090",
+            Protocol = Protocol.Tcp
+        };
+
+        Assert.Equal(TimeSpan.FromSeconds(5), options.HeartbeatInterval);
+        Assert.True(options.ReconnectionSettings.Enabled);
+        Assert.Equal(0, options.ReconnectionSettings.MaxRetries);
+    }
+
     [Fact]
     public void CreateClient_AcceptsMaxResponseFrameSizeUnderHttp()
     {
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
index 817848470..f7d01c11f 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
@@ -71,6 +71,28 @@ public class IggyConsumerBuilderTests
         Assert.NotNull(consumer);
     }
 
+    [Fact]
+    public void Build_WithPersonalAccessToken_CreatesTheClient()
+    {
+        var consumer = IggyConsumerBuilder
+            .Create(StreamId, TopicId, Consumer.New(1))
+            .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "token")
+            .Build();
+
+        Assert.NotNull(consumer);
+    }
+
+    [Fact]
+    public void Build_WithoutCredentials_Throws()
+    {
+        var builder = IggyConsumerBuilder
+            .Create(StreamId, TopicId, Consumer.New(1))
+            .WithConnection(Protocol.Tcp, "127.0.0.1:8090", string.Empty);
+
+        var ex = Assert.Throws<InvalidOperationException>(() => builder.Build());
+        Assert.Contains("PersonalAccessToken", ex.Message);
+    }
+
     [Fact]
     public void TypedBuild_OverTcp_CreatesTheClient()
     {
diff --git a/foreign/csharp/README.md b/foreign/csharp/README.md
index 6f60264b4..ce15956ab 100644
--- a/foreign/csharp/README.md
+++ b/foreign/csharp/README.md
@@ -14,11 +14,14 @@ The Apache Iggy C# SDK provides a comprehensive client library for interacting w
 offers a modern, async-first API with support for multiple transport protocols and comprehensive message streaming
 capabilities.
 
-> Apache Iggy (Incubating) is an effort undergoing incubation at the Apache Software Foundation (ASF), sponsored by the Apache Incubator PMC.
+> Apache Iggy (Incubating) is an effort undergoing incubation at the Apache Software Foundation (ASF), sponsored by the
+> Apache Incubator PMC.
 >
-> Incubation is required of all newly accepted projects until a further review indicates that the infrastructure, communications, and decision making process have stabilized in a manner consistent with other successful ASF projects.
+> Incubation is required of all newly accepted projects until a further review indicates that the infrastructure,
+> communications, and decision making process have stabilized in a manner consistent with other successful ASF projects.
 >
-> While incubation status is not necessarily a reflection of the completeness or stability of the code, it does indicate that the project has yet to be fully endorsed by the ASF.
+> While incubation status is not necessarily a reflection of the completeness or stability of the code, it does indicate
+> that the project has yet to be fully endorsed by the ASF.
 
 ## Getting Started
 
@@ -55,7 +58,8 @@ var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
 await client.ConnectAsync();
 ```
 
-Optionally, you can provide an `ILoggerFactory` for diagnostics and debugging (defaults to `NullLoggerFactory.Instance`):
+Optionally, you can provide an `ILoggerFactory` for diagnostics and debugging (defaults to
+`NullLoggerFactory.Instance`):
 
 ```c#
 var loggerFactory = LoggerFactory.Create(builder =>
@@ -97,11 +101,15 @@ var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
         CertificatePath = "/path/to/cert"
     },
 
-    // Automatic reconnection with exponential backoff
+    // Idle ping keeping the session alive under server-side heartbeat verification (TCP only).
+    // Default 5 seconds, must be between 1 millisecond and about 49 days. Init-only.
+    HeartbeatInterval = TimeSpan.FromSeconds(5),
+
+    // Automatic reconnection with exponential backoff (enabled by default, infinite retries)
     ReconnectionSettings = new ReconnectionSettings
     {
         Enabled = true,
-        MaxRetries = 3,              // 0 = infinite retries
+        MaxRetries = 0,              // 0 = infinite retries
         InitialDelay = TimeSpan.FromSeconds(5),
         MaxDelay = TimeSpan.FromSeconds(30),
         WaitAfterReconnect = TimeSpan.FromSeconds(1),
@@ -109,13 +117,10 @@ var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
         BackoffMultiplier = 2.0
     },
 
-    // Auto-login after connection
-    AutoLoginSettings = new AutoLoginSettings
-    {
-        Enabled = true,
-        Username = "your_username",
-        Password = "your_password"
-    },
+    // Auto-login after connection. Reconnection needs it: without credentials to replay a reconnect cannot
+    // restore the session, so a lost connection fails the request instead
+    AutoLoginSettings = AutoLoginSettings.For("your_username", "your_password"),
+    // or AutoLoginSettings.ForPersonalAccessToken("your_token")
 
     // Optional: logging
     LoggerFactory = loggerFactory
@@ -139,12 +144,7 @@ var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
     // Upper bound on a reply frame the server announces, 64 MiB by default.
     MaxResponseFrameSize = 64 * 1024 * 1024,
 
-    AutoLoginSettings = new AutoLoginSettings
-    {
-        Enabled = true,
-        Username = "iggy",
-        Password = "iggy"
-    }
+    AutoLoginSettings = AutoLoginSettings.For("iggy", "iggy")
 });
 
 await client.ConnectAsync();
@@ -166,8 +166,10 @@ await client.ConnectAsync();
 - **Credentials are bounds-checked locally.** A username outside 3-50 bytes, a password outside 3-100 bytes or a
   personal access token outside 1-255 bytes is rejected before the register body is framed.
 - **`PingAsync` costs more than a ping.** Besides the ping it re-syncs the assignment of every consumer group
-  this client has joined, so it makes one extra round trip per joined group. The SDK runs no background
-  heartbeat: an application that wants assignments refreshed calls `PingAsync` on its own cadence.
+  this client has joined, so it makes one extra round trip per joined group. The TCP client pings on its own
+  every `HeartbeatInterval` (5 seconds by default) while connected, so an idle session survives the server's
+  heartbeat verification and assignments stay fresh; a lost session is repaired by the regular reconnect and
+  auto login.
 
 ### Retries and failed requests
 
@@ -202,6 +204,21 @@ The SDK replays a request whenever the server says it never admitted it. Two cas
 - Clients built with `IggyConsumerBuilder` / `IggyPublisherBuilder` now auto-login with the credentials passed
   to `WithConnection`. Before, a builder-created client came back from a
   reconnect unauthenticated; now the credentials are held for the lifetime of the connection and replayed.
+- The TCP client now pings the server every `HeartbeatInterval` (5 seconds, always on) on its own, and
+  reconnection is on by default (it was off before) with unlimited retries, like the Rust client. Only a
+  failed dial is retried; a rejected certificate, bad credentials or a missing leader is thrown right away.
+  A dropped connection fails every in-flight request at once; they share a single reconnect and are replayed
+  on the connection it establishes. With the default `MaxRetries = 0` an unreachable server is retried
+  forever, so a request that passes no `CancellationToken` waits for as long as the server stays down - set
+  `MaxRetries` or pass a token to bound it. Reconnection only replays a request when `AutoLoginSettings` can
+  restore the session; a client that logged in by hand fails fast on a lost connection. Set
+  `ReconnectionSettings.Enabled = false` to opt out of reconnection.
+- `AutoLoginSettings` properties are now `init`-only, as is `IggyClientConfigurator.HeartbeatInterval`. Build
+  them with an object initializer or the `AutoLoginSettings.For` / `AutoLoginSettings.ForPersonalAccessToken`
+  factories instead of assigning after construction.
+- `IggyConsumerBuilder` / `IggyPublisherBuilder` accept a personal access token through the
+  `WithConnection(protocol, address, personalAccessToken, ...)` overload, as an alternative to a username and
+  password.
 - The SDK now ships a dependency on `System.IO.Hashing`, used for the client-side message-key partitioner.
 - TCP sockets are opened with `NoDelay`. The protocol is request/reply, so a write is
   always the last one before the client waits for the answer and Nagle has nothing to coalesce it with - it
@@ -559,7 +576,8 @@ var fullSnapshot = await client.GetSnapshotAsync(
 
 Available compression methods: `Stored`, `Deflated`, `Bzip2`, `Zstd`, `Lzma`, `Xz`.
 
-Available snapshot types: `FilesystemOverview`, `ProcessList`, `ResourceUsage`, `Test`, `ServerLogs`, `ServerConfig`, `All`.
+Available snapshot types: `FilesystemOverview`, `ProcessList`, `ResourceUsage`, `Test`, `ServerLogs`, `ServerConfig`,
+`All`.
 
 ### Segment Management