Skip to content

Commit ef0240f

Browse files
ranjeshjCopilot
andauthored
fix: setup flow, connection page, and chat token bugs
Fix setup flow, connection page, and chat token bugs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9288bd6 commit ef0240f

6 files changed

Lines changed: 271 additions & 76 deletions

File tree

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

Lines changed: 137 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ public void Initialize(HubWindow hub)
3636
if (_connectionManager != null)
3737
_connectionManager.StateChanged += OnManagerStateChanged;
3838

39+
Unloaded += OnPageUnloaded;
40+
3941
// Populate manual connection fields
4042
GatewayUrlTextBox.Text = settings.GatewayUrl ?? "";
4143
SshToggle.IsOn = settings.UseSshTunnel;
@@ -58,6 +60,12 @@ public void Initialize(HubWindow hub)
5860

5961
private GatewayConnectionSnapshot? _lastSnapshot;
6062

63+
private void OnPageUnloaded(object sender, RoutedEventArgs e)
64+
{
65+
if (_connectionManager != null)
66+
_connectionManager.StateChanged -= OnManagerStateChanged;
67+
}
68+
6169
private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot snapshot)
6270
{
6371
DispatcherQueue?.TryEnqueue(() =>
@@ -217,7 +225,10 @@ private void UpdatePairingGuidance(GatewayConnectionSnapshot snapshot)
217225
}
218226
}
219227
}
220-
catch { }
228+
catch (Exception ex)
229+
{
230+
System.Diagnostics.Debug.WriteLine($"[ConnectionPage] Failed to read device ID from identity file: {ex.Message}");
231+
}
221232
}
222233

223234
if (snapshot.OperatorState == RoleConnectionState.PairingRequired)
@@ -520,29 +531,54 @@ private async void OnDirectConnect(object sender, RoutedEventArgs e)
520531
}
521532

522533
url = GatewayUrlHelper.NormalizeForWebSocket(url);
534+
535+
// Validate SSH config upfront before mutating any state
536+
var useSsh = SshToggle.IsOn;
537+
SshTunnelConfig? sshConfig = null;
538+
if (useSsh)
539+
{
540+
var sshUser = SshUserBox.Text.Trim();
541+
var sshHost = SshHostBox.Text.Trim();
542+
if (string.IsNullOrWhiteSpace(sshUser) || string.IsNullOrWhiteSpace(sshHost))
543+
{
544+
DirectConnectResultText.Text = "SSH user and host are required";
545+
return;
546+
}
547+
if (!int.TryParse(SshRemotePortBox.Text, out var remotePort) || remotePort is < 1 or > 65535)
548+
{
549+
DirectConnectResultText.Text = "SSH remote port must be 1–65535";
550+
return;
551+
}
552+
if (!int.TryParse(SshLocalPortBox.Text, out var localPort) || localPort is < 1 or > 65535)
553+
{
554+
DirectConnectResultText.Text = "SSH local port must be 1–65535";
555+
return;
556+
}
557+
sshConfig = new SshTunnelConfig(sshUser, sshHost, remotePort, localPort);
558+
}
559+
523560
DirectConnectResultText.Text = "Connecting…";
524561

