fix(mp): 修复并发请求饥饿导致开发者工具卡死

- 长轮询走独立通道(requestUnlimited),不再占用 ConcurrencyLimiter 槽位
- ConcurrencyLimiter 上限 8→12,缓解 TabBar 切换请求风暴
- 新增 safeReLaunch 去重,防止并发 401 多次触发页面跳转
- maxFailures 50→10,后端不可用时快速止损而非持续 18 分钟重试

根因:咨询页长轮询每次占用槽位 25-30s,8 个槽位被占满后
所有新请求排队等待,叠加 401 场景形成死锁。
This commit is contained in:
iven
2026-05-17 17:01:24 +08:00
parent b84becfbea
commit 9d50ef7847
4 changed files with 27 additions and 11 deletions

View File

@@ -10,7 +10,7 @@ interface LongPollOptions<T> {
enabled: boolean; enabled: boolean;
/** 成功轮询间隔 ms默认 3000 */ /** 成功轮询间隔 ms默认 3000 */
intervalMs?: number; intervalMs?: number;
/** 连续失败上限,默认 50 */ /** 连续失败上限,默认 10 */
maxFailures?: number; maxFailures?: number;
} }
@@ -27,7 +27,7 @@ export function useLongPolling<T>({
onData, onData,
enabled, enabled,
intervalMs = 3000, intervalMs = 3000,
maxFailures = 50, maxFailures = 10,
}: LongPollOptions<T>) { }: LongPollOptions<T>) {
const generation = useRef(0); const generation = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);

View File

@@ -1,4 +1,4 @@
import { api, requestWithTimeout } from './request'; import { api, requestUnlimited } from './request';
export interface ConsultationSession { export interface ConsultationSession {
id: string; id: string;
@@ -72,5 +72,5 @@ export async function pollMessages(sessionId: string, afterId?: string) {
params.set('timeout', '25'); params.set('timeout', '25');
const query = params.toString(); const query = params.toString();
const path = `/health/consultation-sessions/${sessionId}/messages/poll${query ? '?' + query : ''}`; const path = `/health/consultation-sessions/${sessionId}/messages/poll${query ? '?' + query : ''}`;
return requestWithTimeout<ConsultationMessage[]>('GET', path, undefined, 30000); return requestUnlimited<ConsultationMessage[]>('GET', path, undefined, 30000);
} }

View File

@@ -1,4 +1,4 @@
import { api, requestWithTimeout } from '../request'; import { api, requestUnlimited } from '../request';
// ── Consultation (doctor view) ───────────────────── // ── Consultation (doctor view) ─────────────────────
@@ -71,7 +71,7 @@ export async function pollMessages(sessionId: string, afterId?: string) {
params.set('timeout', '25'); params.set('timeout', '25');
const query = params.toString(); const query = params.toString();
const path = `/health/consultation-sessions/${sessionId}/messages/poll${query ? '?' + query : ''}`; const path = `/health/consultation-sessions/${sessionId}/messages/poll${query ? '?' + query : ''}`;
return requestWithTimeout<ConsultationMessage[]>('GET', path, undefined, 30000); return requestUnlimited<ConsultationMessage[]>('GET', path, undefined, 30000);
} }
export interface ConsultationStats { export interface ConsultationStats {

View File

@@ -56,7 +56,7 @@ class ConcurrencyLimiter {
} }
} }
const limiter = new ConcurrencyLimiter(8); const limiter = new ConcurrencyLimiter(12);
// --- Response cache + deduplication --- // --- Response cache + deduplication ---
@@ -221,13 +221,24 @@ async function doRefresh(): Promise<boolean> {
return false; return false;
} }
// --- reLaunch 去重 ---
let reLaunchPromise: Promise<void> | null = null;
function safeReLaunch(url: string): void {
if (reLaunchPromise) return;
reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, () => {}).then(() => {
setTimeout(() => { reLaunchPromise = null; }, 2000);
});
}
// --- Core request --- // --- Core request ---
async function request<T>(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal): Promise<T> { async function request<T>(method: string, path: string, data?: unknown, timeout?: number, signal?: AbortSignal, bypassLimiter = false): Promise<T> {
let retryCount401 = 0; let retryCount401 = 0;
for (;;) { for (;;) {
if (signal?.aborted) throw new Error('请求已取消'); if (signal?.aborted) throw new Error('请求已取消');
await limiter.acquire(); if (!bypassLimiter) await limiter.acquire();
try { try {
const headers = await getHeaders(); const headers = await getHeaders();
const url = `${BASE_URL}${path}`; const url = `${BASE_URL}${path}`;
@@ -262,7 +273,7 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
const currentPath = pages[pages.length - 1]?.path || ''; const currentPath = pages[pages.length - 1]?.path || '';
if (!currentPath.includes('pages/login')) { if (!currentPath.includes('pages/login')) {
Taro.reLaunch({ url: '/pages/login/index' }); safeReLaunch('/pages/login/index');
} }
} }
throw new Error('登录已过期'); throw new Error('登录已过期');
@@ -287,7 +298,7 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
} }
return body.data as T; return body.data as T;
} finally { } finally {
limiter.release(); if (!bypassLimiter) limiter.release();
} }
} }
} }
@@ -349,6 +360,11 @@ export async function requestWithTimeout<T>(method: string, path: string, data?:
return request<T>(method, path, data, timeout); return request<T>(method, path, data, timeout);
} }
/** 绕过并发限制的请求,用于长轮询等长时间 hang 的请求 */
export async function requestUnlimited<T>(method: string, path: string, data?: unknown, timeout?: number): Promise<T> {
return request<T>(method, path, data, timeout, undefined, true);
}
/** 重置所有模块级状态,用于测试隔离 */ /** 重置所有模块级状态,用于测试隔离 */
export function resetForTesting(): void { export function resetForTesting(): void {
limiter.reset(); limiter.reset();