Skip to content

Commit 80c5498

Browse files
author
IM.codes
committed
Protect active timeline cache from eviction
1 parent a9fed4a commit 80c5498

2 files changed

Lines changed: 111 additions & 0 deletions

File tree

web/src/hooks/useTimeline.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ function pruneTimelineCache(): void {
8282
if (eventsCache.size <= MAX_CACHED_SESSIONS && totalEvents <= MAX_TOTAL_CACHED_EVENTS) return;
8383

8484
const evictionOrder = [...eventsCache.keys()]
85+
.filter((key) => (cacheListeners.get(key)?.size ?? 0) === 0)
8586
.map((key) => ({ key, at: eventsCacheAccess.get(key) ?? 0, size: eventsCache.get(key)?.length ?? 0 }))
8687
.sort((a, b) => a.at - b.at);
8788

web/test/use-timeline-cache.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,116 @@ describe('useTimeline global cache bounds', () => {
8989
expect(screen.getByTestId('window').textContent).toBe('3');
9090
});
9191

92+
it('keeps active session cache resident so a late stale instance cannot wipe history on the next event', async () => {
93+
const sessionName = `deck_transport_live_${Date.now()}`;
94+
const cacheKey = `srv:${sessionName}`;
95+
let handlerA: ((msg: ServerMessage) => void) | null = null;
96+
let handlerB: ((msg: ServerMessage) => void) | null = null;
97+
98+
const wsA: WsClient = {
99+
connected: true,
100+
onMessage: (next: (msg: ServerMessage) => void) => {
101+
handlerA = next;
102+
return () => { handlerA = null; };
103+
},
104+
sendTimelineHistoryRequest: () => 'history-a',
105+
} as unknown as WsClient;
106+
107+
const wsB: WsClient = {
108+
connected: true,
109+
onMessage: (next: (msg: ServerMessage) => void) => {
110+
handlerB = next;
111+
return () => { handlerB = null; };
112+
},
113+
sendTimelineHistoryRequest: () => 'history-b',
114+
} as unknown as WsClient;
115+
116+
function Probe({ name, ws }: { name: string; ws: WsClient }) {
117+
const { events } = useTimeline(sessionName, ws, 'srv');
118+
return h(
119+
'div',
120+
{ 'data-testid': name },
121+
events.map((event) => String(event.payload.text ?? '')).join('|'),
122+
);
123+
}
124+
125+
const view = render(h(Probe, { name: 'primary', ws: wsA }));
126+
127+
await act(async () => {
128+
handlerA?.({
129+
type: 'timeline.history',
130+
sessionName,
131+
requestId: 'history-a',
132+
epoch: 1,
133+
events: [
134+
{
135+
eventId: `${sessionName}-1`,
136+
sessionId: sessionName,
137+
ts: 1,
138+
epoch: 1,
139+
seq: 1,
140+
source: 'daemon',
141+
confidence: 'high',
142+
type: 'user.message',
143+
payload: { text: 'first' },
144+
},
145+
{
146+
eventId: `${sessionName}-2`,
147+
sessionId: sessionName,
148+
ts: 2,
149+
epoch: 1,
150+
seq: 2,
151+
source: 'daemon',
152+
confidence: 'high',
153+
type: 'assistant.text',
154+
payload: { text: 'second' },
155+
},
156+
],
157+
} as ServerMessage);
158+
});
159+
160+
await waitFor(() => {
161+
expect(screen.getByTestId('primary').textContent).toBe('first|second');
162+
});
163+
164+
for (let i = 0; i < 13; i++) {
165+
__setTimelineCacheForTests(`srv:other-${i}`, makeEvents(`other-${i}`, 100));
166+
}
167+
168+
expect(__getTimelineCacheKeysForTests()).toContain(cacheKey);
169+
170+
vi.spyOn(TimelineDB.prototype, 'open').mockImplementation(() => new Promise(() => {}));
171+
172+
view.rerender(
173+
h('div', null,
174+
h(Probe, { name: 'primary', ws: wsA }),
175+
h(Probe, { name: 'secondary', ws: wsB }),
176+
),
177+
);
178+
179+
await act(async () => {
180+
handlerB?.({
181+
type: 'timeline.event',
182+
event: {
183+
eventId: `${sessionName}-3`,
184+
sessionId: sessionName,
185+
ts: 3,
186+
epoch: 1,
187+
seq: 3,
188+
source: 'daemon',
189+
confidence: 'high',
190+
type: 'assistant.text',
191+
payload: { text: 'third' },
192+
},
193+
} as ServerMessage);
194+
});
195+
196+
await waitFor(() => {
197+
expect(screen.getByTestId('primary').textContent).toBe('first|second|third');
198+
expect(screen.getByTestId('secondary').textContent).toBe('first|second|third');
199+
});
200+
});
201+
92202
it('uses the shared cache as the merge base when a late instance is locally stale', () => {
93203
const sessionName = `deck_transport_${Date.now()}`;
94204
const cacheKey = `srv:${sessionName}`;

0 commit comments

Comments
 (0)