562+
// Snapshot previous state for rollback
563+
var previousActiveId = _gatewayRegistry.ActiveGatewayId;
564+
var previousSettings = _hub?.Settings;
565+
var prevGatewayUrl = previousSettings?.GatewayUrl;
566+
var prevUseSsh = previousSettings?.UseSshTunnel ?? false;
567+
var prevSshUser = previousSettings?.SshTunnelUser;
568+
var prevSshHost = previousSettings?.SshTunnelHost;
569+
var prevSshRemotePort = previousSettings?.SshTunnelRemotePort ?? 0;
570+
var prevSshLocalPort = previousSettings?.SshTunnelLocalPort ?? 0;
571+
572+
var existing = _gatewayRegistry.FindByUrl(url);
573+
var isNewRecord = existing == null;
574+
var existingRecordSnapshot = existing;
575+
var recordId = existing?.Id ?? Guid.NewGuid().ToString();
576+
525577
try
526578
{
527579
await _connectionManager.DisconnectAsync();
528580

529-
// Parse SSH config
530-
var useSsh = SshToggle.IsOn;
531-
SshTunnelConfig? sshConfig = null;
532-
if (useSsh)
533-
{
534-
var sshUser = SshUserBox.Text.Trim();
535-
var sshHost = SshHostBox.Text.Trim();
536-
int.TryParse(SshRemotePortBox.Text, out var remotePort);
537-
int.TryParse(SshLocalPortBox.Text, out var localPort);
538-
if (remotePort <= 0) remotePort = 18789;
539-
if (localPort <= 0) localPort = 18789;
540-
sshConfig = new SshTunnelConfig(sshUser, sshHost, remotePort, localPort);
541-
}
542-
543581
// Create/update gateway record with shared token + SSH config
544-
var existing = _gatewayRegistry.FindByUrl(url);
545-
var recordId = existing?.Id ?? Guid.NewGuid().ToString();
546582
var record = new GatewayRecord
547583
{
548584
Id = recordId,
@@ -576,23 +612,25 @@ private async void OnDirectConnect(object sender, RoutedEventArgs e)
576612
writer.Flush();
577613
System.IO.File.WriteAllBytes(keyPath, ms.ToArray());
578614
}
579-
catch { }
615+
catch (Exception ex)
616+
{
617+
System.Diagnostics.Debug.WriteLine($"[ConnectionPage] Failed to clear device tokens: {ex.Message}");
618+
}
580619
}
581620

582621
// Save settings (SSH config + gateway URL for legacy compat)
583-
var settings = _hub?.Settings;
584-
if (settings != null)
622+
if (previousSettings != null)
585623
{
586-
settings.GatewayUrl = url;
587-
settings.UseSshTunnel = useSsh;
624+
previousSettings.GatewayUrl = url;
625+
previousSettings.UseSshTunnel = useSsh;
588626
if (useSsh && sshConfig != null)
589627
{
590-
settings.SshTunnelUser = sshConfig.User;
591-
settings.SshTunnelHost = sshConfig.Host;
592-
settings.SshTunnelRemotePort = sshConfig.RemotePort;
593-
settings.SshTunnelLocalPort = sshConfig.LocalPort;
628+
previousSettings.SshTunnelUser = sshConfig.User;
629+
previousSettings.SshTunnelHost = sshConfig.Host;
630+
previousSettings.SshTunnelRemotePort = sshConfig.RemotePort;
631+
previousSettings.SshTunnelLocalPort = sshConfig.LocalPort;
594632
}
595-
settings.Save();
633+
previousSettings.Save();
596634
}
597635

598636
// Start SSH tunnel if configured
@@ -604,11 +642,82 @@ private async void OnDirectConnect(object sender, RoutedEventArgs e)
604642
}
605643

