Skip to content

Commit 679f21c

Browse files
Fix V2 wizard none-channel hang (#450)
## Summary - Fix V2 wizard hang when selecting no channels during first-run setup. - Pre-seed gateway reload mode so config writes do not interrupt the active wizard request. ## Validation - GitHub checks passed before merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 02ee756 commit 679f21c

2 files changed

Lines changed: 183 additions & 0 deletions

File tree

src/OpenClaw.Tray.WinUI/Onboarding/GatewayWizard/GatewayWizardPage.cs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,44 @@ void ApplyStep(JsonElement payload)
9393

9494
setWizardState("complete");
9595
SaveState("complete");
96+
97+
// Re-arm gateway.reload.mode=hybrid (the gateway default) now that
98+
// the wizard's burst of config writes is done. The pre-seed in
99+
// OpenClawCliGatewayConfigurationPreparer.PrepareAsync set this to
100+
// "hot" so the wizard's mid-flow config writes wouldn't fire a
101+
// service restart that cancels the in-flight wizard.next (see that
102+
// file's comment for the full root-cause analysis). Leaving "hot"
103+
// permanent would silently suppress restart-required config changes
104+
// later (e.g., port/bind/auth changes from the tray Settings page
105+
// would update openclaw.json but the running gateway would keep its
106+
// old settings). Resetting to "hybrid" restores normal behavior.
107+
//
108+
// This write itself does not trigger a reload: gateway.reload is a
109+
// { prefix: "gateway.reload", kind: "none" } rule in
110+
// src/gateway/config-reload-plan.ts:50. Best-effort fire-and-forget;
111+
// failures only log because we don't want to block the wizard
112+
// complete transition on a config write blip.
113+
try
114+
{
115+
var resetClient = ((App)Microsoft.UI.Xaml.Application.Current).GatewayClient ?? Props.GatewayClient;
116+
if (resetClient is not null)
117+
{
118+
_ = resetClient.SetConfigAsync("gateway.reload.mode", "hybrid")
119+
.ContinueWith(t =>
120+
{
121+
if (t.IsFaulted)
122+
Logger.Warn($"[GatewayWizard] Failed to reset gateway.reload.mode to hybrid: {t.Exception?.GetBaseException().Message}");
123+
else if (t.Result)
124+
Logger.Info("[GatewayWizard] Reset gateway.reload.mode to hybrid after wizard completion");
125+
else
126+
Logger.Warn("[GatewayWizard] Reset gateway.reload.mode returned false (config.set declined)");
127+
}, TaskScheduler.Default);
128+
}
129+
}
130+
catch (Exception resetEx)
131+
{
132+
Logger.Warn($"[GatewayWizard] Could not schedule gateway.reload.mode reset: {resetEx.Message}");
133+
}
96134
return;
97135
}
98136

@@ -331,6 +369,10 @@ async void SubmitStep()
331369
ApplyStep(response);
332370
}
333371
}
372+
catch (OperationCanceledException ex)
373+
{
374+
await TryRecoverFromServiceRestartAsync(app, stepId, ex);
375+
}
334376
catch (Exception ex)
335377
{
336378
// SECURITY: Log full exception, show a sanitized version of the gateway
@@ -392,6 +434,17 @@ async void SkipStep()
392434
var response = await client.SendWizardRequestAsync("wizard.next", parameters);
393435
ApplyStep(response);
394436
}
437+
catch (OperationCanceledException ex)
438+
{
439+
// Sibling-defect recovery: SkipStep hits the same WS close 1012
440+
// restart race as SubmitStep when the user clicks the Skip button
441+
// (vs picking __skip__ + Continue). Route through the same recovery
442+
// helper so a transient disconnect or service-restart mid-skip
443+
// doesn't leave the wizard surface stuck. See SubmitStep's catch
444+
// for the underlying root-cause analysis.
445+
var app2 = (App)Microsoft.UI.Xaml.Application.Current;
446+
await TryRecoverFromServiceRestartAsync(app2, stepId, ex);
447+
}
395448
catch (Exception ex)
396449
{
397450
Logger.Error($"[GatewayWizard] Skip step failed: {ex}");
@@ -409,6 +462,112 @@ async void SkipStep()
409462
}
410463
}
411464

