Skip to content

Commit c1db6e6

Browse files
kingpanther13claude
andcommitted
fix(settings-ui): robust restart UX — poll-until-ready, cross-tab sync, unified shape
Addresses all reviewer + idiot-checker findings on the previous unified-restart-flow commit. The intent stays the same: save commits the change, the global Restart Add-on button is the single restart path, the page reloads automatically when the addon comes back. The implementation is now actually robust enough to deliver that. Server - ``_save_tools`` now returns the same ``{success, applied, mode, restart_required}`` shape as ``_save_feature_flags`` and ``_save_backup_config``. The previous ``disabled``/``pinned`` count fields had no consumers (JS read none; no test asserted on them) and pre-empted the unified contract — replaced with ``applied`` (the states dict) and ``mode: "file"``. - ``_save_feature_flags`` file/default branch now also returns the unified shape including ``applied`` (was a bare ``{"success": True}`` before). Standalone / Docker / Claude Desktop users get the same restart banner the addon-mode users get. - ``_save_feature_flags`` docstring no longer claims the addon branch schedules a restart; describes the new pending-then-explicit flow. - ``_save_backup_config`` docstring same fix. - File-mode feature-flag comment no longer makes a grep-falsifiable claim about FEATURE_META text; describes the semantic instead. - "reported by the user" rot-prone phrasing in ``_save_feature_flags`` replaced with the enduring tech reason. - ``_schedule_supervisor_self_restart`` now catches ``RuntimeError`` from ``make_supervisor_httpx_client`` (raised when ``SUPERVISOR_TOKEN`` is unset). Mirrors the parity catch the two supervisor options helpers already had. Without it, a race that unsets the token between handler entry and the 300ms-later task wakeup would surface only as asyncio's "Task exception was never retrieved" at GC time. JS — restart flow - 10-second fixed timer replaced with poll-until-ready against ``./api/settings/info``. Initial 3-second grace lets supervisor actually kill the addon before we start probing (so a too-eager first probe doesn't return 200 from the OLD instance and reload too early); then poll every 2 seconds for up to 60 seconds. Reloads as soon as the new instance answers; falls back to "did not come back online — reload manually" if the probe times out. Obsoletes the magic-number constant. - Concurrency guard: module-level ``restartInProgress`` boolean checked at function entry. Guards against DevTools / programmatic invocation that bypass the button's ``disabled`` attribute. Cleared only on a 4xx genuine config error so the user can fix the underlying cause and retry; otherwise stays true through the full restart cycle. - ``restartAddon`` flattened to early-return on the 4xx config-error branch — removes the ``configError`` flag. - Optional-chaining for ``err?.error?.message`` (drops the verbose nullish chain). - Empty ``catch (_e) {}`` for the network-drop path now logs a ``console.warn`` so DevTools shows what happened — useful when debugging "I clicked Restart and nothing happened" reports. JS — multi-tab sync (BroadcastChannel) - New ``restartChannel`` on ``BroadcastChannel('ha-mcp-settings')``. Falls back to ``null`` on browsers without the API; every channel use is null-guarded so older browsers see no regression. - ``saveConfig`` / ``saveFeatureFlag`` / ``saveBackupConfig`` post ``{type: 'restart-required'}`` when their response carries ``restart_required: true``. Other open tabs surface the same banner. - ``restartAddon`` posts ``{type: 'restart-initiated'}`` BEFORE starting its poll cycle. The listener kicks off the same poll-then-reload cycle in every other tab so they all come back to the fresh addon instead of dangling on a stale connection. JS — quality of life - ``saveFeatureFlag`` JSON-parse fallback: on ``resp.ok`` with a truncated / non-JSON body, default to ``{restart_required: true}`` so the banner still shows. Silently hiding the banner would make the user think the change took effect live. - ``saveBackupConfig`` no longer calls ``loadBackupConfig()`` after a ``restart_required: true`` save. In addon mode the GET reads env-derived values that are still stale (Supervisor has the new options but ``start.py`` doesn't re-derive env vars until the next boot) — reloading the form snapped it back to old values and wiped any in-flight edits the user wanted to bundle. Tests - ``test_schedule_self_restart_catches_runtime_error`` pins the RuntimeError parity catch with a caplog assertion targeting the specific "SUPERVISOR_TOKEN unset" message. - ``TestSaveFeatureFlagsStandaloneMode::test_standalone_save_returns_unified_contract_shape`` pins the new file-mode response shape. - ``TestSaveToolsResponseShape::test_save_returns_unified_contract_shape`` pins the new unified shape on ``_save_tools`` and asserts the retired ``disabled``/``pinned`` count fields do not leak through. Verified: ruff check, ruff format, mypy src/, 88 passing in test_settings_ui.py (1 pre-existing Windows skip). ``node --check`` on the rendered ``<script>`` still passes — confirms the BroadcastChannel + polling code is syntactically valid JS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 31d96d8 commit c1db6e6

