diff --git a/README.md b/README.md
index c58951b..8f774b1 100644
--- a/README.md
+++ b/README.md
@@ -174,7 +174,10 @@ involved.
to authenticate with (required), and optional split-tunnel routes
(`10.0.0.0/8`, `fd00::/8`, …).
4. Select it and **Connect**. The status panel shows the assigned IP, gateway,
- routes, and the live iroh connection path once connected.
+ routes, and the live iroh connection path once connected. A server that is
+ down, or a connection that drops, is retried by the core with backoff (1s
+ doubling to 60s) for as long as it takes; while that runs the panel says
+ how many attempts have failed, when the next one is due, and the last error.
5. **Disconnect** tears down the tunnel and routes.
Keys are shared across profiles: several profiles can authenticate with the same
diff --git a/native/native.targets b/native/native.targets
index 9fd8ff9..edd1c52 100644
--- a/native/native.targets
+++ b/native/native.targets
@@ -32,10 +32,10 @@
next to the app's own version (EzvpnAppVersion in Directory.Build.props,
which is independent of this pin). A local core build (EZVPN_LOCAL_DLL)
still reports the pinned number: the local DLL carries none. -->
- v0.0.48
+ v0.0.49
$(EzvpnReleaseTag.TrimStart('v'))
https://github.com/flexaccessdev/ezvpn/releases/download/$(EzvpnReleaseTag)/ezvpn-windows.dll.zip
- 029c7fe78eab91b81467c60d3eff7dd2feece227c105d956406a1a09c7bf6333
+ edc79a2a607ba2a56609d51e8f78064ae6850fc3c63e610476ae882602dcd048
+
+
State switch
{
+ // The core retries every failed attempt itself; say so once one has
+ // failed, so a backoff wait reads as waiting rather than stuck.
+ ConnectionState.Connecting when IsReconnecting => "Reconnecting…",
ConnectionState.Connecting => "Connecting…",
ConnectionState.Connected => "Connected",
ConnectionState.Error => "Error",
@@ -128,6 +131,39 @@ private set
public bool HasError => State == ConnectionState.Error && !string.IsNullOrEmpty(Error);
+ ///
+ /// True while the core's reconnect loop is between attempts after at least
+ /// one failure (the status reports failed_attempts > 0).
+ ///
+ public bool IsReconnecting => State == ConnectionState.Connecting && _status?.FailedAttempts > 0;
+
+ ///
+ /// How far the retry loop has got, e.g. "3 failed attempts, next in 8s"
+ /// (the same line ezvpn client status prints), or "trying now" while
+ /// an attempt is in progress.
+ ///
+ public string ReconnectText
+ {
+ get
+ {
+ if (_status is null || _status.FailedAttempts == 0)
+ {
+ return "";
+ }
+ var n = _status.FailedAttempts;
+ var attempts = n == 1 ? "1 failed attempt" : $"{n} failed attempts";
+ var next = _status.NextAttemptSecs switch
+ {
+ > 0 and var secs => $"next in {FormatWait(TimeSpan.FromSeconds(secs))}",
+ _ => "trying now",
+ };
+ return $"{attempts}, {next}";
+ }
+ }
+
+ /// The last attempt's error while reconnecting, or "" when unknown.
+ public string ReconnectErrorText => IsReconnecting ? _status?.LastError ?? "" : "";
+
public string ConnectedSinceText =>
_status?.ConnectedSinceSecs is { } secs
? FormatElapsed(TimeSpan.FromSeconds(secs))
@@ -138,6 +174,11 @@ private set
private static string FormatElapsed(TimeSpan t) =>
$"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}";
+ // A backoff wait is at most a minute or so ("8s", "1m 0s"), so a compact
+ // form reads better than the clock-style elapsed time above.
+ private static string FormatWait(TimeSpan t) =>
+ t.TotalMinutes >= 1 ? $"{(int)t.TotalMinutes}m {t.Seconds}s" : $"{t.Seconds}s";
+
// --- State transitions ----------------------------------------------------
public void SetConnecting()
diff --git a/src/Ezvpn.Core/ClientStatus.cs b/src/Ezvpn.Core/ClientStatus.cs
index 2b02e81..4b0f943 100644
--- a/src/Ezvpn.Core/ClientStatus.cs
+++ b/src/Ezvpn.Core/ClientStatus.cs
@@ -51,6 +51,21 @@ public sealed class ClientStatus
public IReadOnlyList BypassAddrs => StrList("bypass_addrs");
+ ///
+ /// Consecutive failed connection attempts in the current outage; 0 while
+ /// connected or before the first failure.
+ ///
+ public int FailedAttempts => Int("failed_attempts") ?? 0;
+
+ ///
+ /// Seconds until the core's reconnect loop tries again: 0 while an attempt
+ /// is in progress, null when no retry is pending.
+ ///
+ public ulong? NextAttemptSecs => ULong("next_attempt_secs");
+
+ /// The error that ended the last failed attempt, while reconnecting.
+ public string? LastError => Str("last_error");
+
public IReadOnlyList CustomRelays
{
get
diff --git a/tests/Ezvpn.Core.Tests/ClientStatusTests.cs b/tests/Ezvpn.Core.Tests/ClientStatusTests.cs
index 0d284cc..18abf76 100644
--- a/tests/Ezvpn.Core.Tests/ClientStatusTests.cs
+++ b/tests/Ezvpn.Core.Tests/ClientStatusTests.cs
@@ -36,6 +36,35 @@ public void Parse_ConnectedDualStack()
Assert.Single(status.CustomRelays);
Assert.True(status.CustomRelays[0].Working);
Assert.Equal("https://relay.example/", status.CustomRelays[0].Url);
+ Assert.Equal(0, status.FailedAttempts);
+ Assert.Null(status.NextAttemptSecs);
+ Assert.Null(status.LastError);
+ }
+
+ // The reconnect loop's progress while down: consecutive failures, when it
+ // tries again (0 while an attempt is in progress), and the last error.
+ [Fact]
+ public void Parse_ReconnectingReportsProgress()
+ {
+ const string json = """
+ {"role":"client","instance":"work","state":"disconnected","mode":"none",
+ "server_node_id":"node","device_id":"x",
+ "failed_attempts":3,"next_attempt_secs":8,
+ "last_error":"Signaling error: Failed to connect to server"}
+ """;
+ var status = ClientStatus.Parse(json);
+ Assert.NotNull(status);
+ Assert.False(status!.IsConnected);
+ Assert.Equal(3, status.FailedAttempts);
+ Assert.Equal(8ul, status.NextAttemptSecs);
+ Assert.Equal("Signaling error: Failed to connect to server", status.LastError);
+
+ const string trying = """
+ {"state":"disconnected","failed_attempts":1,"next_attempt_secs":0,"last_error":"Connection lost"}
+ """;
+ var mid = ClientStatus.Parse(trying);
+ Assert.Equal(1, mid!.FailedAttempts);
+ Assert.Equal(0ul, mid.NextAttemptSecs);
}
[Fact]
@@ -50,6 +79,9 @@ public void Parse_Disconnected()
Assert.False(status!.IsConnected);
Assert.Null(status.AssignedIp);
Assert.Empty(status.Routes);
+ Assert.Equal(0, status.FailedAttempts);
+ Assert.Null(status.NextAttemptSecs);
+ Assert.Null(status.LastError);
}
[Theory]