Skip to content

Commit 2fcfe76

Browse files
ranjeshjCopilot
andauthored
fix: connection stability — stop node reconnect storms, fix bootstrap token handling (#287)
* fix: connection stability — stop node reconnect storms, fix bootstrap token handling Critical fixes for connection management bugs introduced in PR #272: 1. Node reconnect storm during pairing (WindowsNodeClient) - Added ShouldAutoReconnect() override with _pairingBlocked flag - Flag survives OnDisconnected() (which clears _isPendingApproval) - Added rate-limit detection for terminal auth errors - Marked _pairingBlocked/_rateLimited as volatile for thread safety - Clear _rateLimited on successful hello-ok (transient, not permanent) 2. Backoff jitter (WebSocketClientBase) - Added 0-25% random jitter to prevent thundering herd when operator + node clients reconnect simultaneously 3. Client leak on reinitialize (App.xaml.cs) - Added _gatewayClient?.Dispose() before creating new client - Old clients were keeping reconnect loops alive as zombies 4. Bootstrap token not saved as Settings.Token - Setup code decoder no longer persists bootstrap to Settings.Token - Prevents reconnect storms on app restart with stale bootstrap token - TestConnection skips writing bootstrap value to Settings.Token - InitializeGatewayClient falls back to BootstrapToken for bootstrap flow 5. Token PasswordBox → TextBox - Users can see what they pasted (SetupWizardWindow + ConnectionPage) 6. Clear stale tray data on disconnect - Sessions/channels/nodes/models cleared when disconnected/error - Tray menu no longer shows old data alongside 'Disconnected' 7. Onboarding UX fixes - Removed disruptive auto-paste-on-focus from setup code field - Setup code state only updates on valid decode (prevents focus loss) - Added 'Relaunch First-Run Setup' button to Debug page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: increase PowerShell echo test timeout to 30s for slow CI runners Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 584a19f commit 2fcfe76

12 files changed

Lines changed: 105 additions & 52 deletions

File tree

src/OpenClaw.Shared/WebSocketClientBase.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ protected async Task ReconnectWithBackoffAsync()
251251
while (!_disposed && !_cts.Token.IsCancellationRequested && ShouldAutoReconnect())
252252
{
253253
var delay = BackoffMs[Math.Min(_reconnectAttempts, BackoffMs.Length - 1)];
254+
// Add 0-25% jitter to prevent thundering herd when multiple clients
255+
// (operator + node) reconnect on the same schedule
256+
var jitter = Random.Shared.Next(0, delay / 4);
257+
delay += jitter;
254258
_reconnectAttempts++;
255259
_logger.Warn($"{ClientRole} reconnecting in {delay}ms (attempt {_reconnectAttempts})");
256260
RaiseStatusChanged(ConnectionStatus.Connecting);

src/OpenClaw.Shared/WindowsNodeClient.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ public class WindowsNodeClient : WebSocketClientBase
3030
private bool _isPaired;
3131
// Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken.
3232
private bool _pairingApprovedAwaitingReconnect;
33+
// Persists across disconnect/error so ShouldAutoReconnect can block reconnect
34+
// even after OnDisconnected clears _isPendingApproval.
35+
private volatile bool _pairingBlocked;
36+
private volatile bool _rateLimited;
3337
private readonly string _gatewayToken;
3438
private readonly string? _bootstrapToken;
3539

@@ -277,6 +281,7 @@ private void HandlePairingRequestedEvent(JsonElement root, string? eventType)
277281

278282
_isPendingApproval = true;
279283
_isPaired = false;
284+
_pairingBlocked = true;
280285
_pairingApprovedAwaitingReconnect = false;
281286

282287
_logger.Info($"[NODE] Pairing requested for this device via {eventType}");
@@ -310,6 +315,7 @@ private async Task HandlePairingResolvedEventAsync(JsonElement root, string? eve
310315
{
311316
_isPendingApproval = false;
312317
_isPaired = true;
318+
_pairingBlocked = false; // Allow reconnect after approval
313319
_pairingApprovedAwaitingReconnect = true;
314320

315321
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
@@ -603,6 +609,7 @@ private void HandleResponse(JsonElement root)
603609
PublishGatewaySelf(GatewaySelfInfo.FromHelloOk(payload));
604610
var reconnectingAfterApproval = _pairingApprovedAwaitingReconnect;
605611
_isConnected = true;
612+
_rateLimited = false; // Clear transient rate-limit on successful connect
606613
ResetReconnectAttempts();
607614

608615
// Extract node ID if returned
@@ -654,6 +661,7 @@ private void HandleResponse(JsonElement root)
654661
{
655662
_isPendingApproval = true;
656663
_isPaired = false;
664+
_pairingBlocked = true;
657665
_logger.Info("Not yet paired - check 'openclaw devices list' for pending approval");
658666
_logger.Info($"To approve, run: openclaw devices approve {_deviceIdentity.DeviceId}");
659667
PairingStatusChanged?.Invoke(this, new PairingStatusEventArgs(
@@ -717,6 +725,7 @@ private void HandleRequestError(JsonElement root)
717725

718726
_isPendingApproval = true;
719727
_isPaired = false;
728+
_pairingBlocked = true;
720729
_pairingApprovedAwaitingReconnect = false;
721730

722731
var detail = !string.IsNullOrWhiteSpace(pairingRequestId)
@@ -731,6 +740,18 @@ private void HandleRequestError(JsonElement root)
731740
return;
732741
}
733742

743+
// Rate-limit / terminal auth errors — stop reconnecting
744+
if (error.Contains("too many failed", StringComparison.OrdinalIgnoreCase) ||
745+
error.Contains("rate limit", StringComparison.OrdinalIgnoreCase) ||
746+
error.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase) ||
747+
error.Contains("token mismatch", StringComparison.OrdinalIgnoreCase))
748+
{
749+
_rateLimited = true;
750+
_logger.Warn($"[NODE] Terminal auth error; stopping reconnect. Error: {error}");
751+
RaiseStatusChanged(ConnectionStatus.Error);
752+
return;
753+
}
754+
734755
_logger.Error($"Node registration failed: {error} (code: {errorCode})");
735756
RaiseStatusChanged(ConnectionStatus.Error);
736757
}
@@ -997,6 +1018,20 @@ private void PublishGatewaySelf(GatewaySelfInfo info)
9971018
GatewaySelfUpdated?.Invoke(this, info);
9981019
}
9991020

1021+
protected override bool ShouldAutoReconnect()
1022+
{
1023+
// Don't reconnect while awaiting pairing approval — each reconnect
1024+
// generates a new pairing request on the gateway, causing a storm.
1025+
// _pairingBlocked survives OnDisconnected (which clears _isPendingApproval).
1026+
if (_pairingBlocked)
1027+
return false;
1028+
1029+
if (_rateLimited)
1030+
return false;
1031+
1032+
return true;
1033+
}
1034+
10001035
protected override void OnDisconnected()
10011036
{
10021037
_isConnected = false;

src/OpenClaw.Tray.WinUI/App.xaml.cs

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1494,19 +1494,30 @@ private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false)
14941494
return;
14951495
}
14961496

1497-
if (string.IsNullOrWhiteSpace(_settings.Token))
1497+
// Need either a regular token or a bootstrap token to connect
1498+
var effectiveToken = _settings.Token;
1499+
if (string.IsNullOrWhiteSpace(effectiveToken))
14981500
{
1499-
Logger.Info("Gateway token not configured — skipping operator client initialization");
1500-
return;
1501+
if (useBootstrapHandoffAuth && !string.IsNullOrWhiteSpace(_settings.BootstrapToken))
1502+
{
1503+
// Bootstrap-only flow (setup code / QR): use bootstrap token for initial pairing
1504+
effectiveToken = _settings.BootstrapToken;
1505+
}
1506+
else
1507+
{
1508+
Logger.Info("Gateway token not configured — skipping operator client initialization");
1509+
return;
1510+
}
15011511
}
15021512

15031513
// Unsubscribe from old client if exists
15041514
UnsubscribeGatewayEvents();
1515+
_gatewayClient?.Dispose();
15051516
_lastGatewaySelf = null;
15061517

15071518
_gatewayClient = new OpenClawGatewayClient(
15081519
gatewayUrl,
1509-
_settings.Token,
1520+
effectiveToken,
15101521
new AppLogger(),
15111522
useBootstrapHandoffAuth);
15121523
_gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null);
@@ -1947,6 +1958,19 @@ private void OnConnectionStatusChanged(object? sender, ConnectionStatus status)
19471958
if (_hubWindow != null && !_hubWindow.IsClosed)
19481959
_hubWindow.LastAuthError = null;
19491960
}
1961+
1962+
// Clear stale data when disconnected so tray menu doesn't show old sessions/nodes
1963+
if (status == ConnectionStatus.Disconnected || status == ConnectionStatus.Error)
1964+
{
1965+
_lastSessions = Array.Empty<SessionInfo>();
1966+
_lastChannels = Array.Empty<ChannelHealth>();
1967+
_lastNodes = Array.Empty<GatewayNodeInfo>();
1968+
_lastNodePairList = null;
1969+
_lastDevicePairList = null;
1970+
_lastModelsList = null;
1971+
_lastGatewaySelf = null;
1972+
}
1973+
19501974
UpdateTrayIcon();
19511975
_dispatcherQueue?.TryEnqueue(UpdateStatusDetailWindow);
19521976

