fix: address ds-review-bot v8 findings
- llm-deepseek: the uncatalogued resolveModel fallback declares text-only modalities — the wire route is text-only regardless of catalog membership, so "unknown" must not let the host persist-then-fail images. - session.selectModel also consults the pending-inbox mirror: a queued image prompt enters the log only when claimed, after a switch would land. - attachment store: ensureDurableDirectory syncs every ancestor entry up to a caller-vouched boundary regardless of what mkdir reports — a raced "already existed" is not "already durable". - One image walker (imageBlockIn/imageInEvent) now serves both attachment authorization and the selection gate; referencedImage therefore also authorizes references inside wrapped message content. - InputHub: the scope disposer resolves the conversation service optionally (teardown/HMR must reach quiescence), and a send failing after its scope died releases the in-flight drafts instead of restoring them onto a disposed shell. - http-bridge destroys declared-oversize requests with connection: close instead of draining a body the client can trickle indefinitely. - LlmService validates AND detaches modality arrays identically on the advisory and exact routes; READMEs record the fourth INVALID_MODEL_INFO rejection reason. - CLI provider docs (JSDoc, README pair, Agent Note pair) describe the reuse behavior; llm-route.spec now parses the SHIPPED cordis.yml through the production extraction, pinning the row coupling. - image-display lane pins gallery/rail shape in inline snapshots and the object-URL scheme this environment must take; stale host.schema comment dropped.
This commit is contained in:
@@ -30,9 +30,13 @@ export async function bridge(
|
||||
})
|
||||
const declaredLength = req.headers['content-length']
|
||||
if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) {
|
||||
res.writeHead(413)
|
||||
// Same discipline as the chunked-overrun path below: destroy, never
|
||||
// drain. resume() would keep the socket open while the client trickles
|
||||
// its declared length — an already-rejected request holding a server
|
||||
// socket for as long as it likes.
|
||||
res.writeHead(413, { connection: 'close' })
|
||||
res.end()
|
||||
req.resume()
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
@@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest'
|
||||
import { bridge } from '../src/http-bridge.ts'
|
||||
|
||||
describe('HTTP bridge abort', () => {
|
||||
it('destroys a declared-oversize request instead of draining it', async () => {
|
||||
const destroyed: true[] = []
|
||||
const request = Readable.from([]) as unknown as IncomingMessage
|
||||
Object.assign(request, {
|
||||
url: '/api/session.prompt',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'content-length': '999999' },
|
||||
destroy: () => { destroyed.push(true) },
|
||||
})
|
||||
let status: number | undefined
|
||||
let headers: unknown
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(code: number, values?: unknown) { status = code; headers = values; return this },
|
||||
write() { return true },
|
||||
end(this: { writableEnded: boolean }) { this.writableEnded = true; return this },
|
||||
}) as unknown as ServerResponse
|
||||
|
||||
await bridge(request, response, {
|
||||
fetch: () => { throw new Error('a rejected request must never reach the handler') },
|
||||
}, 1000)
|
||||
// The socket must not stay parked draining a body the client can trickle
|
||||
// at will after the rejection — same discipline as the chunked overrun.
|
||||
expect(status).toBe(413)
|
||||
expect(headers).toMatchObject({ connection: 'close' })
|
||||
expect(destroyed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('aborts a pending native picker request when the browser disconnects', async () => {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {},
|
||||
|
||||
@@ -87,11 +87,17 @@ export class InputHub implements InputService {
|
||||
for (const off of offs) off()
|
||||
// Draft attachments die with the scope: the shell only holds ids, so
|
||||
// the service-owned File objects and object URLs must be released
|
||||
// here or they leak for the page lifetime.
|
||||
// here or they leak for the page lifetime. The lookup is optional —
|
||||
// during application teardown or HMR of this plugin the root
|
||||
// `conversation` service can already be unregistered while session
|
||||
// scopes are still alive; a throwing disposer would abort teardown
|
||||
// quiescence, and the service's own disposal effect revokes every
|
||||
// remaining URL in that case anyway.
|
||||
const drafts = shell.snapshot.imageIds
|
||||
shell.dispose()
|
||||
this.shells.delete(id)
|
||||
for (const imageId of drafts) this.conversation().releaseDraftImage(imageId)
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const imageId of drafts) conversation?.releaseDraftImage(imageId)
|
||||
}
|
||||
}, 'conversation.input: session shell')
|
||||
return shell
|
||||
@@ -139,8 +145,18 @@ export class InputHub implements InputService {
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
shell?.commitSend(imageIds)
|
||||
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => {
|
||||
shell?.restoreImages(imageIds)
|
||||
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
// Restore only into the shell that still owns the session: if the scope
|
||||
// died while the send was in flight, `commitSend` already removed the
|
||||
// ids from the (now disposed) shell, so the teardown release could not
|
||||
// see them — release the drafts here instead of resurrecting them onto
|
||||
// a dead instance where they would leak for the page lifetime.
|
||||
if (this.shells.get(session.sessionId) === shell) {
|
||||
shell?.restoreImages(imageIds)
|
||||
if (shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
return
|
||||
}
|
||||
const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
|
||||
for (const id of imageIds) conversation?.releaseDraftImage(id)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,34 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases in-flight send images when the scope dies before the failure lands', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1')
|
||||
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
||||
try {
|
||||
const [attachment] = b.root.createDraftImages([new File([new Uint8Array(4)], 'b.png', { type: 'image/png' })])
|
||||
if (attachment === undefined) throw new Error('draft attachment missing')
|
||||
const shell = b.hub.shell(b.runtime.sessions.behavior('s1').sessionId)
|
||||
shell.addImages([attachment.id])
|
||||
let reject!: (error: Error) => void
|
||||
b.prompt.mockReturnValueOnce(new Promise((_resolve, rej) => { reject = rej }) as never)
|
||||
shell.setDraft('x')
|
||||
shell.submit('queue')
|
||||
// commitSend already removed the ids from the shell; kill the scope
|
||||
// while the RPC is still pending, then land the failure.
|
||||
await b.runtime.sessions.remove('s1')
|
||||
reject(new Error('transport died'))
|
||||
await vi.waitFor(() => {
|
||||
expect(revoked).toHaveBeenCalledWith('blob:inflight-1')
|
||||
})
|
||||
expect(b.root.draftImages([attachment.id])).toEqual([])
|
||||
} finally {
|
||||
created.mockRestore()
|
||||
revoked.mockRestore()
|
||||
}
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
|
||||
Reference in New Issue
Block a user