fix(apiproxy): cancel attachment reads during export

Response-consumer cancellation already stopped lineage reads, persistence reads, and ZIP production, but the final attachment phase called readImage without the producer signal. A slow or stalled attachment backend could therefore keep working after the browser abandoned the download and prevent the producer from settling.\n\nExtend the attachment read seam with optional cancellation, forward it through the local backend into Node's filesystem read, and preserve the abort reason rather than wrapping it as a storage failure. The exporter now passes its combined request/consumer signal to every attachment read.\n\nCover both ownership boundaries: the local-store test proves filesystem forwarding and cancellation identity, while the assembled export test cancels a reader during a pending attachment provider call. Regenerate the Cordis API catalog and paired documentation so implementers can rely on the new contract.
This commit is contained in:
Tianyi Cui
2026-08-11 17:52:24 +08:00
parent 5e067fa7fe
commit c10d74ba95
19 changed files with 101 additions and 27 deletions

View File

@@ -60,7 +60,7 @@ async function buildApi(
services: {
query?: boolean
persistence?: boolean | 'throw' | 'unsupported'
attachments?: boolean | ((ref: ImageAttachmentRef) => Promise<ReturnType<typeof storedImage>>)
attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
sessions?: {
get(id: SessionId): { readonly id: SessionId } | undefined
flush(session: { readonly id: SessionId }): Promise<boolean>
@@ -490,6 +490,39 @@ describe('session.export download endpoint', () => {
expect(descendantSignal.reason).toBe(cancellation)
})
it('aborts attachment reads when its reader cancels', async () => {
let reportAttachmentStarted!: (signal: AbortSignal) => void
const attachmentStarted = new Promise<AbortSignal>((resolve) => {
reportAttachmentStarted = resolve
})
const root = artifact('session-root', undefined, [
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
imageEventLine('slow-img'),
].join('\n') + '\n')
const api = await buildApi({ 'session-root': root }, [], {
attachments: async (_ref, signal) => {
if (signal === undefined) throw new Error('missing attachment signal')
reportAttachmentStarted(signal)
return new Promise((_, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason as Error)
}, { once: true })
})
},
})
const response = await api.downloads.sessionLog(
{ sessionId: sid('session-root'), includeDescendants: false },
new AbortController().signal,
)
const reader = response.body?.getReader()
if (reader === undefined) throw new Error('missing response body')
const attachmentSignal = await attachmentStarted
const cancellation = new Error('download consumer left during attachment read')
await reader.cancel(cancellation)
expect(attachmentSignal.aborted).toBe(true)
expect(attachmentSignal.reason).toBe(cancellation)
})
it('uses a stable Error reason when its reader cancels without one', async () => {
let reportDescendantStarted!: (signal: AbortSignal) => void
const descendantStarted = new Promise<AbortSignal>((resolve) => {