@@ -2506,6 +2530,7 @@ private void ShowHub(string? navigateTo = null)
25062530
_hubWindow.OpenDashboardAction = OpenDashboard;
25072531
_hubWindow.CheckForUpdatesAction = () => _ = CheckForUpdatesUserInitiatedAsync();
25082532
_hubWindow.QuickSendAction = () => ShowQuickSend();
2533+
_hubWindow.OpenSetupAction = () => _ = ShowOnboardingAsync();
25092534
_hubWindow.ConnectAction = () =>
25102535
{
25112536
InitializeGatewayClient();

src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -136,21 +136,23 @@ void SelectMode(ConnectionMode m)
136136

137137
void OnSetupCodeChanged(string code)
138138
{
139-
setSetupCode(code);
140139
if (string.IsNullOrWhiteSpace(code)) return;
141140

142141
var result = SetupCodeDecoder.Decode(code);
143142

144143
if (!result.Success)
145144
{
146-
// Not a valid setup code — user might be still typing
145+
// Not a valid setup code — user might be still typing.
146+
// Don't call setSetupCode here to avoid re-render that steals focus.
147147
if (code.Length > 2048)
148148
Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit");
149149
else
150150
Logger.Debug($"[Connection] Setup code parse attempt failed: {result.Error}");
151151
return;
152152
}
153153

154+
// Valid setup code decoded — now update state (will re-render)
155+
setSetupCode(code);
154156
if (result.Url != null)
155157
{
156158
setUrl(result.Url);
@@ -159,7 +161,8 @@ void OnSetupCodeChanged(string code)
159161
if (result.Token != null)
160162
{
161163
setToken(result.Token);
162-
Props.Settings.Token = result.Token;
164+
// Bootstrap token goes to BootstrapToken only — it's single-use for pairing.
165+
// Don't save as Settings.Token (causes reconnect storms on restart).
163166
Props.Settings.BootstrapToken = result.Token;
164167
}
165168
setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}");
@@ -205,7 +208,13 @@ bool TryCopyPairingCommand(string command)
205208
async void TestConnection()
206209
{
207210
Props.Settings.GatewayUrl = url;
208-
Props.Settings.Token = token;
211+
// Only save to Settings.Token if the user entered a manual token,
212+
// not a decoded bootstrap token (which belongs in BootstrapToken only).
213+
if (string.IsNullOrWhiteSpace(Props.Settings.BootstrapToken) ||
214+
!string.Equals(token, Props.Settings.BootstrapToken, StringComparison.Ordinal))
215+
{
216+
Props.Settings.Token = token;
217+
}
209218

210219
// When SSH mode, start the managed tunnel before health-checking the local URL.
211220
if (mode == ConnectionMode.Ssh)
@@ -473,40 +482,14 @@ void PasteSetupCode()
473482
catch { /* clipboard unavailable — ignore */ }
474483
}
475484

476-
// Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding)
485+
// Setup code row: TextField + Paste + QR buttons
477486
cardChildren.Add(
478487
Grid(["1*", "Auto", "Auto"], ["Auto"],
479488
TextField(setupCode, OnSetupCodeChanged,
480489
placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"),
481490
header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode"))
482-
.OnGotFocus((sender, _) =>
483-
{
484-
if (sender is Microsoft.UI.Xaml.Controls.TextBox tb && string.IsNullOrEmpty(tb.Text))
485-
{
486-
try
487-
{
488-
var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent();
489-
if (content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text))
490-
{
491-
var task = content.GetTextAsync();
492-
task.Completed = (op, status) =>
493-
{
494-
if (status == global::Windows.Foundation.AsyncStatus.Completed)
495-
{
496-
var text = op.GetResults();
497-
tb.DispatcherQueue.TryEnqueue(() =>
498-
{
499-
tb.Text = text;
500-
OnSetupCodeChanged(text);
501-
});
502-
}
503-
};
504-
}
505-
}
506-
catch { }
507-
}
508-
})
509-
.Grid(row: 0, column: 0),
491+
.Grid(row: 0, column: 0)
492+
.Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSetupCode")),
510493
Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode)
511494
.VAlign(VerticalAlignment.Bottom)
512495
.Margin(6, 0, 0, 0)

src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
<ColumnDefinition Width="Auto"/>
101101
<ColumnDefinition Width="Auto"/>
102102
</Grid.ColumnDefinitions>
103-
<PasswordBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
103+
<TextBox x:Uid="TokenPromptBox" x:Name="TokenPromptBox" Grid.Column="0"
104104
PlaceholderText="Gateway token" Header="Token"/>
105105
<Button x:Uid="ConnectionPage_Button_105" Grid.Column="1" Content="Cancel"
106106
VerticalAlignment="Bottom" Click="OnCancelTokenPrompt"/>

src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -473,14 +473,14 @@ private void OnConnectToGateway(object sender, RoutedEventArgs e)
473473
_pendingGatewayUrl = gw.ConnectionUrl;
474474
_pendingGatewayId = gw.Id;
475475
TokenPromptText.Text = $"Connect to gateway at {gw.Host}:{gw.Port}";
476-
TokenPromptBox.Password = _hub.Settings.Token ?? "";
476+
TokenPromptBox.Text = _hub.Settings.Token ?? "";
477477
TokenPromptPanel.Visibility = Visibility.Visible;
478478
TokenPromptBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
479479
}
480480

481481
private void OnConnectWithToken(object sender, RoutedEventArgs e)
482482
{
483-
var token = TokenPromptBox.Password?.Trim();
483+
var token = TokenPromptBox.Text?.Trim();
484484
if (string.IsNullOrEmpty(token) || _hub?.Settings == null || string.IsNullOrEmpty(_pendingGatewayUrl))
485485
return;
486486

@@ -535,10 +535,9 @@ private void OnApplySetupCode(object sender, RoutedEventArgs e)
535535
settings.GatewayUrl = result.Url;
536536
if (!string.IsNullOrEmpty(result.Token))
537537
{
538+
// Bootstrap token goes to BootstrapToken only — it's single-use for pairing.
539+
// Don't save it as Settings.Token, which would cause reconnect storms on restart.
538540
settings.BootstrapToken = result.Token;
539-
// Also set as the operator token so InitializeGatewayClient can connect
540-
if (string.IsNullOrWhiteSpace(settings.Token))
541-
settings.Token = result.Token;
542541
}
543542

544543
settings.Save();

src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
<Button x:Uid="DebugPage_Button_113" Content="📁 Open Diagnostics Folder" Click="OnOpenDiagnosticsFolder" HorizontalAlignment="Stretch"/>
114114
<Button x:Uid="DebugPage_Button_114" Content="📋 Copy Support Context" Click="OnCopySupportContext"
115115
Style="{ThemeResource AccentButtonStyle}" HorizontalAlignment="Stretch"/>
116+
<Button Content="🔄 Relaunch First-Run Setup" Click="OnRelaunchOnboarding" HorizontalAlignment="Stretch"/>
116117
</StackPanel>
117118
</Expander>
118119

src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,9 @@ private void OnCopySupportContext(object sender, RoutedEventArgs e)
196196
timer.Start();
197197
}
198198
}
199+
200+
private void OnRelaunchOnboarding(object sender, RoutedEventArgs e)
201+
{
202+
_hub?.OpenSetupAction?.Invoke();
203+
}
199204
}

