Prevent terminal auth spinner deadlock

This commit is contained in:
2569718930@qq.com
2026-06-01 12:36:29 +08:00
parent fb6c354317
commit 49cb0bb42d
5 changed files with 360 additions and 18 deletions
@@ -87,6 +87,10 @@ const TrainingDashboard = dynamic(
);
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 {
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 {
return {
...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() {
const [proAccess, setProAccess] = useState<ProAccessState>(() =>
createEmptyAccess(true),
@@ -984,17 +1116,26 @@ function ScanTerminalScreen() {
const headers: Record<string, string> = { Accept: "application/json" };
const token = String(accessToken || "").trim();
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(
options?.preferSnapshot
? "/api/auth/me?prefer_snapshot=1"
: "/api/auth/me",
{
cache: "no-store",
headers,
},
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AuthProfilePayload>;
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
const timeoutId = controller
? globalThis.setTimeout(() => controller.abort(), AUTH_PROFILE_REQUEST_TIMEOUT_MS)
: null;
try {
const response = await fetch(
options?.preferSnapshot
? "/api/auth/me?prefer_snapshot=1"
: "/api/auth/me",
{
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,
loadAuthProfile: (accessToken) =>
loadAuthProfile(accessToken, { preferSnapshot: false }),
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
});
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
}, [loadAuthProfile]);
@@ -1093,13 +1235,17 @@ function ScanTerminalScreen() {
setLocale((prev) => (prev === "zh-CN" ? "en-US" : "zh-CN"));
const [hydrated, setHydrated] = useState(false);
const [localFullAccess, setLocalFullAccess] = useState(false);
const [authWaitExpired, setAuthWaitExpired] = useState(false);
const [authRetrying, setAuthRetrying] = useState(false);
const canUseLocalFullAccess = hydrated && localFullAccess;
const isAuthenticated =
hydrated && (proAccess.authenticated || canUseLocalFullAccess);
const isPro =
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
const accessDecisionPending =
!hydrated || (proAccess.loading && !canUseLocalFullAccess);
!hydrated || (proAccess.loading && !canUseLocalFullAccess && !authWaitExpired);
const authSyncRecoveryNeeded =
hydrated && proAccess.loading && !canUseLocalFullAccess && authWaitExpired;
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
const userLocalTime = useUserLocalClock();
const { themeMode } = useScanTerminalTheme();
@@ -1139,6 +1285,10 @@ function ScanTerminalScreen() {
cancelled = true;
};
}
const cachedActiveAccess = readCachedActiveAccess();
if (cachedActiveAccess) {
setProAccess(cachedActiveAccess);
}
if (typeof fetch !== "function") {
setProAccess(createEmptyAccess(false));
return () => {
@@ -1153,6 +1303,7 @@ function ScanTerminalScreen() {
: Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile,
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
})
.then((payload) => {
if (cancelled) return;
@@ -1176,6 +1327,23 @@ function ScanTerminalScreen() {
};
}, [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(() => {
if (
!hydrated ||
@@ -1199,6 +1367,7 @@ function ScanTerminalScreen() {
: Promise.resolve({ data: { session: null } }),
hasSupabasePublicEnv: supabaseEnabled,
loadAuthProfile,
timeoutMs: AUTH_PROFILE_REQUEST_TIMEOUT_MS + 2000,
});
if (cancelled) return;
setProAccess((prev) => mergeAccessStateWithAuthPayload(prev, payload));
@@ -1264,6 +1433,17 @@ function ScanTerminalScreen() {
clearCityDetailCache();
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[]>(() =>
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) {
return (
<ProductAccessRequired
@@ -124,6 +124,45 @@ export async function runTests() {
"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 } } }>();
let coldStartSettled = false;
const coldStartResultPromise = loadTerminalAuthProfile({
@@ -17,6 +17,7 @@ type LoadTerminalAuthProfileOptions = {
accessToken?: string | null,
options?: { preferSnapshot?: boolean },
) => Promise<TerminalAuthProfilePayload>;
timeoutMs?: number;
};
type SettledProfile =
@@ -87,11 +88,14 @@ export async function loadTerminalAuthProfile({
getSession,
hasSupabasePublicEnv,
loadAuthProfile,
timeoutMs = 6500,
}: LoadTerminalAuthProfileOptions) {
let resolvedAuthenticated = false;
let resolveAuthenticated:
| ((payload: TerminalAuthProfilePayload) => void)
| null = null;
let latestCookiePayload: TerminalAuthProfilePayload | null = null;
let latestBearerPayload: TerminalAuthProfilePayload | null = null;
const authenticatedProfile = new Promise<TerminalAuthProfilePayload>((resolve) => {
resolveAuthenticated = resolve;
@@ -105,6 +109,7 @@ export async function loadTerminalAuthProfile({
const cookieProfile = settleProfile(
loadAuthProfile(null, { preferSnapshot: true }).then((payload) => {
latestCookiePayload = payload;
resolveIfAuthenticated(payload);
return payload;
}),
@@ -120,6 +125,7 @@ export async function loadTerminalAuthProfile({
).trim();
if (!accessToken) return null;
const payload = await loadAuthProfile(accessToken, { preferSnapshot: true });
latestBearerPayload = payload;
resolveIfAuthenticated(payload);
return payload;
})(),
@@ -129,5 +135,20 @@ export async function loadTerminalAuthProfile({
([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]);
}
+37
View File
@@ -775,6 +775,43 @@ def test_city_detail_batch_chart_scope_returns_only_chart_fields(monkeypatch):
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):
import asyncio
+57 -5
View File
@@ -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_REFRESH_LOCK = asyncio.Lock()
CityDetailPayloadCacheKey = Tuple[str, str, str, str, str, int]
CityChartDetailPayloadCacheKey = Tuple[str, str, str, int]
CityDetailBatchResponseCacheKey = Tuple[Tuple[str, ...], bool, str, str, str, str]
_CITY_DETAIL_PAYLOAD_CACHE: Dict[CityDetailPayloadCacheKey, Dict[str, Any]] = {}
_CITY_DETAIL_PAYLOAD_CACHE_TS: Dict[CityDetailPayloadCacheKey, float] = {}
_CITY_DETAIL_PAYLOAD_INFLIGHT: Dict[CityDetailPayloadCacheKey, "asyncio.Task[Dict[str, Any]]"] = {}
_CITY_DETAIL_PAYLOAD_EPOCH: Dict[str, int] = {}
_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_TS: Dict[CityDetailBatchResponseCacheKey, float] = {}
_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:
_CITY_DETAIL_PAYLOAD_CACHE.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]:
@@ -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(
data: Dict[str, Any],
market_slug: Optional[str],
@@ -250,11 +280,33 @@ async def _build_city_chart_detail_payload(
data: Dict[str, Any],
resolution: Optional[str],
) -> Dict[str, Any]:
return await run_in_threadpool(
legacy_routes._build_city_chart_detail_payload,
data,
resolution,
)
ttl = _city_detail_payload_cache_ttl()
if ttl <= 0:
return legacy_routes._build_city_chart_detail_payload(data, 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]: