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

@@ -213,7 +213,7 @@ export function sessionLogZipFilename(sessionId: string): string {
* missing-session path can answer cleanly before streaming starts).
* @param sessionId - the root session id.
* @param includeDescendants - whether to include every subagent descendant.
* @param signal - optional cancellation forwarded to lineage and persistence reads.
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
* @returns the export entries in zip order.
*/
export async function* sessionLogZipEntries(
@@ -259,7 +259,7 @@ export async function* sessionLogZipEntries(
}
for (const ref of media.values()) {
signal?.throwIfAborted()
const stored = await deps.attachments.readImage(ref)
const stored = await deps.attachments.readImage(ref, signal)
signal?.throwIfAborted()
yield { path: mediaEntryPath(ref), data: stored.data }
}

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) => {