465+
// Recovery helper shared by SubmitStep and SkipStep.
466+
//
467+
// Empirical bug (logs at 2026-05-16 14:47:44, channels-skip path on a
468+
// fresh openclaw.json): the gateway commits the wizard's collected
469+
// config to disk between stages (writeWizardConfigFile at
470+
// src/wizard/setup.ts:750). On a fresh install that snapshot adds new
471+
// gateway.bind / gateway.tailscale.* / gateway.controlUi.* /
472+
// auth.profiles.* paths, all of which match the catch-all
473+
// { prefix: "gateway", kind: "restart" } rule in
474+
// src/gateway/config-reload-plan.ts:126. The chokidar config-reload
475+
// watcher in src/gateway/config-reload.ts queues a service restart
476+
// (WS close 1012, "gateway restarting", restartExpectedMs: 1500 — see
477+
// src/cli/gateway-cli/run-loop.ts:558).
478+
// OpenClawGatewayClient.ClearPendingRequests then raises
479+
// OperationCanceledException from the pending wizard TCS.
480+
//
481+
// The primary fix for this is the gateway.reload.mode=hot pre-seed in
482+
// OpenClawCliGatewayConfigurationPreparer.PrepareAsync; this helper is
483+
// the defense-in-depth path that still runs if any other event drops
484+
// the wizard.next mid-flight (gateway crash, manual systemctl restart,
485+
// intermittent WSL network blip, etc.). Mirrors the macOS tray pattern
486+
// in apps/macos/Sources/OpenClaw/OnboardingWizard.swift:171-186
487+
// (restartIfSessionLost): wait for reconnect, clear stale session,
488+
// re-issue wizard.start. If the gateway-side session is still alive
489+
// (transient WS disconnect, no full restart), fall back to wizard.status
490+
// — mirrors StartWizard at the top of this file (lines 238-253).
491+
async Task TryRecoverFromServiceRestartAsync(App app, string failingStepId, OperationCanceledException originalEx)
492+
{
493+
Logger.Info($"[GatewayWizard] wizard.next cancelled by service-restart-like event ({originalEx.Message}); waiting for reconnect to restart wizard…");
494+
setErrorMsg("");
495+
setWizardState("loading");
496+
SaveState("loading");
497+
498+
IOperatorGatewayClient? client = null;
499+
var reconnected = false;
500+
for (int wait = 0; wait < 20; wait++)
501+
{
502+
client = app.GatewayClient ?? Props.GatewayClient;
503+
if (client?.IsConnectedToGateway == true) { reconnected = true; break; }
504+
await Task.Delay(1000);
505+
}
506+
507+
if (!reconnected || client == null)
508+
{
509+
Logger.Warn("[GatewayWizard] Gateway did not reconnect within 20s after service restart");
510+
var msg = LocalizationHelper.GetString("Onboarding_Wizard_ErrorGatewayDisconnectedDetail");
511+
if (string.IsNullOrEmpty(msg) || msg == "Onboarding_Wizard_ErrorGatewayDisconnectedDetail")
512+
msg = "Gateway is restarting. Try again in a moment.";
513+
setErrorMsg(msg);
514+
setWizardState("error");
515+
SaveState("error", msg);
516+
return;
517+
}
518+
519+
// Clear the stale session before restarting so wizard.start does not
520+
// get rejected with "wizard already running" if the server preserved
521+
// the in-memory session across our WS close (transient blip).
522+
Props.WizardSessionId = null;
523+
Props.WizardStepPayload = null;
524+
525+
try
526+
{
527+
Logger.Info("[GatewayWizard] Reconnected; calling wizard.start to recover from service restart");
528+
var restartResponse = await client.SendWizardRequestAsync("wizard.start");
529+
ApplyStep(restartResponse);
530+
}
531+
catch (InvalidOperationException sessionStillRunning) when (sessionStillRunning.Message.Contains("already running", StringComparison.OrdinalIgnoreCase))
532+
{
533+
// Transient disconnect, NOT a full service restart: the gateway-side
534+
// wizard session is still alive. wizard.status returns {status,error?}
535+
// only — no step payload — but ApplyStep falls through to
536+
// setWizardState("active") preserving whatever stale step the UI was
537+
// showing. Mirrors StartWizard's pattern at lines 238-253.
538+
Logger.Info("[GatewayWizard] Wizard session still running after reconnect, fetching current status…");
539+
try
540+
{
541+
var statusResponse = await client.SendWizardRequestAsync("wizard.status");
542+
ApplyStep(statusResponse);
543+
}
544+
catch
545+
{
546+
Logger.Warn("[GatewayWizard] Could not resume existing wizard session after reconnect, marking offline");
547+
setWizardState("offline");
548+
SaveState("offline");
549+
}
550+
}
551+
catch (InvalidOperationException unknownOrMissing) when (
552+
unknownOrMissing.Message.Contains("unknown method", StringComparison.OrdinalIgnoreCase) ||
553+
unknownOrMissing.Message.Contains("not found", StringComparison.OrdinalIgnoreCase))
554+
{
555+
Logger.Warn($"[GatewayWizard] wizard.start after reconnect hit unknown-method/not-found: {unknownOrMissing.Message}");
556+
setWizardState("offline");
557+
SaveState("offline");
558+
}
559+
catch (Exception restartEx)
560+
{
561+
Logger.Error($"[GatewayWizard] wizard.start after service restart failed: {restartEx}");
562+
var fallback = LocalizationHelper.GetString("Onboarding_Wizard_StepError");
563+
if (fallback == "Onboarding_Wizard_StepError") fallback = WizardErrorFormatter.GenericFallbackMessage;
564+
var msg = WizardErrorFormatter.FormatStepError(restartEx, failingStepId, fallback);
565+
setErrorMsg(msg);
566+
setWizardState("error");
567+
SaveState("error", msg);
568+
}
569+
}
570+
412571
// Always render exactly the same element tree structure.
413572
// Use empty strings for unused fields to keep a consistent child count.
414573
string displayTitle = "";