606644
await _connectionManager.ConnectAsync(recordId);
607-
DirectConnectResultText.Text = $"✓ Connected to {GatewayUrlHelper.SanitizeForDisplay(url)}";
645+
646+
// Poll connection manager state — ConnectAsync fires connect asynchronously,
647+
// so we need to wait for a definitive result before reporting success/failure.
648+
bool connected = false;
649+
bool failed = false;
650+
for (int attempt = 0; attempt < 15; attempt++)
651+
{
652+
await Task.Delay(1000);
653+
var snapshot = _connectionManager.CurrentSnapshot;
654+
if (snapshot.OverallState is Services.Connection.OverallConnectionState.Connected
655+
or Services.Connection.OverallConnectionState.Ready)
656+
{
657+
connected = true;
658+
break;
659+
}
660+
if (snapshot.OverallState is Services.Connection.OverallConnectionState.Error)
661+
{
662+
failed = true;
663+
break;
664+
}
665+
if (snapshot.OverallState is Services.Connection.OverallConnectionState.PairingRequired)
666+
{
667+
DirectConnectResultText.Text = $"⏳ Pairing required — approve on gateway";
668+
return; // don't rollback, pairing is in progress
669+
}
670+
}
671+
672+
if (connected)
673+
{
674+
DirectConnectResultText.Text = $"✓ Connected to {GatewayUrlHelper.SanitizeForDisplay(url)}";
675+
return;
676+
}
677+
678+
// Connection failed or timed out — rollback
679+
var reason = failed ? "Connection failed" : "Connection timed out";
680+
DirectConnectResultText.Text = $"✗ {reason}";
681+
RollbackDirectConnect(previousActiveId, isNewRecord, recordId, existingRecordSnapshot,
682+
previousSettings, prevGatewayUrl, prevUseSsh, prevSshUser, prevSshHost, prevSshRemotePort, prevSshLocalPort);
608683
}
609684
catch (Exception ex)
610685
{
611686
DirectConnectResultText.Text = $"✗ {ex.Message}";
687+
RollbackDirectConnect(previousActiveId, isNewRecord, recordId, existingRecordSnapshot,
688+
previousSettings, prevGatewayUrl, prevUseSsh, prevSshUser, prevSshHost, prevSshRemotePort, prevSshLocalPort);
689+
}
690+
}
691+
692+
private void RollbackDirectConnect(
693+
string? previousActiveId, bool isNewRecord, string recordId,
694+
GatewayRecord? existingRecordSnapshot, SettingsManager? settings,
695+
string? prevGatewayUrl, bool prevUseSsh, string? prevSshUser,
696+
string? prevSshHost, int prevSshRemotePort, int prevSshLocalPort)
697+
{
698+
if (_gatewayRegistry == null) return;
699+
700+
// Restore or remove the gateway record
701+
if (isNewRecord)
702+
_gatewayRegistry.Remove(recordId);
703+
else if (existingRecordSnapshot != null)
704+
_gatewayRegistry.AddOrUpdate(existingRecordSnapshot);
705+
706+
// Restore active gateway
707+
if (previousActiveId != null)
708+
_gatewayRegistry.SetActive(previousActiveId);
709+
_gatewayRegistry.Save();
710+
711+
// Restore legacy settings
712+
if (settings != null)
713+
{
714+
settings.GatewayUrl = prevGatewayUrl;
715+
settings.UseSshTunnel = prevUseSsh;
716+
settings.SshTunnelUser = prevSshUser;
717+
settings.SshTunnelHost = prevSshHost;
718+
settings.SshTunnelRemotePort = prevSshRemotePort;
719+
settings.SshTunnelLocalPort = prevSshLocalPort;
720+
settings.Save();
612721
}
613722
}
614723

src/OpenClaw.Tray.WinUI/Services/Connection/GatewayConnectionManager.cs

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ public async Task ConnectAsync(string? gatewayId = null)
8181
await _transitionSemaphore.WaitAsync();
8282
try
8383
{
84+
await ConnectCoreAsync(gatewayId);
85+
}
86+
finally
87+
{
88+
_transitionSemaphore.Release();
89+
}
90+
}
91+
92+
/// <summary>Core connect logic. Caller must hold <see cref="_transitionSemaphore"/>.</summary>
93+
private async Task ConnectCoreAsync(string? gatewayId = null)
94+
{
8495
var id = gatewayId ?? _registry.ActiveGatewayId;
8596
if (id == null)
8697
{
@@ -238,11 +249,6 @@ public async Task ConnectAsync(string? gatewayId = null)
238249
_logger.Error($"[ConnMgr] Connect failed: {ex.Message}");
239250
}
240251
}, ct);
241-
}
242-
finally
243-
{
244-
_transitionSemaphore.Release();
245-
}
246252
}
247253

248254
public async Task DisconnectAsync()
@@ -251,36 +257,66 @@ public async Task DisconnectAsync()
251257
await _transitionSemaphore.WaitAsync();
252258
try
253259
{
254-
var prev = _stateMachine.Current.OverallState;
255-
DisposeActiveClient();
256-
_stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested);
257-
_diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState);
258-
EmitStateChanged(prev);
260+
DisconnectCore();
259261
}
260262
finally
261263
{
262264
_transitionSemaphore.Release();
263265
}
264266
}
265267

268+
/// <summary>Core disconnect logic. Caller must hold <see cref="_transitionSemaphore"/>.</summary>
269+
private void DisconnectCore()
270+
{
271+
var prev = _stateMachine.Current.OverallState;
272+
DisposeActiveClient();
273+
_stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested);
274+
_diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState);
275+
EmitStateChanged(prev);
276+
}
277+
266278
public async Task ReconnectAsync()
267279
{
268-
await DisconnectAsync();
269-
await ConnectAsync();
280+
ThrowIfDisposed();
281+
await _transitionSemaphore.WaitAsync();
282+
try
283+
{
284+
DisconnectCore();
285+
await ConnectCoreAsync();
286+
}
287+
finally
288+
{
289+
_transitionSemaphore.Release();
290+
}
270291
}
271292

