fix(web): guard chat window fetch races on session switch

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:21 +00:00
committed by weichen
co-authored by Cursor
parent 7c830146f5
commit 62ab734021
3 changed files with 100 additions and 4 deletions
+3 -2
View File
@@ -100,14 +100,15 @@ export function mapRawBucket(b: RawChatDayBucket): ChatDayBucket {
export async function fetchChatWindow(
sessionId: string,
days: number = 7,
beforeDay?: string
beforeDay?: string,
signal?: AbortSignal,
): Promise<ChatWindowResult> {
let url = `/api/v1/chat/messages/window?session_id=${encodeURIComponent(sessionId)}&days=${days}`;
if (beforeDay) {
url += `&before_day=${encodeURIComponent(beforeDay)}`;
}
const res = await fetch(url);
const res = await fetch(url, { signal });
if (!res.ok) {
throw new Error(`fetchChatWindow failed: HTTP ${res.status}`);
}
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
/** 模拟 A→B→A 窗口拉取竞态:B 晚到不得覆盖 A */
test('window fetch race: stale response ignored by generation', async () => {
type Resolver = (value: { sessionId: string; days: { dayKey: string; messages: { id: string }[] }[] }) => void;
let generation = 0;
const pending = new Map<number, Resolver>();
const fetchForSession = (sessionId: string, gen: number) =>
new Promise<{ sessionId: string; days: { dayKey: string; messages: { id: string }[] }[] }>((resolve) => {
pending.set(gen, resolve);
}).then((res) => {
if (gen !== generation) return null;
if (res.sessionId !== sessionId) return null;
return res;
});
let dayStack: { dayKey: string; messages: { id: string }[] }[] = [];
const applyIfCurrent = (
gen: number,
sessionId: string,
res: { sessionId: string; days: typeof dayStack } | null,
) => {
if (!res || gen !== generation) return;
if (res.sessionId !== sessionId) return;
dayStack = res.days;
};
// A1 发出
generation = 1;
const a1Gen = 1;
const a1Promise = fetchForSession('session-A', a1Gen);
// 切 B
generation = 2;
const bGen = 2;
const bPromise = fetchForSession('session-B', bGen);
// 再切回 A
generation = 3;
const a2Gen = 3;
const a2Promise = fetchForSession('session-A', a2Gen);
// A2 先返回
pending.get(a2Gen)!({
sessionId: 'session-A',
days: [{ dayKey: '2026-09-11', messages: [{ id: 'msg-a2' }] }],
});
applyIfCurrent(a2Gen, 'session-A', await a2Promise);
assert.deepEqual(dayStack[0].messages[0].id, 'msg-a2');
// B 晚到 — 应被 generation 丢弃
pending.get(bGen)!({
sessionId: 'session-B',
days: [{ dayKey: '2026-09-11', messages: [{ id: 'msg-b-stale' }] }],
});
applyIfCurrent(bGen, 'session-B', await bPromise);
assert.deepEqual(dayStack[0].messages[0].id, 'msg-a2');
// A1 更晚到 — 同样丢弃
pending.get(a1Gen)!({
sessionId: 'session-A',
days: [{ dayKey: '2026-09-11', messages: [{ id: 'msg-a1-stale' }] }],
});
applyIfCurrent(a1Gen, 'session-A', await a1Promise);
assert.deepEqual(dayStack[0].messages[0].id, 'msg-a2');
});
+27 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { ChatMessage, ChatDayBucket, MessageCategory } from '../types/workbench';
import {
fetchChatWindow,
@@ -75,6 +75,20 @@ export function useChatWindow({
const [selectedCategory, setSelectedCategory] = useState<MessageCategory | 'all'>('all');
const [draft, setDraftState] = useState<string>(() => loadSessionDraft(sessionId));
const generationRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
// session 切换时立即恢复该 session 的窗口缓存骨架,避免展示上一 session 内容
useEffect(() => {
abortRef.current?.abort();
generationRef.current += 1;
const cached = loadSessionWindowCache(sessionId);
setDayStack(cached && cached.length > 0 ? cached : []);
setHasMoreBefore(true);
setOldestLoadedDay('');
setPhase(cached && cached.length > 0 ? 'ready' : 'loading_window');
setDraftState(loadSessionDraft(sessionId));
}, [sessionId]);
const setDraft = useCallback(
(newDraft: string) => {
@@ -88,10 +102,17 @@ export function useChatWindow({
// 初次拉取窗口
const loadInitialWindow = useCallback(async () => {
if (!sessionId) return;
const generation = ++generationRef.current;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setPhase('loading_window');
try {
const res = await fetchChatWindow(sessionId, days);
const res = await fetchChatWindow(sessionId, days, undefined, controller.signal);
if (controller.signal.aborted || generation !== generationRef.current) return;
if (res.sessionId !== sessionId) return;
if (res.days.length > 0) {
setDayStack(res.days);
setHasMoreBefore(res.hasMoreBefore);
@@ -118,6 +139,7 @@ export function useChatWindow({
}
setPhase('ready');
} catch (err) {
if (controller.signal.aborted || generation !== generationRef.current) return;
console.warn('[useChatWindow] fetch window failed, falling back to local cache/fallback', err);
// 若已有本地快照则维持
const cached = loadSessionWindowCache(sessionId);
@@ -142,6 +164,9 @@ export function useChatWindow({
useEffect(() => {
loadInitialWindow();
return () => {
abortRef.current?.abort();
};
}, [loadInitialWindow]);
// 触顶加载更早日 (P3: 滚动位置由虚拟化器 scrollToIndex 恢复,无需 DOM 补偿)