fix(apiproxy): address session-export review — surrogate-safe chunks, backpressure drain, strict flag

Chunk boundaries never split a surrogate pair (a lone high surrogate
re-encodes as U+FFFD and silently corrupts the exported artifact), production
yields whenever the response queue fills so a slow consumer bounds the
accumulation, includeDescendants rejects values other than true/false instead
of silently under-exporting, the dead missing-services arm is deleted by
narrowing the streaming deps, and the readRaw failure answers 500 without
leaking host paths into the browser error bar.
This commit is contained in:
_Kerman
2026-08-10 19:50:09 +08:00
parent bf70da473b
commit beb1d0601f
4 changed files with 171 additions and 39 deletions

View File

@@ -43,7 +43,7 @@ function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageN
async function buildApi(
artifacts: Record<string, SessionRawArtifact>,
descendants: SessionLineageNode[] = [],
services: { query?: boolean; persistence?: boolean } = { query: true, persistence: true },
services: { query?: boolean; persistence?: boolean | 'throw' } = { query: true, persistence: true },
) {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
@@ -60,7 +60,10 @@ async function buildApi(
}
if (services.persistence) {
ctx.provide('sessionPersistence', {
readRaw: async (id: SessionId) => artifacts[id],
readRaw: async (id: SessionId) => {
if (services.persistence === 'throw') throw new Error('/host/private/session.jsonl')
return artifacts[id]
},
} as never)
}
return createApiProxy(ctx, {
@@ -125,6 +128,14 @@ describe('session.export download endpoint', () => {
expect(response.status).toBe(400)
})
it('answers 400 for an includeDescendants value other than true or false', async () => {
const api = await buildApi({ 'session-root': artifact('session-root') })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
)
expect(response.status).toBe(400)
})
it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
const api = await buildApi({}, [], { query: false, persistence: false })
const response = await toFetchHandler(api).fetch(
@@ -146,4 +157,73 @@ describe('session.export download endpoint', () => {
// than returning a truncated-but-valid archive.
await expect(response.arrayBuffer()).rejects.toThrow()
})
it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
// The push loop slices by 2^16 code units and must back off one unit when
// the boundary lands inside a surrogate pair; otherwise the pair re-encodes
// as U+FFFD and the exported artifact is silently corrupted.
const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
// A boundary that lands on a BMP character needs no surrogate backoff; the
// round trip must still be byte-identical across the multi-chunk push.
const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
})
it('exports an empty artifact as an empty zip entry', async () => {
const root = { ...artifact('session-root'), content: '' }
const api = await buildApi({ 'session-root': root })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files)).toEqual(['session.jsonl'])
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
})
it('exports a shared lineage node once (seen-set dedup)', async () => {
const api = await buildApi({
'session-root': artifact('session-root'),
'child-a': artifact('child-a', sid('session-root')),
'child-b': artifact('child-b', sid('session-root')),
shared: artifact('shared', sid('child-a')),
}, [
node('child-a', node('shared')),
node('child-b', node('shared')),
])
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
)
const files = unzipSync(await responseBytes(response))
expect(Object.keys(files).sort()).toEqual([
'session.jsonl',
'subagents/child-a/session.jsonl',
'subagents/child-b/session.jsonl',
'subagents/shared/session.jsonl',
])
})
it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
const api = await buildApi({}, [], { query: true, persistence: 'throw' })
const response = await toFetchHandler(api).fetch(
new Request('http://host/api/session.export?sessionId=session-root'),
)
expect(response.status).toBe(500)
const body = await response.text()
expect(body).toBe('session log export failed to read the stored artifact')
expect(body).not.toContain('/host/private/')
})
})