272293
public async Task SwitchGatewayAsync(string gatewayId)
273294
{
274-
await DisconnectAsync();
275-
// Stop tunnel when switching gateways — the new one may not need it
276-
if (_tunnelManager?.IsActive == true)
295+
ThrowIfDisposed();
296+
await _transitionSemaphore.WaitAsync();
297+
try
298+
{
299+
DisconnectCore();
300+
// Stop tunnel when switching gateways — the new one may not need it.
301+
// Use a bounded timeout to avoid blocking all connection transitions.
302+
if (_tunnelManager?.IsActive == true)
303+
{
304+
try
305+
{
306+
var tunnelStop = _tunnelManager.StopAsync();
307+
if (await Task.WhenAny(tunnelStop, Task.Delay(TimeSpan.FromSeconds(5))) != tunnelStop)
308+
_logger.Warn("[ConnMgr] Tunnel stop timed out during gateway switch");
309+
}
310+
catch (Exception ex) { _logger.Warn($"[ConnMgr] Tunnel stop error on gateway switch: {ex.Message}"); }
311+
}
312+
_gatewayNeedsV2Signature = false; // new gateway might support v3
313+
_registry.SetActive(gatewayId);
314+
await ConnectCoreAsync(gatewayId);
315+
}
316+
finally
277317
{
278-
try { await _tunnelManager.StopAsync(); }
279-
catch (Exception ex) { _logger.Warn($"[ConnMgr] Tunnel stop error on gateway switch: {ex.Message}"); }
318+
_transitionSemaphore.Release();
280319
}
281-
_gatewayNeedsV2Signature = false; // new gateway might support v3
282-
_registry.SetActive(gatewayId);
283-
await ConnectAsync(gatewayId);
284320
}
285321

286322
public async Task<SetupCodeResult> ApplySetupCodeAsync(string setupCode)
@@ -723,10 +759,10 @@ private void EmitStateChanged(OverallConnectionState previousOverall)
723759

724760
private void DisposeActiveClient()
725761
{
726-
// Disconnect node first
762+
// Disconnect node first — run on threadpool to avoid sync context deadlocks
727763
if (_nodeConnector != null)
728764
{
729-
try { _nodeConnector.DisconnectAsync().Wait(TimeSpan.FromSeconds(2)); }
765+
try { Task.Run(() => _nodeConnector.DisconnectAsync()).Wait(TimeSpan.FromSeconds(2)); }
730766
catch (Exception ex) { _logger.Warn($"[ConnMgr] Node disconnect error: {ex.Message}"); }
731767
}
732768

@@ -760,17 +796,27 @@ public void Dispose()
760796
_nodeConnector.StatusChanged -= OnNodeStatusChanged;
761797
_nodeConnector.PairingStatusChanged -= OnNodePairingStatusChanged;
762798
}
763-
_stateMachine.TryTransition(ConnectionTrigger.Disposed);
764-
DisposeActiveClient();
765-
// Stop tunnel on app shutdown
766-
if (_tunnelManager?.IsActive == true)
799+
// Acquire semaphore briefly to ensure no in-flight reconnect/switch is mid-transition.
800+
// Use a short timeout — if something is stuck, proceed with disposal anyway.
801+
try { _transitionSemaphore.Wait(TimeSpan.FromSeconds(2)); } catch { }
802+
try
803+
{
804+
_stateMachine.TryTransition(ConnectionTrigger.Disposed);
805+
DisposeActiveClient();
806+
// Stop tunnel on app shutdown — run on threadpool with timeout to avoid stalling exit
807+
if (_tunnelManager?.IsActive == true)
808+
{
809+
try { Task.Run(() => _tunnelManager.StopAsync()).Wait(TimeSpan.FromSeconds(3)); }
810+
catch { /* shutting down — best effort */ }
811+
}
812+
_operationCts?.Cancel();
813+
_operationCts?.Dispose();
814+
}
815+
finally
767816
{
768-
try { _tunnelManager.StopAsync().GetAwaiter().GetResult(); }
769-
catch { /* shutting down — best effort */ }
817+
try { _transitionSemaphore.Release(); } catch { }
818+
_transitionSemaphore.Dispose();
770819
}
771-
_operationCts?.Cancel();
772-
_operationCts?.Dispose();
773-
_transitionSemaphore.Dispose();
774820
}
775821
}
776822

0 commit comments

Comments
 (0)