2 files changed

Lines changed: 363 additions & 56 deletions

File tree

src/ha_mcp/settings_ui.py

Lines changed: 207 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -921,42 +921,139 @@ def apply_tool_visibility(
921921
}
922922
}
923923
924-
const RESTART_RELOAD_DELAY_S = 10;
924+
// Restart-readiness probe tunables. The grace period gives supervisor
925+
// time to actually kill the addon (so a too-eager first probe doesn't
926+
// hit the OLD instance and reload before the new one is up). The poll
927+
// interval is short enough to feel responsive on a fast restart, long
928+
// enough to not hammer ingress. The cap is the user-visible upper
929+
// bound; HAOS addon restarts are typically 15-25s but cold-start +
930+
// image pull can stretch further, so 60s gives genuine breathing room
931+
// before we tell the user the auto-reload failed.
932+
const RESTART_PROBE_INITIAL_GRACE_MS = 3000;
933+
const RESTART_PROBE_INTERVAL_MS = 2000;
934+
const RESTART_PROBE_MAX_TOTAL_MS = 60000;
935+
936+
// Cross-tab restart broadcast channel. When any tab saves a setting
937+
// that needs a restart, it posts ``restart-required`` so the other
938+
// tabs surface the same banner. When any tab fires the supervisor
939+
// restart, it posts ``restart-initiated`` so the other tabs run the
940+
// same poll-then-reload cycle — that way ALL tabs come back to the
941+
// fresh addon instead of leaving stale ones spinning.
942+
const restartChannel =
943+
typeof BroadcastChannel === 'function'
944+
? new BroadcastChannel('ha-mcp-settings')
945+
: null;
946+
947+
// Module-level concurrency guard. The button's ``disabled`` attribute
948+
// blocks normal clicks, but a second invocation via DevTools / a
949+
// keyboard accessibility tool / a cross-tab broadcast would otherwise
950+
// queue a second supervisor restart + a second auto-reload. Cleared
951+
// only on a 4xx genuine config error (so the user can reload and try
952+
// again); otherwise stays true through the restart cycle until the
953+
// page reloads.
954+
let restartInProgress = false;
955+
956+
async function _probeAddonReady() {
957+
// Resolve true when ``/api/settings/info`` returns 200 (addon is
958+
// serving HTTP again), false when we hit the cap. Caller decides
959+
// whether to reload, surface a "didn't come back" message, or
960+
// both. ``cache: 'no-store'`` so the browser doesn't serve a
961+
// stale 200 from before the restart.
962+
const deadline = Date.now() + RESTART_PROBE_MAX_TOTAL_MS;
963+
while (Date.now() < deadline) {
964+
try {
965+
const resp = await fetch('./api/settings/info', {cache: 'no-store'});
966+
if (resp.ok) return true;
967+
} catch (_e) {
968+
// Connection drop / DNS / ingress 5xx while addon is down —
969+
// expected. Suppress noise: a console.warn here would spam the
970+
// devtools log during every restart.
971+
}
972+
await new Promise(r => setTimeout(r, RESTART_PROBE_INTERVAL_MS));
973+
}
974+
return false;
975+
}
976+
977+
async function _runRestartReloadCycle() {
978+
const btn = document.getElementById('restartBtn');
979+
// Initial grace lets supervisor actually kill the addon before we
980+
// start probing — otherwise the first probe may hit the OLD
981+
// instance and we reload before the new one is up.
982+
btn.textContent = 'Restarting…';
983+
await new Promise(r => setTimeout(r, RESTART_PROBE_INITIAL_GRACE_MS));
984+
btn.textContent = 'Waiting for add-on to come back online…';
985+
const ready = await _probeAddonReady();
986+
if (ready) {
987+
window.location.reload();
988+
} else {
989+
// Probe gave up after RESTART_PROBE_MAX_TOTAL_MS. Restart may
990+
// have failed entirely or supervisor is genuinely slow. Surface
991+
// a clear next-step instead of silently doing nothing.
992+
btn.textContent = 'Add-on did not come back online — reload manually';
993+
btn.disabled = false;
994+
restartInProgress = false;
995+
}
996+
}
925997
926998
async function restartAddon() {
999+
if (restartInProgress) return;
9271000
const btn = document.getElementById('restartBtn');
9281001
if (!confirm('Restart the add-on now? The page will reload automatically once the add-on is back online.')) return;
1002+
restartInProgress = true;
9291003
btn.disabled = true;
9301004
btn.textContent = 'Restarting…';
931-
// Only suppress the auto-reload on a genuine config error (4xx like
932-
// SUPERVISOR_TOKEN missing). Anything else — 200, 5xx from ingress
933-
// when supervisor killed our upstream mid-response, or a thrown
934-
// network error from the same kill — means the restart is in flight
935-
// and we should reload after the addon comes back.
936-
let configError = false;
9371005
try {
9381006
const resp = await fetch('./api/settings/restart', {method: 'POST'});
9391007
if (!resp.ok && resp.status < 500) {
940-
configError = true;
1008+
// 4xx is a genuine config error (e.g. SUPERVISOR_TOKEN unset).
1009+
// The restart was NOT initiated — surface the error and let the
1010+
// user fix the underlying cause. Keep button enabled so they
1011+
// can retry once the issue is resolved. Don't broadcast (other
1012+
// tabs would only see a misleading "restart in progress").
9411013
let msg = 'Restart failed';
9421014
try {
9431015
const err = await resp.json();
944-
if (err && err.error && err.error.message) {
945-
msg = 'Failed: ' + err.error.message;
946-
}
947-
} catch (_e) {}
1016+
if (err?.error?.message) msg = 'Failed: ' + err.error.message;
1017+
} catch (_e) { /* leave default msg */ }
9481018
btn.textContent = msg;
9491019
btn.disabled = false;
1020+
restartInProgress = false;
9501021
alert(msg);
1022+
return;
9511023
}
1024+
// 200 OK → background task scheduled. 5xx → ingress upstream
1025+
// drop, restart IS in flight. Both fall through to the reload
1026+
// cycle.
9521027
} catch (_e) {
953-
// Connection lost mid-request — restart in flight, fall through.
1028+
// Network error mid-request — supervisor killed our upstream.
1029+
// Restart in flight; fall through. Log for debug, suppress the
1030+
// unused-binding lint.
1031+
console.warn('restartAddon fetch dropped (expected during self-restart):', _e);
9541032
}
955-
if (!configError) {
956-
btn.textContent =
957-
'Restarting… page will reload in ' + RESTART_RELOAD_DELAY_S + 's';
958-
setTimeout(() => window.location.reload(), RESTART_RELOAD_DELAY_S * 1000);
1033+
// Other tabs need to run the same cycle so they reload to the fresh
1034+
// addon, not stay on a stale view. Broadcast BEFORE we sleep.
1035+
if (restartChannel) {
1036+
restartChannel.postMessage({type: 'restart-initiated'});
9591037
}
1038+
await _runRestartReloadCycle();
1039+
}
1040+
1041+
// Listener: when ANY tab broadcasts a save that needs a restart, all
1042+
// open tabs surface the banner. When ANY tab fires the restart, all
1043+
// open tabs run their own poll-then-reload cycle so none of them are
1044+
// left holding a stale connection to a now-dead addon.
1045+
if (restartChannel) {
1046+
restartChannel.addEventListener('message', (e) => {
1047+
const data = e.data || {};
1048+
if (data.type === 'restart-required') {
1049+
document.getElementById('restartNotice').classList.add('show');
1050+
} else if (data.type === 'restart-initiated' && !restartInProgress) {
1051+
restartInProgress = true;
1052+
const btn = document.getElementById('restartBtn');
1053+
if (btn) btn.disabled = true;
1054+
_runRestartReloadCycle();
1055+
}
1056+
});
9601057
}
9611058
9621059
const DEFAULT_PINNED = """
@@ -1167,6 +1264,10 @@ def apply_tool_visibility(
11671264
if (resp.ok) {
11681265
updateStatus('Saved — restart required', true);
11691266
document.getElementById('restartNotice').classList.add('show');
1267+
// Cross-tab sync — other open settings tabs surface the same
1268+
// banner so the user can click Restart from whichever tab they
1269+
// are on.
1270+
if (restartChannel) restartChannel.postMessage({type: 'restart-required'});
11701271
} else {
11711272
updateStatus('Save failed!');
11721273
}
@@ -1317,12 +1418,18 @@ def apply_tool_visibility(
13171418
if (data.restart_required) {
13181419
// Unified restart flow — save persists but does NOT auto-restart.
13191420
// Surface the cross-tab restart-required banner; user picks the
1320-
// moment via the global Restart Add-on button. Refresh the form
1321-
// so origins update (default → addon/file etc.) but skip the
1322-
// backup-list reload until after the actual restart.
1421+
// moment via the global Restart Add-on button.
1422+
//
1423+
// Don't reload the form here. In addon mode the GET reads
1424+
// env-derived ``get_global_settings()`` values which are still
1425+
// stale (Supervisor has the new options but ``start.py``
1426+
// doesn't re-derive env vars until the next addon boot). Reloading
1427+
// would snap the form back to old values, look like the save
1428+
// reverted, and clobber any further edits the user wanted to
1429+
// bundle before clicking Restart.
13231430
statusEl.textContent = 'Saved — restart required';
13241431
document.getElementById('restartNotice').classList.add('show');
1325-
loadBackupConfig();
1432+
if (restartChannel) restartChannel.postMessage({type: 'restart-required'});
13261433
} else {
13271434
statusEl.textContent = 'Saved.';
13281435
// Refresh display so origins update (default → file, etc.).
@@ -1632,12 +1739,17 @@ def apply_tool_visibility(
16321739
return;
16331740
}
16341741
let data = null;
1635-
try { data = await resp.json(); } catch (_e) {}
1742+
try { data = await resp.json(); } catch (_e) {
1743+
// On a 200 OK with truncated / non-JSON body, default to the
1744+
// "restart needed" state so the user gets the banner — silently
1745+
// skipping it would let them think the change took effect live
1746+
// and they'd never restart. Only do this on resp.ok; for an
1747+
// error response we want the HTTP status to drive the message.
1748+
if (resp.ok) data = {restart_required: true};
1749+
}
16361750
if (!resp.ok) {
16371751
let msg = `Save failed (HTTP ${resp.status})`;
1638-
if (data && data.error && data.error.message) {
1639-
msg = 'Save failed: ' + data.error.message;
1640-
}
1752+
if (data?.error?.message) msg = 'Save failed: ' + data.error.message;
16411753
updateStatus(msg);
16421754
return;
16431755
}
@@ -1648,8 +1760,9 @@ def apply_tool_visibility(
16481760
// button is hidden (no supervisor to drive it) but the banner still
16491761
// surfaces "restart required" as guidance.
16501762
updateStatus('Saved — restart required', true);
1651-
if (data && data.restart_required) {
1763+
if (data?.restart_required) {
16521764
document.getElementById('restartNotice').classList.add('show');
1765+
if (restartChannel) restartChannel.postMessage({type: 'restart-required'});
16531766
}
16541767
}
16551768
@@ -1888,6 +2001,17 @@ async def _do_restart() -> None:
18882001
except (httpx.ReadError, httpx.RemoteProtocolError):
18892002
# Supervisor killed us mid-call — expected; no action needed.
18902003
pass
2004+
except RuntimeError:
2005+
# ``make_supervisor_httpx_client`` raises RuntimeError when
2006+
# SUPERVISOR_TOKEN is unset. The route guard at handler entry
2007+
# already checks for this, but a race that unsets the token
2008+
# between request entry and the 300ms-later task wakeup
2009+
# would otherwise propagate uncaught and surface only as
2010+
# asyncio's "Task exception was never retrieved" at GC time.
2011+
# Log it loudly so the user can find it in the addon log.
2012+
# Mirrors the same RuntimeError catch in the supervisor
2013+
# options helpers.
2014+
logger.exception("Background self-restart aborted: SUPERVISOR_TOKEN unset")
18912015
except httpx.HTTPError:
18922016
logger.exception("Background self-restart failed")
18932017

@@ -2025,11 +2149,18 @@ async def _save_tools(request: Request) -> JSONResponse:
20252149
pinned_count,
20262150
)
20272151

2152+
# Same response shape as ``_save_feature_flags`` and
2153+
# ``_save_backup_config``: every save endpoint returns
2154+
# ``{success, applied, mode, restart_required}`` so the JS can
2155+
# branch on a single field and BroadcastChannel listeners in
2156+
# other tabs can react uniformly. Tool config writes only ever
2157+
# land in the on-disk JSON (no Supervisor round-trip), hence
2158+
# ``mode="file"`` regardless of addon/standalone deployment.
20282159
return JSONResponse(
20292160
{
20302161
"success": True,
2031-
"disabled": disabled_count,
2032-
"pinned": pinned_count,
2162+
"applied": states,
2163+
"mode": "file",
20332164
"restart_required": True,
20342165
}
20352166
)
@@ -2201,17 +2332,22 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
22012332
Routing by per-field origin (see
22022333
:func:`config.get_feature_flag_origin`):
22032334
2204-
- **addon**: POST the merged options to Supervisor and schedule
2205-
an addon restart so ``start.py`` re-derives env vars from
2206-
``config.yaml`` on the next boot. Web UI edits and
2207-
Configuration-tab edits land in the same place, so the two
2208-
surfaces stay in sync.
2335+
- **addon**: POST the merged options to Supervisor and return
2336+
``restart_required=True``. ``start.py`` will re-derive env
2337+
vars from ``config.yaml`` on the next addon boot — but the
2338+
actual restart is fired by the user clicking the global
2339+
Restart Add-on button, NOT by this handler. Web UI edits
2340+
and Configuration-tab edits land in the same place, so the
2341+
two surfaces stay in sync after the restart.
22092342
- **env**: refuse — env var explicitly set wins. Returns
22102343
``VALIDATION_INVALID_PARAMETER`` with the env var name so
22112344
the UI can surface the locking source.
22122345
- **file** / **default**: merge into the override file in the
22132346
data dir; takes effect on the next
2214-
``get_global_settings()`` call (cache reset).
2347+
``get_global_settings()`` call (cache reset). The response
2348+
still carries ``restart_required=True`` because most
2349+
flag descriptions advertise "Requires restart to take
2350+
effect" — the UI shows the banner regardless of mode.
22152351
"""
22162352
from .config import (
22172353
_FEATURE_FLAG_INT_BOUNDS,
@@ -2407,9 +2543,10 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
24072543
# restart (Tools, Server Settings, Backups) returns
24082544
# ``restart_required=True`` and lets the user pick when to
24092545
# fire the actual restart via the global Restart Add-on
2410-
# button. Don't auto-restart here — racing the response
2411-
# against the supervisor kill caused the "Restart failed"
2412-
# alert reported by the user.
2546+
# button. Don't auto-restart from the save handler —
2547+
# supervisor would kill the addon before this JSON response
2548+
# could flush through HA ingress, surfacing as a spurious
2549+
# "Restart failed" alert at the browser.
24132550
return JSONResponse(
24142551
{
24152552
"success": True,
@@ -2501,17 +2638,26 @@ async def _save_feature_flags(request: Request) -> JSONResponse:
25012638
)
25022639

25032640
# Publish the change so the same process picks it up on the
2504-
# next ``get_global_settings()`` call (server-restart still
2505-
# required for many flags — the UI surfaces that — but the
2506-
# cached singleton must not return the stale pre-write
2507-
# values to subsequent /api/settings/features GETs).
2508-
# ``restart_required=True`` because every flag in
2509-
# FEATURE_META carries "Requires restart to take effect" in its
2510-
# help text — file-mode saves persist the value but the live
2511-
# process keeps the old reads until the MCP host is restarted
2641+
# next ``get_global_settings()`` call. The cached singleton
2642+
# must not return the stale pre-write values to subsequent
2643+
# /api/settings/features GETs.
2644+
#
2645+
# ``restart_required=True`` because the feature flags here gate
2646+
# tool registration, FastMCP transforms, and other startup-time
2647+
# reads. File-mode persists the value, but the live process
2648+
# keeps the old behavior until the MCP host is restarted
25122649
# (Claude Desktop relaunch, Docker container restart, etc.).
2650+
# Surfacing the banner is the same contract Tools, Server
2651+
# Settings, and Backups all advertise.
25132652
_reset_global_settings()
2514-
return JSONResponse({"success": True, "mode": "file", "restart_required": True})
2653+
return JSONResponse(
2654+
{
2655+
"success": True,
2656+
"applied": new_overrides,
2657+
"mode": "file",
2658+
"restart_required": True,
2659+
}
2660+
)
25152661

25162662
# ---- Auto-backup routes (#1288) ----
25172663

@@ -2779,16 +2925,21 @@ async def _save_backup_config(request: Request) -> JSONResponse:
27792925
"""Persist auto-backup config edits and publish to the live process.
27802926
27812927
Routing:
2782-
- Addon mode: POST ``/addons/self/options`` to update ``config.yaml``,
2783-
then ``/addons/self/restart`` to make the new values take effect via
2784-
``start.py``'s env-var write at next boot. The HTTP response races
2785-
the restart-induced socket drop; the JS treats both 200 and
2786-
connection-drop as success and reloads the page after ~30s.
2787-
- Standalone (file) mode: refuse any field that's pinned by an env
2788-
var (process or ``.env``) — return 409 with the offending names so
2789-
the UI can refresh and show the read-only banner. Editable fields
2790-
merge into ``<data_dir>/backup_settings.json`` and a Settings
2791-
cache reset publishes them immediately (no restart).
2928+
- Addon mode: POST ``/addons/self/options`` (with the existing
2929+
options merged so required schema keys like ``backup_hint``
2930+
survive the full-replacement validation) and return
2931+
``restart_required=True``. ``start.py`` will re-derive env
2932+
vars from ``config.yaml`` on the next addon boot, but the
2933+
actual restart is fired by the user clicking the global
2934+
Restart Add-on button — NOT by this handler. Same unified
2935+
flow as the Tools and Server Settings save endpoints.
2936+
- Standalone (file) mode: refuse any field that's pinned by an
2937+
env var (process or ``.env``) — return 409 with the offending
2938+
names so the UI can refresh and show the read-only banner.
2939+
Editable fields merge into
2940+
``<data_dir>/backup_settings.json`` and a Settings cache
2941+
reset publishes them immediately, hence
2942+
``restart_required=False``.
27922943
"""
27932944
try:
27942945
payload = await request.json()

0 commit comments

Comments
 (0)