Browse Source

fix(client): preserve paged event views during gap repair

pku-xht 2 months ago
parent
commit
c3058e8d46

+ 12 - 6
packages/client/runtime/src/client/sessions/session.ts

@@ -861,6 +861,18 @@ export class Session implements SessionFace {
    *  which lets the transcript render every event between its ends and a compaction checkpoint
    *  which lets the transcript render every event between its ends and a compaction checkpoint
    *  find its cited summary event. */
    *  find its cited summary event. */
   private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void {
   private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void {
+    const loading = this.loadingOlder
+    if (loading !== null && view !== undefined && event.seq < loading.beforeSeq) {
+      try {
+        const retained = loading.views.get(event.seq)
+        if (retained !== undefined) assertSameEvent(retained.event, event)
+        loading.views.set(event.seq, { event, view })
+      } catch (error) {
+        console.error('[web-runtime] older-page late session event failed identity validation:', error)
+        void this.resync()
+      }
+      return
+    }
     if (this.openState === 'loading' || this.stitching) {
     if (this.openState === 'loading' || this.stitching) {
       this.liveBuffer.push({ event, view })
       this.liveBuffer.push({ event, view })
       return
       return
@@ -870,12 +882,6 @@ export class Session implements SessionFace {
     if (tailSeq !== null && event.seq <= tailSeq) {
     if (tailSeq !== null && event.seq <= tailSeq) {
       try {
       try {
         if (event.seq < this.baseSeq) {
         if (event.seq < this.baseSeq) {
-          const loading = this.loadingOlder
-          if (loading !== null && view !== undefined && event.seq < loading.beforeSeq) {
-            const retained = loading.views.get(event.seq)
-            if (retained !== undefined) assertSameEvent(retained.event, event)
-            loading.views.set(event.seq, { event, view })
-          }
           return
           return
         }
         }
         const changed = this.upgradeLiveView(event, view)
         const changed = this.upgradeLiveView(event, view)

+ 82 - 0
packages/client/runtime/tests/session.spec.ts

@@ -241,6 +241,88 @@ describe('late event views', () => {
     }])
     }])
   })
   })
 
 
+  it('keeps an older-page late view while a gap repair is also in flight', async () => {
+    const { api, session } = makeSession()
+    api.onHistory = () => histResponse(logRange(6, 12), true)
+    await session.open()
+
+    const repair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = payload => payload.beforeSeq === undefined ? repair.promise : page.promise
+    const gapTail = ev.user(15, '修复后的尾部')
+    session.handleMuxEnvelope('gap' as never, {
+      type: 'session/event', sessionId: SID, event: gapTail,
+    })
+    await vi.waitFor(() => {
+      expect(api.callsOf('session.history')).toHaveLength(2)
+    })
+
+    const loading = session.loadOlder()
+    const target = reminderEvent(3, 'schedule-overlapping-repairs')
+    session.handleMuxEnvelope('late' as never, {
+      type: 'session/event', sessionId: SID, event: target,
+      view: reminderView('schedule-overlapping-repairs'),
+    })
+
+    repair.resolve(ok({
+      events: entries([...logRange(6, 15), gapTail]) as never[],
+      hasMore: true,
+    }))
+    await vi.waitFor(() => {
+      expect(session.getSnapshot().nodes).toMatchObject([{ kind: 'user', seq: 15 }])
+    })
+    page.resolve(ok({
+      events: entries([...logRange(0, 3), target, ...logRange(4, 6)]) as never[],
+      hasMore: false,
+    }))
+    await loading
+
+    expect(session.getSnapshot().nodes).toMatchObject([
+      {
+        kind: 'presented-event', seq: target.seq,
+        view: { id: 'schedule-overlapping-repairs' },
+      },
+      { kind: 'user', seq: 15 },
+    ])
+  })
+
+  it('resyncs when repeated older-page late views disagree on event identity', async () => {
+    const { api, session } = makeSession()
+    const newer = logRange(6, 12)
+    api.onHistory = () => histResponse(newer, true)
+    await session.open()
+
+    const page = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
+    api.onHistory = () => page.promise
+    const loading = session.loadOlder()
+    const delivered = reminderEvent(3, 'schedule-delivered')
+    session.handleMuxEnvelope('late' as never, {
+      type: 'session/event', sessionId: SID, event: delivered,
+      view: reminderView('schedule-delivered'),
+    })
+
+    api.onHistory = () => histResponse(newer)
+    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    try {
+      session.handleMuxEnvelope('drifted' as never, {
+        type: 'session/event', sessionId: SID, event: reminderEvent(3, 'schedule-drifted'),
+        view: reminderView('schedule-drifted'),
+      })
+      await vi.waitFor(() => {
+        expect(api.callsOf('session.history')).toHaveLength(3)
+        expect(session.getSnapshot().openState).toBe('open')
+      })
+      expect(errorSpy).toHaveBeenCalledWith(
+        '[web-runtime] older-page late session event failed identity validation:',
+        expect.objectContaining({ message: 'session event identity mismatch at seq 3' }),
+      )
+    } finally {
+      errorSpy.mockRestore()
+      page.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
+      await loading
+    }
+  })
+
   it('resyncs when an older page disagrees with its buffered late event identity', async () => {
   it('resyncs when an older page disagrees with its buffered late event identity', async () => {
     const { api, session } = makeSession()
     const { api, session } = makeSession()
     const newer = logRange(6, 12)
     const newer = logRange(6, 12)