From 6a10910fda1c2781822fac8a12b03dfb199021f4 Mon Sep 17 00:00:00 2001 From: Battleplus <3559424769@qq.com> Date: Tue, 25 Aug 2026 17:50:20 +0800 Subject: [PATCH 1/2] fix(desktop): release isLoadingMore lock on invalidated initial history backfill When ensureInitialMessages backfill is invalidated by an epoch change (reloadMessages, purge, etc.), the old generation's early return left isLoadingMore permanently true, blocking all subsequent pagination. Release the shared pagination lock in retryInvalidatedInitialHistoryFetchIfNeeded so it is freed regardless of whether a retry occurs. Also release it in the .catch() error path to prevent lock leaks on fetch failure. Regression test confirms isLoadingMore is released after epoch invalidation. Signed-off-by: Battleplus <3559424769@qq.com> --- .../makerChatStoreActiveView.test.ts | 52 +++++++++++++++++++ .../src/renderer/lib/makerChatStore.ts | 6 ++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts b/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts index 0e8a198d339..3bc54541bfc 100644 --- a/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerChatStoreActiveView.test.ts @@ -1322,4 +1322,56 @@ describe('makerChatStore active view tracking', () => { expect(makerChatStore.getSnapshot(sessionId).messages).toHaveLength(0); expect(makerChatStore.getSnapshot(sessionId).historyLoaded).toBe(false); }); + + // Regression: when ensureInitialMessages backfill is invalidated by an epoch + // change (e.g. reloadMessages), isLoadingMore must be released so that + // loadOlderMessages is not permanently blocked. + it('releases isLoadingMore when initial backfill is invalidated by epoch change', async () => { + const sessionId = sid('initial-backfill-invalidation-lock'); + const latestPage = Array.from({ length: 49 }, (_, i) => + dbMessage( + sessionId, + `latest-${String(i).padStart(2, '0')}`, + `latest message ${i}`, + new Date(BASE_TIME.getTime() + (60 + i) * 1000).toISOString(), + ), + ); + const latestPlan = dbToolUseMessage( + sessionId, + 'latest-plan', + 'TaskUpdate', + { taskId: 'abc', status: 'completed' }, + new Date(BASE_TIME.getTime() + 120_000).toISOString(), + ); + let resolveOlderPage!: (rows: Message[]) => void; + vi.mocked(messageService.list) + .mockResolvedValueOnce([...latestPage, latestPlan]) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveOlderPage = resolve; + }), + ); + + makerChatStore.ensureInitialMessages(sessionId); + await flushPromises(4); + + // Backfill is in progress, lock held. + expect(makerChatStore.getSnapshot(sessionId).isLoadingMore).toBe(true); + expect(messageService.list).toHaveBeenCalledTimes(2); + + // Epoch change invalidates the in-flight backfill. + makerChatStore.reloadMessages(sessionId); + await Promise.resolve(); + + // Resolve the pending backfill — it should detect invalidation and exit. + resolveOlderPage([ + dbMessage(sessionId, 'older-visible', 'older visible message', BASE_TIME.toISOString()), + ]); + await flushPromises(); + + // isLoadingMore must have been released despite the invalidation path. + expect(makerChatStore.getSnapshot(sessionId).isLoadingMore).toBe(false); + }); + + }); diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index e3051bc4af3..aefeb2ec532 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -9930,6 +9930,10 @@ function retryInvalidatedInitialHistoryFetchIfNeeded( origin: string | undefined, epoch: number, ): void { + // Release the shared pagination lock so that loadOlderMessages is not + // permanently blocked when this invalidated request exits without retrying. + // If a retry does happen, ensureInitialMessages will re-acquire the lock. + setState(sessionId, (s) => (s.isLoadingMore ? { ...s, isLoadingMore: false } : s)); const ownsFetch = _historyFetchToken.get(sessionId) === token; const epochChanged = (_messagesEpoch.get(sessionId) ?? 0) !== epoch; const originUnchanged = remoteProjectsStore.getSessionDeviceId(sessionId) === origin; @@ -10490,7 +10494,7 @@ function ensureInitialMessages(sessionId: string): void { // rewind 之类的粘滞抑制(见 releaseCacheHydrationAfterFailure)。屏上已 hydrate 的 // 缓存行**保持不动**:离线时它是用户唯一能看到的历史,清掉纯属倒退。 releaseCacheHydrationAfterFailure(sessionId); - setState(sessionId, (s) => ({ ...s, historyLoaded: false })); + setState(sessionId, (s) => ({ ...s, historyLoaded: false, isLoadingMore: false })); }); } From f86e6da254b86955d47ea2940a91607fcb624e22 Mon Sep 17 00:00:00 2001 From: Battleplus <3559424769@qq.com> Date: Tue, 25 Aug 2026 18:19:03 +0800 Subject: [PATCH 2/2] fix(desktop): gate isLoadingMore release on fetch token ownership Signed-off-by: Battleplus <3559424769@qq.com> --- apps/desktop/src/renderer/lib/makerChatStore.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index aefeb2ec532..725e559c4de 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -9930,11 +9930,13 @@ function retryInvalidatedInitialHistoryFetchIfNeeded( origin: string | undefined, epoch: number, ): void { - // Release the shared pagination lock so that loadOlderMessages is not - // permanently blocked when this invalidated request exits without retrying. - // If a retry does happen, ensureInitialMessages will re-acquire the lock. - setState(sessionId, (s) => (s.isLoadingMore ? { ...s, isLoadingMore: false } : s)); const ownsFetch = _historyFetchToken.get(sessionId) === token; + // Release the shared pagination lock only when this fetch still owns the + // token. A stale callback from a superseded fetch must not clear the lock + // that a newer replacement backfill is actively holding. + if (ownsFetch) { + setState(sessionId, (s) => (s.isLoadingMore ? { ...s, isLoadingMore: false } : s)); + } const epochChanged = (_messagesEpoch.get(sessionId) ?? 0) !== epoch; const originUnchanged = remoteProjectsStore.getSessionDeviceId(sessionId) === origin; releaseHistoryFetchIfCurrent(sessionId, token);