mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] boot: bootstrap fetches retry until the backend answers and heal on focus, so a lost boot race stops bricking first paint
This commit is contained in:
@@ -215,6 +215,25 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
useEffect(() => {
|
||||
dispatch(fetchSettings());
|
||||
dispatch(fetchModels());
|
||||
// Boot race (ENG-207): the renderer can beat the backend up by many seconds, and a hidden
|
||||
// window's timers get App-Napped, so a one-shot fetch that lost the race stayed lost until a
|
||||
// manual reload. Retry the two bootstrap reads until settings answer, and re-kick on focus /
|
||||
// visibility so a napped window heals the moment anyone looks at it.
|
||||
let bootTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const bootRetry = () => {
|
||||
const st = store.getState();
|
||||
const settingsOk = st.settings.loaded;
|
||||
const modelsOk = st.models.loaded && !st.models.failed;
|
||||
if (settingsOk && modelsOk) {
|
||||
if (bootTimer) { clearInterval(bootTimer); bootTimer = null; }
|
||||
return;
|
||||
}
|
||||
if (!settingsOk) dispatch(fetchSettings());
|
||||
if (!modelsOk) dispatch(fetchModels());
|
||||
};
|
||||
bootTimer = setInterval(bootRetry, 3000);
|
||||
window.addEventListener('focus', bootRetry);
|
||||
document.addEventListener('visibilitychange', bootRetry);
|
||||
// Report the app launch with the browser's canonical tz/locale so the backend can emit analytics app_lifecycle.opened with values that work in packaged, dev, and open-source builds. Guarded once per page load; backend dedupes per process.
|
||||
reportAppOpened();
|
||||
// Connected subscriptions live in their own slice; without this the dashboard (and the onboarding gate) think no model is connected until the user opens Settings > Models, so a fresh launch shows a false "connect a model" empty state and the welcome cursor never fires. Refetched after sync + on focus below.
|
||||
@@ -231,6 +250,11 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
|
||||
// The backend arms server-side regardless of whether the browser can read the mint response (a transient boot-time CORS/timing miss makes `data` unreadable), so refetch unconditionally, the GET is the only reliable signal the UI gets that it armed.
|
||||
.finally(() => { dispatch(fetchSettings()); dispatch(fetchSubscriptionStatus()); dispatch(markFreeTrialArmSettled()); });
|
||||
});
|
||||
return () => {
|
||||
if (bootTimer) clearInterval(bootTimer);
|
||||
window.removeEventListener('focus', bootRetry);
|
||||
document.removeEventListener('visibilitychange', bootRetry);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -68,7 +68,7 @@ const AppShell: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const modelsLoaded = useAppSelector((s) => s.models.loaded);
|
||||
const modelsLoaded = useAppSelector((s) => s.models.loaded && !s.models.failed);
|
||||
// The models list is marked loaded even when its fetch fails, so it alone can't tell "no model" from "couldn't ask". Settings is where the user's own key/sub lives, so the banner waits for it.
|
||||
const settingsKnown = useAppSelector((s) => s.settings.loaded);
|
||||
// "Connected" = the user's OWN model (key/sub/pro/custom), NOT a non-empty /models list: the free-trial Haiku is always in that list now, so a byProvider-length check would falsely read as connected and hide the out-of-runs banner.
|
||||
|
||||
@@ -33,9 +33,16 @@ const DashboardAutoEnter: React.FC = () => {
|
||||
if (!cancelled) timer = setTimeout(enter, 3000);
|
||||
};
|
||||
enter();
|
||||
// A hidden window's timers get App-Napped, so the 3s retry could stall forever while the
|
||||
// backend finished booting behind it (ENG-207). Focus/visibility re-kick heals it on sight.
|
||||
const rekick = (): void => { if (!timer) return; clearTimeout(timer); timer = null; enter(); };
|
||||
window.addEventListener('focus', rekick);
|
||||
document.addEventListener('visibilitychange', rekick);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
window.removeEventListener('focus', rekick);
|
||||
document.removeEventListener('visibilitychange', rekick);
|
||||
};
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
|
||||
@@ -48,7 +48,12 @@ export async function refreshAuthToken(): Promise<string> {
|
||||
/** Resolve auth token once; concurrent callers share the same promise. */
|
||||
export function ensureAuthToken(): Promise<string> {
|
||||
if (_authTokenPromise) return _authTokenPromise;
|
||||
_authTokenPromise = refreshAuthToken();
|
||||
_authTokenPromise = refreshAuthToken().then((tok) => {
|
||||
// A boot race can resolve EMPTY (backend hadn't written the token file yet); memoizing that
|
||||
// left the renderer auth-dead until a manual reload (ENG-207). Empty = not an answer; retry.
|
||||
if (!tok) _authTokenPromise = null;
|
||||
return tok;
|
||||
});
|
||||
return _authTokenPromise;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,13 @@ export interface ModelOption {
|
||||
interface ModelsState {
|
||||
byProvider: Record<string, ModelOption[]>;
|
||||
loaded: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
const initialState: ModelsState = {
|
||||
byProvider: {},
|
||||
loaded: false,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
export const fetchModels = createAsyncThunk('models/fetchModels', async () => {
|
||||
@@ -49,6 +51,11 @@ const modelsSlice = createSlice({
|
||||
.addCase(fetchModels.rejected, (state) => {
|
||||
// Mark loaded even on failure so callers fall back to hardcoded options.
|
||||
state.loaded = true;
|
||||
// ...but remember it FAILED: a boot-race miss used to read as "loaded with no models" and lit the red banner on a fully configured install (ENG-207).
|
||||
state.failed = true;
|
||||
})
|
||||
.addCase(fetchModels.pending, (state) => {
|
||||
state.failed = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user