src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public sealed partial class HubWindow : WindowEx
3030
public Action? ConnectAction { get; set; }
3131
public Action? DisconnectAction { get; set; }
3232
public Action? ReconnectAction { get; set; }
33+
public Action? OpenSetupAction { get; set; }
3334

3435
// Node service state (set by App.xaml.cs in ShowHub)
3536
public bool NodeIsConnected { get; set; }

src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public sealed class SetupWizardWindow : WindowEx
5151
// Step 0: Setup code + manual entry
5252
private readonly TextBox _setupCodeBox;
5353
private readonly TextBox _gatewayUrlBox;
54-
private readonly PasswordBox _tokenBox;
54+
private readonly TextBox _tokenBox;
5555
private readonly TextBlock _testStatusLabel;
5656
private readonly Button _testButton;
5757
private readonly StackPanel _manualEntryPanel;
@@ -196,17 +196,17 @@ public SetupWizardWindow(SettingsManager settings)
196196
Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"],
197197
Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"]
198198
});
199-
_tokenBox = new PasswordBox
199+
_tokenBox = new TextBox
200200
{
201201
Header = LocalizationHelper.GetString("Setup_TokenHeader"),
202202
PlaceholderText = LocalizationHelper.GetString("Setup_TokenPlaceholder"),
203-
Password = _draftToken
203+
Text = _draftToken
204204
};
205205
AutomationProperties.SetAutomationId(_tokenBox, "TokenBox");
206-
_tokenBox.PasswordChanged += (s, e) => _connectionTested = false;
207-
_tokenBox.PasswordChanged += (s, e) =>
206+
_tokenBox.TextChanged += (s, e) => _connectionTested = false;
207+
_tokenBox.TextChanged += (s, e) =>
208208
{
209-
_draftToken = _tokenBox.Password;
209+
_draftToken = _tokenBox.Text;
210210
UpdatePairingStatusText();
211211
};
212212
_manualEntryPanel.Children.Add(_tokenBox);
@@ -718,7 +718,7 @@ private void UpdateNodeModePairingVisibility(bool showPairing)
718718
private async void OnTestConnection(object sender, RoutedEventArgs e)
719719
{
720720
_draftGatewayUrl = _gatewayUrlBox.Text.Trim();
721-
_draftToken = _tokenBox.Password;
721+
_draftToken = _tokenBox.Text;
722722
UpdatePairingStatusText();
723723

724724
if (!GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl))

0 commit comments

Comments
 (0)