Prevent terminal auth spinner deadlock
This commit is contained in:
@@ -87,6 +87,10 @@ const TrainingDashboard = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
|
const ONLINE_USERS_REFRESH_MS = 5 * 60_000;
|
||||||
|
const AUTH_PROFILE_REQUEST_TIMEOUT_MS = 4500;
|
||||||
|
const AUTH_DECISION_RECOVERY_MS = 10_000;
|
||||||
|
const ACTIVE_ACCESS_CACHE_KEY = "polyweather_terminal_active_access_v1";
|
||||||
|
const ACTIVE_ACCESS_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function createEmptyAccess(loading = true): ProAccessState {
|
function createEmptyAccess(loading = true): ProAccessState {
|
||||||
return {
|
return {
|
||||||
@@ -118,6 +122,67 @@ function createLocalAccess(): ProAccessState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFutureAccessExpiry(value: string | null | undefined, now = Date.now()) {
|
||||||
|
if (!value) return true;
|
||||||
|
const ts = Date.parse(value);
|
||||||
|
return Number.isFinite(ts) && ts > now;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCachedActiveAccess(now = Date.now()): ProAccessState | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(ACTIVE_ACCESS_CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const cached = JSON.parse(raw);
|
||||||
|
const access = cached?.access as Partial<ProAccessState> | undefined;
|
||||||
|
const ts = Number(cached?.ts || 0);
|
||||||
|
if (!access?.authenticated || !access.subscriptionActive) return null;
|
||||||
|
if (!ts || now - ts < 0 || now - ts > ACTIVE_ACCESS_CACHE_TTL_MS) return null;
|
||||||
|
if (!isFutureAccessExpiry(access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt, now)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
authenticated: true,
|
||||||
|
userId: access.userId ?? null,
|
||||||
|
subscriptionActive: true,
|
||||||
|
subscriptionPlanCode: access.subscriptionPlanCode ?? null,
|
||||||
|
subscriptionExpiresAt: access.subscriptionExpiresAt ?? null,
|
||||||
|
subscriptionTotalExpiresAt:
|
||||||
|
access.subscriptionTotalExpiresAt ?? access.subscriptionExpiresAt ?? null,
|
||||||
|
subscriptionQueuedDays: Number(access.subscriptionQueuedDays ?? 0),
|
||||||
|
points: Number(access.points ?? 0),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCachedActiveAccess(access: ProAccessState) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!access.authenticated || !access.subscriptionActive) return;
|
||||||
|
if (!isFutureAccessExpiry(access.subscriptionTotalExpiresAt || access.subscriptionExpiresAt)) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
ACTIVE_ACCESS_CACHE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
ts: Date.now(),
|
||||||
|
access: {
|
||||||
|
authenticated: access.authenticated,
|
||||||
|
userId: access.userId,
|
||||||
|
subscriptionActive: access.subscriptionActive,
|
||||||
|
subscriptionPlanCode: access.subscriptionPlanCode,
|
||||||
|
subscriptionExpiresAt: access.subscriptionExpiresAt,
|
||||||
|
subscriptionTotalExpiresAt: access.subscriptionTotalExpiresAt,
|
||||||
|
subscriptionQueuedDays: access.subscriptionQueuedDays,
|
||||||
|
points: access.points,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function createTransientAccess(error: unknown): ProAccessState {
|
function createTransientAccess(error: unknown): ProAccessState {
|
||||||
return {
|
return {
|
||||||
...createEmptyAccess(true),
|
...createEmptyAccess(true),
|
||||||
@@ -971,6 +1036,73 @@ function PolyWeatherTerminal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AuthSyncRecoveryScreen({
|
||||||
|
isEn,
|
||||||
|
onRetry,
|
||||||
|
rootClassName,
|
||||||
|
retrying,
|
||||||
|
themeMode,
|
||||||
|
userLocalTime,
|
||||||
|
}: {
|
||||||
|
isEn: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
rootClassName: string;
|
||||||
|
retrying: boolean;
|
||||||
|
themeMode: "dark" | "light";
|
||||||
|
userLocalTime: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<div className={clsx("flex h-screen w-full bg-[#e9edf3] text-slate-950", themeMode === "light" && "light")}>
|
||||||
|
<aside className="w-[52px] bg-[#171d24]" />
|
||||||
|
<main className="flex flex-1 flex-col">
|
||||||
|
<header className="flex h-[52px] items-center justify-between border-b border-slate-200 bg-white px-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2 hover:opacity-90">
|
||||||
|
<img src="/logo.png" alt="PolyWeather" className="h-7 w-auto object-contain" />
|
||||||
|
<span className="text-sm font-semibold tracking-tight text-slate-900">Terminal</span>
|
||||||
|
</Link>
|
||||||
|
<div className="font-mono text-sm text-slate-500">{userLocalTime}</div>
|
||||||
|
</header>
|
||||||
|
<section className="grid flex-1 place-items-center p-6">
|
||||||
|
<div className="w-full max-w-md rounded-[6px] border border-amber-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-3 inline-flex rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] font-black text-amber-700">
|
||||||
|
{isEn ? "Access sync timeout" : "权限同步超时"}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-black text-slate-950">
|
||||||
|
{isEn ? "We could not confirm your subscription yet" : "暂时未能确认你的订阅状态"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-600">
|
||||||
|
{isEn
|
||||||
|
? "The session or entitlement service is taking too long. Retry access sync; if you just paid, wait a few seconds and try again."
|
||||||
|
: "当前会话或会员服务响应过慢。请先重新同步权限;如果刚完成支付,等待几秒后再试。"}
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={retrying}
|
||||||
|
className="inline-flex flex-1 items-center justify-center gap-2 rounded-[4px] bg-blue-600 px-4 py-2.5 text-sm font-black text-white transition hover:bg-blue-700 disabled:cursor-wait disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{retrying && (
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" />
|
||||||
|
)}
|
||||||
|
{isEn ? "Retry access sync" : "重新同步权限"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/account"
|
||||||
|
className="inline-flex flex-1 items-center justify-center rounded-[4px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-black text-slate-700 transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{isEn ? "Open account" : "打开账户页"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ScanTerminalScreen() {
|
function ScanTerminalScreen() {
|
||||||
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
|
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
|
||||||
createEmptyAccess(true),
|
createEmptyAccess(true),
|
||||||
@@ -984,17 +1116,26 @@ function ScanTerminalScreen() {
|
|||||||
const headers: Record<string, string> = { Accept: "application/json" };
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
const token = String(accessToken || "").trim();
|
const token = String(accessToken || "").trim();
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
const response = await fetch(
|
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
|
||||||
options?.preferSnapshot
|
const timeoutId = controller
|
||||||
? "/api/auth/me?prefer_snapshot=1"
|
? globalThis.setTimeout(() => controller.abort(), AUTH_PROFILE_REQUEST_TIMEOUT_MS)
|
||||||
: "/api/auth/me",
|
: null;
|
||||||
{
|
try {
|
||||||
cache: "no-store",
|
const response = await fetch(
|
||||||
headers,
|
options?.preferSnapshot
|
||||||
},
|
? "/api/auth/me?prefer_snapshot=1"
|
||||||
);
|
: "/api/auth/me",
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
{
|
||||||
return response.json() as Promise<AuthProfilePayload>;
|
cache: "no-store",
|
||||||
|
headers,
|
||||||
|
signal: controller?.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.json() as Promise<AuthProfilePayload>;
|
||||||
|
} finally {
|
||||||
|
if (timeoutId !== null) globalThis.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -1031,6 +1172,7 @@ function ScanTerminalScreen() {
|
|||||||
hasSupabasePublicEnv: supabaseEnabled,
|
hasSupabasePublicEnv: supabaseEnabled,
|
||||||
loadAuthProfile: (accessToken) =>
|
loadAuthProfile: (accessToken) =>
|
||||||
loadAuthProfile(accessToken, { preferSnapshot: false }),
|
loadAuthProfile(accessToken, { preferSnapshot: false }),
|
||||||
|
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
|
||||||
});
|
});
|
||||||
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
|
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
|
||||||
}, [loadAuthProfile]);
|
}, [loadAuthProfile]);
|
||||||
@@ -1093,13 +1235,17 @@ function ScanTerminalScreen() {
|
|||||||
setLocale((prev) => (prev === "zh-CN" ? "en-US" : "zh-CN"));
|
setLocale((prev) => (prev === "zh-CN" ? "en-US" : "zh-CN"));
|
||||||
const [hydrated, setHydrated] = useState(false);
|
const [hydrated, setHydrated] = useState(false);
|
||||||
const [localFullAccess, setLocalFullAccess] = useState(false);
|
const [localFullAccess, setLocalFullAccess] = useState(false);
|
||||||
|
const [authWaitExpired, setAuthWaitExpired] = useState(false);
|
||||||
|
const [authRetrying, setAuthRetrying] = useState(false);
|
||||||
const canUseLocalFullAccess = hydrated && localFullAccess;
|
const canUseLocalFullAccess = hydrated && localFullAccess;
|
||||||
const isAuthenticated =
|
const isAuthenticated =
|
||||||
hydrated && (proAccess.authenticated || canUseLocalFullAccess);
|
hydrated && (proAccess.authenticated || canUseLocalFullAccess);
|
||||||
const isPro =
|
const isPro =
|
||||||
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
|
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
|
||||||
const accessDecisionPending =
|
const accessDecisionPending =
|
||||||
!hydrated || (proAccess.loading && !canUseLocalFullAccess);
|
!hydrated || (proAccess.loading && !canUseLocalFullAccess && !authWaitExpired);
|
||||||
|
const authSyncRecoveryNeeded =
|
||||||
|
hydrated && proAccess.loading && !canUseLocalFullAccess && authWaitExpired;
|
||||||
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
|
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
|
||||||
const userLocalTime = useUserLocalClock();
|
const userLocalTime = useUserLocalClock();
|
||||||
const { themeMode } = useScanTerminalTheme();
|
const { themeMode } = useScanTerminalTheme();
|
||||||
@@ -1139,6 +1285,10 @@ function ScanTerminalScreen() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const cachedActiveAccess = readCachedActiveAccess();
|
||||||
|
if (cachedActiveAccess) {
|
||||||
|
setProAccess(cachedActiveAccess);
|
||||||
|
}
|
||||||
if (typeof fetch !== "function") {
|
if (typeof fetch !== "function") {
|
||||||
setProAccess(createEmptyAccess(false));
|
setProAccess(createEmptyAccess(false));
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1153,6 +1303,7 @@ function ScanTerminalScreen() {
|
|||||||
: Promise.resolve({ data: { session: null } }),
|
: Promise.resolve({ data: { session: null } }),
|
||||||
hasSupabasePublicEnv: supabaseEnabled,
|
hasSupabasePublicEnv: supabaseEnabled,
|
||||||
loadAuthProfile,
|
loadAuthProfile,
|
||||||
|
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
|
||||||
})
|
})
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1176,6 +1327,23 @@ function ScanTerminalScreen() {
|
|||||||
};
|
};
|
||||||
}, [loadAuthProfile, refreshLiveAuthProfile]);
|
}, [loadAuthProfile, refreshLiveAuthProfile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydrated || !proAccess.loading || canUseLocalFullAccess) {
|
||||||
|
setAuthWaitExpired(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setAuthWaitExpired(true);
|
||||||
|
}, AUTH_DECISION_RECOVERY_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [canUseLocalFullAccess, hydrated, proAccess.loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hydrated && proAccess.authenticated && proAccess.subscriptionActive) {
|
||||||
|
writeCachedActiveAccess(proAccess);
|
||||||
|
}
|
||||||
|
}, [hydrated, proAccess]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!hydrated ||
|
!hydrated ||
|
||||||
@@ -1199,6 +1367,7 @@ function ScanTerminalScreen() {
|
|||||||
: Promise.resolve({ data: { session: null } }),
|
: Promise.resolve({ data: { session: null } }),
|
||||||
hasSupabasePublicEnv: supabaseEnabled,
|
hasSupabasePublicEnv: supabaseEnabled,
|
||||||
loadAuthProfile,
|
loadAuthProfile,
|
||||||
|
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
|
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
|
||||||
@@ -1264,6 +1433,17 @@ function ScanTerminalScreen() {
|
|||||||
clearCityDetailCache();
|
clearCityDetailCache();
|
||||||
refreshScanTerminalManually();
|
refreshScanTerminalManually();
|
||||||
}, [refreshScanTerminalManually]);
|
}, [refreshScanTerminalManually]);
|
||||||
|
const handleRetryAuthSync = useCallback(() => {
|
||||||
|
setAuthRetrying(true);
|
||||||
|
setAuthWaitExpired(false);
|
||||||
|
void refreshLiveAuthProfile()
|
||||||
|
.catch((error) => {
|
||||||
|
setProAccess((prev) => ({ ...prev, error: String(error) }));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setAuthRetrying(false);
|
||||||
|
});
|
||||||
|
}, [refreshLiveAuthProfile]);
|
||||||
|
|
||||||
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
|
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
|
||||||
cityListItemsToScanRows(readCachedCityList() || STATIC_CITY_LIST),
|
cityListItemsToScanRows(readCachedCityList() || STATIC_CITY_LIST),
|
||||||
@@ -1356,6 +1536,19 @@ function ScanTerminalScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authSyncRecoveryNeeded) {
|
||||||
|
return (
|
||||||
|
<AuthSyncRecoveryScreen
|
||||||
|
isEn={isEn}
|
||||||
|
onRetry={handleRetryAuthSync}
|
||||||
|
rootClassName={scanRootClass}
|
||||||
|
retrying={authRetrying}
|
||||||
|
themeMode={themeMode}
|
||||||
|
userLocalTime={userLocalTime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldShowPaywall) {
|
if (shouldShowPaywall) {
|
||||||
return (
|
return (
|
||||||
<ProductAccessRequired
|
<ProductAccessRequired
|
||||||
|
|||||||
@@ -124,6 +124,45 @@ export async function runTests() {
|
|||||||
"terminal auth bootstrap should accept an authenticated cookie profile immediately",
|
"terminal auth bootstrap should accept an authenticated cookie profile immediately",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const slowDegradedSession = deferred<{ data: { session: { access_token: string } | null } }>();
|
||||||
|
const timedCookieResult = await loadTerminalAuthProfile({
|
||||||
|
hasSupabasePublicEnv: true,
|
||||||
|
getSession: () => slowDegradedSession.promise,
|
||||||
|
timeoutMs: 1,
|
||||||
|
loadAuthProfile: (accessToken) => {
|
||||||
|
assert(!accessToken, "timeout fallback should use the settled cookie profile when bearer session is still pending");
|
||||||
|
return Promise.resolve({
|
||||||
|
authenticated: true,
|
||||||
|
user_id: "cookie-user",
|
||||||
|
subscription_active: null,
|
||||||
|
degraded_auth_profile: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
timedCookieResult.user_id === "cookie-user" &&
|
||||||
|
timedCookieResult.subscription_active === null,
|
||||||
|
"terminal auth bootstrap timeout must resolve with a known degraded cookie profile instead of spinning forever",
|
||||||
|
);
|
||||||
|
|
||||||
|
const neverCookie = deferred<TerminalAuthProfilePayload>();
|
||||||
|
const neverSession = deferred<{ data: { session: { access_token: string } | null } }>();
|
||||||
|
let timeoutRejected = false;
|
||||||
|
try {
|
||||||
|
await loadTerminalAuthProfile({
|
||||||
|
hasSupabasePublicEnv: true,
|
||||||
|
getSession: () => neverSession.promise,
|
||||||
|
timeoutMs: 1,
|
||||||
|
loadAuthProfile: () => neverCookie.promise,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
timeoutRejected = String(error).includes("Terminal auth bootstrap timeout");
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
timeoutRejected,
|
||||||
|
"terminal auth bootstrap must reject instead of leaving the loading screen unresolved when no profile request settles",
|
||||||
|
);
|
||||||
|
|
||||||
const delayedBearerSession = deferred<{ data: { session: { access_token: string } } }>();
|
const delayedBearerSession = deferred<{ data: { session: { access_token: string } } }>();
|
||||||
let coldStartSettled = false;
|
let coldStartSettled = false;
|
||||||
const coldStartResultPromise = loadTerminalAuthProfile({
|
const coldStartResultPromise = loadTerminalAuthProfile({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type LoadTerminalAuthProfileOptions = {
|
|||||||
accessToken?: string | null,
|
accessToken?: string | null,
|
||||||
options?: { preferSnapshot?: boolean },
|
options?: { preferSnapshot?: boolean },
|
||||||
) => Promise<TerminalAuthProfilePayload>;
|
) => Promise<TerminalAuthProfilePayload>;
|
||||||
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettledProfile =
|
type SettledProfile =
|
||||||
@@ -87,11 +88,14 @@ export async function loadTerminalAuthProfile({
|
|||||||
getSession,
|
getSession,
|
||||||
hasSupabasePublicEnv,
|
hasSupabasePublicEnv,
|
||||||
loadAuthProfile,
|
loadAuthProfile,
|
||||||
|
timeoutMs = 6500,
|
||||||
}: LoadTerminalAuthProfileOptions) {
|
}: LoadTerminalAuthProfileOptions) {
|
||||||
let resolvedAuthenticated = false;
|
let resolvedAuthenticated = false;
|
||||||
let resolveAuthenticated:
|
let resolveAuthenticated:
|
||||||
| ((payload: TerminalAuthProfilePayload) => void)
|
| ((payload: TerminalAuthProfilePayload) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
|
let latestCookiePayload: TerminalAuthProfilePayload | null = null;
|
||||||
|
let latestBearerPayload: TerminalAuthProfilePayload | null = null;
|
||||||
|
|
||||||
const authenticatedProfile = new Promise<TerminalAuthProfilePayload>((resolve) => {
|
const authenticatedProfile = new Promise<TerminalAuthProfilePayload>((resolve) => {
|
||||||
resolveAuthenticated = resolve;
|
resolveAuthenticated = resolve;
|
||||||
@@ -105,6 +109,7 @@ export async function loadTerminalAuthProfile({
|
|||||||
|
|
||||||
const cookieProfile = settleProfile(
|
const cookieProfile = settleProfile(
|
||||||
loadAuthProfile(null, { preferSnapshot: true }).then((payload) => {
|
loadAuthProfile(null, { preferSnapshot: true }).then((payload) => {
|
||||||
|
latestCookiePayload = payload;
|
||||||
resolveIfAuthenticated(payload);
|
resolveIfAuthenticated(payload);
|
||||||
return payload;
|
return payload;
|
||||||
}),
|
}),
|
||||||
@@ -120,6 +125,7 @@ export async function loadTerminalAuthProfile({
|
|||||||
).trim();
|
).trim();
|
||||||
if (!accessToken) return null;
|
if (!accessToken) return null;
|
||||||
const payload = await loadAuthProfile(accessToken, { preferSnapshot: true });
|
const payload = await loadAuthProfile(accessToken, { preferSnapshot: true });
|
||||||
|
latestBearerPayload = payload;
|
||||||
resolveIfAuthenticated(payload);
|
resolveIfAuthenticated(payload);
|
||||||
return payload;
|
return payload;
|
||||||
})(),
|
})(),
|
||||||
@@ -129,5 +135,20 @@ export async function loadTerminalAuthProfile({
|
|||||||
([cookieResult, bearerResult]) => firstKnownProfile(cookieResult, bearerResult),
|
([cookieResult, bearerResult]) => firstKnownProfile(cookieResult, bearerResult),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Promise.race([authenticatedProfile, fallbackProfile]);
|
const timeoutProfile = new Promise<TerminalAuthProfilePayload>((resolve, reject) => {
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return;
|
||||||
|
globalThis.setTimeout(() => {
|
||||||
|
if (latestBearerPayload) {
|
||||||
|
resolve(latestBearerPayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (latestCookiePayload) {
|
||||||
|
resolve(latestCookiePayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error("Terminal auth bootstrap timeout"));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([authenticatedProfile, fallbackProfile, timeoutProfile]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -775,6 +775,43 @@ def test_city_detail_batch_chart_scope_returns_only_chart_fields(monkeypatch):
|
|||||||
assert "ai_analysis" not in detail
|
assert "ai_analysis" not in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_detail_payload_avoids_threadpool_queue_and_reuses_short_cache(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
build_calls = 0
|
||||||
|
|
||||||
|
async def fail_threadpool(*_args, **_kwargs):
|
||||||
|
raise AssertionError("chart payload should not wait behind the shared threadpool")
|
||||||
|
|
||||||
|
def build_chart_detail(data, resolution):
|
||||||
|
nonlocal build_calls
|
||||||
|
build_calls += 1
|
||||||
|
return {
|
||||||
|
"city": data["city"],
|
||||||
|
"resolution": resolution,
|
||||||
|
"hourly": data["hourly"],
|
||||||
|
}
|
||||||
|
|
||||||
|
city_api._CITY_CHART_DETAIL_PAYLOAD_CACHE.clear()
|
||||||
|
city_api._CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.clear()
|
||||||
|
monkeypatch.setenv("POLYWEATHER_CITY_DETAIL_PAYLOAD_CACHE_TTL_SEC", "20")
|
||||||
|
monkeypatch.setattr(city_api, "run_in_threadpool", fail_threadpool)
|
||||||
|
monkeypatch.setattr(city_api.legacy_routes, "_build_city_chart_detail_payload", build_chart_detail)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"city": "paris",
|
||||||
|
"updated_at": "2026-05-30T15:00:00Z",
|
||||||
|
"hourly": {"times": ["2026-05-30T15:00:00Z"], "temps": [20.0]},
|
||||||
|
}
|
||||||
|
|
||||||
|
first = asyncio.run(city_api._build_city_chart_detail_payload(data, "10m"))
|
||||||
|
second = asyncio.run(city_api._build_city_chart_detail_payload(data, "10m"))
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
assert first["resolution"] == "10m"
|
||||||
|
assert build_calls == 1
|
||||||
|
|
||||||
|
|
||||||
def test_city_detail_batch_endpoint_limits_backend_concurrency(monkeypatch):
|
def test_city_detail_batch_endpoint_limits_backend_concurrency(monkeypatch):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,16 @@ _CITY_FULL_REFRESH_INFLIGHT: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
|||||||
_CITY_FULL_STALE_REFRESH_TASKS: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
_CITY_FULL_STALE_REFRESH_TASKS: Dict[str, "asyncio.Task[Dict[str, Any]]"] = {}
|
||||||
_CITY_FULL_REFRESH_LOCK = asyncio.Lock()
|
_CITY_FULL_REFRESH_LOCK = asyncio.Lock()
|
||||||
CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int]
|
CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int]
|
||||||
|
CityChartDetailPayloadCacheKey = Tuple[str, str, str, int]
|
||||||
CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str, str]
|
CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str, str]
|
||||||
_CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {}
|
_CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {}
|
||||||
_CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {}
|
_CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {}
|
||||||
_CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
|
_CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
|
||||||
_CITY_DETAIL_PAYLOAD_EPOCH: Dict[str, int] = {}
|
_CITY_DETAIL_PAYLOAD_EPOCH: Dict[str, int] = {}
|
||||||
_CITY_DETAIL_PAYLOAD_LOCK = asyncio.Lock()
|
_CITY_DETAIL_PAYLOAD_LOCK = asyncio.Lock()
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE: Dict[CityChartDetailPayloadCacheKey, Dict[str, Any]] = {}
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS: Dict[CityChartDetailPayloadCacheKey, float] = {}
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_LOCK = asyncio.Lock()
|
||||||
_CITY_DETAIL_BATCH_RESPONSE_CACHE: Dict[CityDetailBatchResponseCacheKey, Dict[str, Any]] = {}
|
_CITY_DETAIL_BATCH_RESPONSE_CACHE: Dict[CityDetailBatchResponseCacheKey, Dict[str, Any]] = {}
|
||||||
_CITY_DETAIL_BATCH_RESPONSE_CACHE_TS: Dict[CityDetailBatchResponseCacheKey, float] = {}
|
_CITY_DETAIL_BATCH_RESPONSE_CACHE_TS: Dict[CityDetailBatchResponseCacheKey, float] = {}
|
||||||
_CITY_DETAIL_BATCH_RESPONSE_INFLIGHT: Dict[CityDetailBatchResponseCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
|
_CITY_DETAIL_BATCH_RESPONSE_INFLIGHT: Dict[CityDetailBatchResponseCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
|
||||||
@@ -102,6 +106,11 @@ async def _invalidate_city_detail_payload_cache(city: str) -> None:
|
|||||||
for key in old_keys:
|
for key in old_keys:
|
||||||
_CITY_DETAIL_PAYLOAD_CACHE.pop(key, None)
|
_CITY_DETAIL_PAYLOAD_CACHE.pop(key, None)
|
||||||
_CITY_DETAIL_PAYLOAD_CACHE_TS.pop(key, None)
|
_CITY_DETAIL_PAYLOAD_CACHE_TS.pop(key, None)
|
||||||
|
async with _CITY_CHART_DETAIL_PAYLOAD_LOCK:
|
||||||
|
old_chart_keys = [key for key in _CITY_CHART_DETAIL_PAYLOAD_CACHE if key[0] == normalized]
|
||||||
|
for key in old_chart_keys:
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE.pop(key, None)
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_city_full_data(city: str, force_refresh: bool) -> Dict[str, Any]:
|
async def _refresh_city_full_data(city: str, force_refresh: bool) -> Dict[str, Any]:
|
||||||
@@ -189,6 +198,27 @@ def _city_detail_payload_cache_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _city_chart_detail_payload_cache_key(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
resolution: Optional[str],
|
||||||
|
) -> CityChartDetailPayloadCacheKey:
|
||||||
|
city = str(data.get("city") or data.get("name") or "").strip().lower()
|
||||||
|
fingerprint = str(
|
||||||
|
data.get("updated_at_ts")
|
||||||
|
or data.get("updated_at")
|
||||||
|
or data.get("local_time")
|
||||||
|
or data.get("local_date")
|
||||||
|
or id(data)
|
||||||
|
)
|
||||||
|
generation = _CITY_DETAIL_PAYLOAD_EPOCH.get(city, 0)
|
||||||
|
return (
|
||||||
|
city,
|
||||||
|
str(resolution or "10m"),
|
||||||
|
fingerprint,
|
||||||
|
generation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _build_city_detail_payload_cached(
|
async def _build_city_detail_payload_cached(
|
||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
market_slug: Optional[str],
|
market_slug: Optional[str],
|
||||||
@@ -250,11 +280,33 @@ async def _build_city_chart_detail_payload(
|
|||||||
data: Dict[str, Any],
|
data: Dict[str, Any],
|
||||||
resolution: Optional[str],
|
resolution: Optional[str],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
return await run_in_threadpool(
|
ttl = _city_detail_payload_cache_ttl()
|
||||||
legacy_routes._build_city_chart_detail_payload,
|
if ttl <= 0:
|
||||||
data,
|
return legacy_routes._build_city_chart_detail_payload(data, resolution)
|
||||||
resolution,
|
|
||||||
)
|
key = _city_chart_detail_payload_cache_key(data, resolution)
|
||||||
|
now_ts = time.time()
|
||||||
|
async with _CITY_CHART_DETAIL_PAYLOAD_LOCK:
|
||||||
|
cached = _CITY_CHART_DETAIL_PAYLOAD_CACHE.get(key)
|
||||||
|
cached_ts = _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.get(key, 0.0)
|
||||||
|
if cached is not None and now_ts - cached_ts < ttl:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
payload = legacy_routes._build_city_chart_detail_payload(data, resolution)
|
||||||
|
|
||||||
|
async with _CITY_CHART_DETAIL_PAYLOAD_LOCK:
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE[key] = payload
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS[key] = time.time()
|
||||||
|
if len(_CITY_CHART_DETAIL_PAYLOAD_CACHE) > 256:
|
||||||
|
oldest_keys = sorted(
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS,
|
||||||
|
key=lambda item: _CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.get(item, 0.0),
|
||||||
|
)[:64]
|
||||||
|
for old_key in oldest_keys:
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE.pop(old_key, None)
|
||||||
|
_CITY_CHART_DETAIL_PAYLOAD_CACHE_TS.pop(old_key, None)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _default_deb_recent() -> Dict[str, object]:
|
def _default_deb_recent() -> Dict[str, object]:
|
||||||
|
|||||||
Reference in New Issue
Block a user