Merge remote-tracking branch 'origin/master' into worktree/drop-create-by-name
This commit is contained in:
@@ -83,6 +83,35 @@ describe('connection lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a host.describe business error as generation failure', async () => {
|
||||
const api = new FakeApiClient()
|
||||
let describeCalls = 0
|
||||
api.onDescribe = () => {
|
||||
describeCalls += 1
|
||||
if (describeCalls === 1) {
|
||||
return Promise.resolve({
|
||||
rpcId: 'bad-describe' as never,
|
||||
result: {
|
||||
ok: false as const,
|
||||
error: { code: 'internal' as const, message: 'not ready', details: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
|
||||
}
|
||||
let connected = 0
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST)
|
||||
controller.start()
|
||||
try {
|
||||
await vi.waitFor(() => { expect(describeCalls).toBe(2) })
|
||||
await vi.waitFor(() => { expect(connected).toBe(1) })
|
||||
} finally {
|
||||
controller.stop()
|
||||
warnSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('converges stream/error frames into reconnect instead of dispatching them', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
|
||||
@@ -67,6 +67,8 @@ export class FakeApiClient implements IApiClient {
|
||||
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onAttachment: (payload: unknown) => Promise<RpcResponse<{ attachment: { attachmentId: never; mediaType: 'image/png'; bytes: number; width: number; height: number }; data: string }>> =
|
||||
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
@@ -110,6 +112,7 @@ export class FakeApiClient implements IApiClient {
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)),
|
||||
updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
@@ -38,7 +66,7 @@ describe('HTTP bridge abort', () => {
|
||||
}
|
||||
return Response.json({ aborted: fetchRequest.signal.aborted })
|
||||
},
|
||||
})
|
||||
}, Number.MAX_SAFE_INTEGER)
|
||||
await started
|
||||
response.emit('close')
|
||||
await pending
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
@@ -89,6 +90,19 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails loud when the carrier cap cannot hold the configured image batch', () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 },
|
||||
} as AttachmentStore)
|
||||
ctx.provide('apiProxy', {} as ApiProxy)
|
||||
expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) })
|
||||
.toThrow(/must be at least .* aggregate image limit/)
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
|
||||
Reference in New Issue
Block a user