fix(session-export): wait for response pull capacity

The ZIP loop checked desiredSize only after a push and responded to an overfull queue with setTimeout(0). A timer turn does not mean the consumer drained anything, so a slow or disconnected client still allowed the producer to enqueue the complete compressed archive while later artifact and attachment reads ran eagerly.

Give the ReadableStream a 64 KiB byte queuing strategy and block the single producer on a pull-released capacity gate whenever desiredSize is non-positive. Cancellation wakes that gate through the existing producer signal; synchronous fflate output is therefore bounded to the queue high-water mark plus one input push. A regression test exhausts timer turns without consuming and proves the next media entry remains unread until response pulling begins, and the bilingual contracts now describe the real bound.
This commit is contained in:
Tianyi Cui
2026-08-11 15:18:34 +08:00
parent 192840e198
commit 1419671f3f
8 changed files with 107 additions and 33 deletions

View File

@@ -5,7 +5,8 @@
* root → 404, missing descendant → errored stream).
*/
import { describe, expect, it } from 'vitest'
import { randomBytes } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { unzipSync, strFromU8 } from 'fflate'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -286,6 +287,37 @@ describe('session.export download endpoint', () => {
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('waits for response pull capacity before reading the next archive entry', async () => {
const root = artifact('session-root', undefined, [
imageEventLine('after-root'),
randomBytes(512 * 1024).toString('base64'),
].join('\n'))
let imageReads = 0
const api = await buildApi({ 'session-root': root }, [], {
attachments: async (ref) => {
imageReads += 1
return storedImage(String(ref.attachmentId), ref.mediaType)
},
})
vi.useFakeTimers()
let response: Response | undefined
try {
response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
// Exhausting timer turns must not advance a producer whose byte queue is
// full; only a consumer pull can release it.
await vi.runAllTimersAsync()
expect(imageReads).toBe(0)
} finally {
vi.useRealTimers()
}
if (response === undefined) throw new Error('missing export response')
const files = unzipSync(await responseBytes(response))
expect(imageReads).toBe(1)
expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
})
it('exports an empty artifact as an empty zip entry', async () => {
const root = { ...artifact('session-root'), content: '' }
const api = await buildApi({ 'session-root': root })