fix(layers): make restart repair passive and always-on

This commit is contained in:
Shadowbroker
2026-08-20 21:09:50 -06:00
parent ae237eb2b4
commit 108e0a49e5
+12 -18
View File
@@ -94,10 +94,8 @@ export function getDefaultActiveLayers(): ActiveLayers {
const ACTIVE_LAYER_KEYS = Object.keys(getDefaultActiveLayers()) as (keyof ActiveLayers)[];
const LAYER_BACKEND_SYNC_INTERVAL_MS = 5000;
let layerBackendSyncTimer: ReturnType<typeof setInterval> | null = null;
let layerBackendSyncTimer: number | null = null;
let layerBackendSyncInFlight = false;
let layerBackendSeenSuccess = false;
let layerBackendSawFailure = false;
let repairedDefaultBackendState = false;
function isBooleanRecord(value: unknown): value is Record<string, boolean> {
@@ -199,10 +197,9 @@ function emitLayerPreferencesChanged(layers: ActiveLayers): void {
* The dashboard already saves the operator's layer choices locally. The backend,
* however, resets its fetcher gates on process restart. This reconciliation loop
* observes the existing GET /api/layers endpoint and replays the saved operator
* state only when a connection is first established, after an observed outage,
* or when the backend has unmistakably fallen back to its shipped defaults.
* It does not continuously overwrite arbitrary non-default server state, so two
* independent clients do not get into a five-second preference tug-of-war.
* state only when the backend is unmistakably back at its shipped defaults.
* It deliberately leaves other non-default server state alone, so independent
* clients do not get into a five-second preference tug-of-war.
*/
export async function reconcileActiveLayersWithBackend(): Promise<'synced' | 'noop' | 'offline'> {
if (typeof window === 'undefined' || typeof fetch !== 'function') return 'offline';
@@ -228,11 +225,7 @@ export async function reconcileActiveLayersWithBackend(): Promise<'synced' | 'no
const backend = mergeActiveLayers(defaults, rawLayers);
const mismatch = !layerMapsEqual(desired, backend);
const backendAtDefaults = layerMapsEqual(backend, defaults);
const shouldRepair =
mismatch &&
(!layerBackendSeenSuccess ||
layerBackendSawFailure ||
(backendAtDefaults && !repairedDefaultBackendState));
const shouldRepair = mismatch && backendAtDefaults && !repairedDefaultBackendState;
if (shouldRepair) {
const syncResponse = await fetch(`${API_BASE}/api/layers`, {
@@ -245,18 +238,15 @@ export async function reconcileActiveLayersWithBackend(): Promise<'synced' | 'no
}
// useDataPolling already listens for this event and refreshes gated data immediately.
window.dispatchEvent(new Event('sb:layer-toggle'));
repairedDefaultBackendState = backendAtDefaults;
layerBackendSeenSuccess = true;
layerBackendSawFailure = false;
repairedDefaultBackendState = true;
return 'synced';
}
// Once the repaired non-default state is observable, arm the watchdog for a
// future backend restart that returns the process-local gate to defaults.
if (!backendAtDefaults) repairedDefaultBackendState = false;
layerBackendSeenSuccess = true;
layerBackendSawFailure = false;
return 'noop';
} catch {
layerBackendSawFailure = true;
return 'offline';
} finally {
layerBackendSyncInFlight = false;
@@ -352,3 +342,7 @@ export function loadLayerSectionExpanded(): Record<string, boolean> {
export function saveLayerSectionExpanded(expanded: Record<string, boolean>): void {
writeDashboardPrefs({ layerSectionsExpanded: expanded });
}
// Start the passive restart watchdog as soon as the preferences module is loaded
// in a real browser. SSR and Vitest are guarded inside ensureLayerBackendSync().
ensureLayerBackendSync();