src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewaySetup.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,30 @@ public async Task<GatewayConfigurationResult> PrepareAsync(LocalGatewaySetupOpti
11461146
openClaw + " config set gateway.port " + options.GatewayPort.ToString(CultureInfo.InvariantCulture) + " --strict-json",
11471147
openClaw + " config set gateway.auth.mode token",
11481148
"xargs -r " + openClaw + " config set gateway.auth.token </var/lib/openclaw/gateway-token",
1149+
// Suppress restart-required reloads triggered by config writes the V2 setup
1150+
// wizard makes mid-flow. With the default "hybrid" mode, the gateway-side
1151+
// wizard at src/wizard/setup.ts:750 commits the wizard's collected snapshot
1152+
// (gateway.bind / gateway.tailscale.* / gateway.controlUi.* /
1153+
// auth.profiles.*) — those paths fall under the catch-all restart rule
1154+
// in src/gateway/config-reload-plan.ts (line 126: { prefix: "gateway",
1155+
// kind: "restart" }) and would fire a service restart (WS close 1012,
1156+
// restartExpectedMs: 1500). That cancels the in-flight wizard.next mid-
1157+
// step and forces the operator to re-walk the entire wizard with no
1158+
// memory of the previous answers.
1159+
//
1160+
// Setting reload.mode=hot makes the watcher LOG-and-IGNORE restart-required
1161+
// changes (src/gateway/config-reload.ts:274-281: "config reload requires
1162+
// gateway restart; hot mode ignoring") while still allowing legitimate
1163+
// hot reloads (channels/hooks/plugins). The gateway already has the right
1164+
// bind/auth/port from this PrepareGatewayConfig phase, so suppressing the
1165+
// mid-wizard restart loses nothing: the wizard's writes for those paths
1166+
// are no-ops in terms of running gateway state.
1167+
//
1168+
// gateway.reload itself is { prefix: "gateway.reload", kind: "none" } in
1169+
// the reload-plan rules, so this write does not itself trigger a reload.
1170+
// The watcher is not yet active when this script runs (StartGateway phase
1171+
// hasn't started), so even the earlier writes above don't notify.
1172+
openClaw + " config set gateway.reload.mode hot",
11491173
openClaw + " config validate"
11501174
});
11511175

0 commit comments

Comments
 (0)