Compare commits

..

2 Commits

Author SHA1 Message Date
iven
1576709342 docs(wiki): 新增症状导航 — 开发者工具卡死(并发饥饿)修复记录 2026-05-17 17:02:08 +08:00
iven
9d50ef7847 fix(mp): 修复并发请求饥饿导致开发者工具卡死
- 长轮询走独立通道(requestUnlimited),不再占用 ConcurrencyLimiter 槽位
- ConcurrencyLimiter 上限 8→12,缓解 TabBar 切换请求风暴
- 新增 safeReLaunch 去重,防止并发 401 多次触发页面跳转
- maxFailures 50→10,后端不可用时快速止损而非持续 18 分钟重试

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

View File

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

View File

@@ -1,4 +1,4 @@
import { api, requestWithTimeout } from './request';
import { api, requestUnlimited } from './request';
export interface ConsultationSession {
id: string;
@@ -72,5 +72,5 @@ export async function pollMessages(sessionId: string, afterId?: string) {
params.set('timeout', '25');
const query = params.toString();
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) ─────────────────────
@@ -71,7 +71,7 @@ export async function pollMessages(sessionId: string, afterId?: string) {
params.set('timeout', '25');
const query = params.toString();
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 {

View File

@@ -56,7 +56,7 @@ class ConcurrencyLimiter {
}
}
const limiter = new ConcurrencyLimiter(8);
const limiter = new ConcurrencyLimiter(12);
// --- Response cache + deduplication ---
@@ -221,13 +221,24 @@ async function doRefresh(): Promise<boolean> {
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 ---
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;
for (;;) {
if (signal?.aborted) throw new Error('请求已取消');
await limiter.acquire();
if (!bypassLimiter) await limiter.acquire();
try {
const headers = await getHeaders();
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 currentPath = pages[pages.length - 1]?.path || '';
if (!currentPath.includes('pages/login')) {
Taro.reLaunch({ url: '/pages/login/index' });
safeReLaunch('/pages/login/index');
}
}
throw new Error('登录已过期');
@@ -287,7 +298,7 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
}
return body.data as T;
} 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);
}
/** 绕过并发限制的请求,用于长轮询等长时间 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 {
limiter.reset();

View File

@@ -94,6 +94,8 @@
| 小程序轮播图图片 404 | [[erp-health]] 公开端点 | Axum 路由参数 `:id``{id}` 语法变更 + URL 拼接缺失 `/api/v1` | **已修复:** 路由改用 `{banner_id}`,新增 `/public/banner-image/{id}` 图片服务端点 |
| 前端媒体库图片 401 | [[frontend]] MediaLibrary | `/uploads` 路径需要 JWT 认证 | **已修复:** 新增 `resolveMediaUrl()` 工具函数自动拼接 `?token=`Vite 代理 `/uploads` |
| 后端启动 panic "Path segments must not start with `:`" | [[erp-health]] module.rs | Axum v0.8+ 路由参数语法变更 | 路由定义使用 `{param}` 而非 `:param` |
| 小程序分包页 navigateTo:fail timeout | [[miniprogram]] 页面生命周期 | `useEffect` + `usePageData`(useDidShow) 双重初始化 | **已修复:** 合并为 `usePageData` 单次回调,移除独立 `useEffect` |
| 小程序/开发者工具卡死无响应 | [[miniprogram]] request.ts 并发控制 | 长轮询占用 ConcurrencyLimiter 槽位 25-30s叠加 401 形成死锁 | **已修复:** 长轮询走 `requestUnlimited` 独立通道 + 并发上限 8→12 + reLaunch 去重 + maxFailures 50→10 |
## 模块导航