mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Keep DeepSeekHarness.run() reusable, but make ownership of its lazy runtime process explicit. Document the context-manager/close contract and update every construction example to use a context manager so repeated runs remain valid without encouraging leaked subprocesses. Contain notification predicate failures at the subscription boundary. Remove only the subscriber whose callback raised, deliver that exception through its queue, and continue dispatching to healthy subscribers so arbitrary callback code cannot terminate the shared reader thread or strand later requests. Enforce one in-flight prompt per server session with an atomic activePrompt guard. Route overlap through the existing -32603 handler-error response and clear the guard in finally, preserving parallel prompts across sessions and sequential reuse without changing JSON-RPC request or notification shapes. Use StringDecoder for line framing so a UTF-8 code point split across Buffer chunks is not corrupted. Add a queued-write flush barrier, and make memoized shutdown await it before disposal and exit while retaining exactly-once cleanup when shutdown calls race or flushing fails. Cover callback isolation, same-session exclusion, cross-session concurrency, split multibyte input, delayed writes, racing shutdown, and flush failure with deterministic tests.
261 lines
7.9 KiB
TypeScript
261 lines
7.9 KiB
TypeScript
import { once } from 'node:events'
|
|
import { PassThrough, Writable } from 'node:stream'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { JsonRpcLineTransport } from '../src/index.ts'
|
|
|
|
function transportPair() {
|
|
const aToB = new PassThrough()
|
|
const bToA = new PassThrough()
|
|
const a = new JsonRpcLineTransport(bToA, aToB)
|
|
const b = new JsonRpcLineTransport(aToB, bToA)
|
|
return { a, b, aToB, bToA }
|
|
}
|
|
|
|
describe('JsonRpcLineTransport', () => {
|
|
it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => {
|
|
const { a, b } = transportPair()
|
|
const notifications: Record<string, unknown>[] = []
|
|
|
|
a.onRequest(async (method, params) => {
|
|
expect(method).toBe('echo')
|
|
return { echoed: params }
|
|
})
|
|
b.onNotification((method, params) => {
|
|
notifications.push({ method, params })
|
|
})
|
|
a.start()
|
|
b.start()
|
|
|
|
const response = await b.request('echo', { value: 42 })
|
|
expect(response).toEqual({ echoed: { value: 42 } })
|
|
|
|
a.notify('session.finished', { sessionId: 'main', status: 'ok' })
|
|
a.notify('heartbeat')
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
expect(notifications).toEqual([
|
|
{ method: 'session.finished', params: { sessionId: 'main', status: 'ok' } },
|
|
{ method: 'heartbeat', params: {} },
|
|
])
|
|
|
|
a.close()
|
|
b.close()
|
|
})
|
|
|
|
it('reports JSON-RPC request errors from the remote peer', async () => {
|
|
const { a, b } = transportPair()
|
|
a.onRequest(async () => {
|
|
throw new Error('handler boom')
|
|
})
|
|
a.start()
|
|
b.start()
|
|
|
|
await expect(b.request('explode', {})).rejects.toThrow('handler boom')
|
|
|
|
a.close()
|
|
b.close()
|
|
})
|
|
|
|
it('stringifies non-Error request handler failures', async () => {
|
|
const { a, b } = transportPair()
|
|
a.onRequest(async () => {
|
|
throw 'string boom'
|
|
})
|
|
a.start()
|
|
b.start()
|
|
|
|
await expect(b.request('explode-string', {})).rejects.toThrow('string boom')
|
|
|
|
a.close()
|
|
b.close()
|
|
})
|
|
|
|
it('reports method-not-found when no request handler is installed', async () => {
|
|
const { a, b } = transportPair()
|
|
a.start()
|
|
b.start()
|
|
|
|
await expect(b.request('missing', {})).rejects.toThrow('method not found: missing')
|
|
|
|
a.close()
|
|
b.close()
|
|
})
|
|
|
|
it('normalizes non-object request params and ignores notifications without a handler', async () => {
|
|
const { aToB, bToA, b } = transportPair()
|
|
const seen: Record<string, unknown>[] = []
|
|
b.onRequest(async (method, params) => {
|
|
seen.push({ method, params })
|
|
return { ok: true }
|
|
})
|
|
b.start()
|
|
|
|
aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n')
|
|
aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n')
|
|
const chunk = (await once(bToA, 'data'))[0] as Buffer | string
|
|
|
|
expect(seen).toEqual([{ method: 'array-params', params: {} }])
|
|
expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } })
|
|
b.close()
|
|
})
|
|
|
|
it('ignores malformed frames and accepts notifications without params', async () => {
|
|
const { aToB, b } = transportPair()
|
|
const notifications: Record<string, unknown>[] = []
|
|
b.onNotification((method, params) => {
|
|
notifications.push({ method, params })
|
|
})
|
|
b.start()
|
|
b.start()
|
|
|
|
aToB.write('not json\n')
|
|
aToB.write('\n')
|
|
aToB.write('null\n')
|
|
aToB.write('{"jsonrpc":"2.0","params":{}}\n')
|
|
aToB.write('{"jsonrpc":"2.0","method":"tick"}\n')
|
|
aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n')
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
expect(notifications).toEqual([
|
|
{ method: 'tick', params: {} },
|
|
{ method: 'string-chunk', params: {} },
|
|
])
|
|
b.close()
|
|
})
|
|
|
|
it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => {
|
|
const input = new PassThrough()
|
|
const output = new PassThrough()
|
|
const transport = new JsonRpcLineTransport(input, output)
|
|
const notifications: Record<string, unknown>[] = []
|
|
transport.onNotification((method, params) => { notifications.push({ method, params }) })
|
|
transport.start()
|
|
|
|
const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`)
|
|
const character = Buffer.from('你')
|
|
const characterStart = frame.indexOf(character)
|
|
expect(characterStart).toBeGreaterThanOrEqual(0)
|
|
input.write(frame.subarray(0, characterStart + 1))
|
|
input.write(frame.subarray(characterStart + 1))
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }])
|
|
transport.close()
|
|
})
|
|
|
|
it('flush waits for all earlier output writes', async () => {
|
|
const events: string[] = []
|
|
const output = new Writable({
|
|
write(chunk: Buffer, _encoding, callback) {
|
|
const label = chunk.length === 0 ? 'barrier' : 'frame'
|
|
events.push(`start:${label}`)
|
|
setTimeout(() => {
|
|
events.push(`finish:${label}`)
|
|
callback()
|
|
}, 5)
|
|
},
|
|
})
|
|
const transport = new JsonRpcLineTransport(new PassThrough(), output)
|
|
|
|
transport.notify('tick')
|
|
await transport.flush()
|
|
|
|
expect(events).toEqual([
|
|
'start:frame',
|
|
'finish:frame',
|
|
'start:barrier',
|
|
'finish:barrier',
|
|
])
|
|
transport.close()
|
|
})
|
|
|
|
it('reports an output callback failure from flush', async () => {
|
|
const output = {
|
|
write(_chunk: string, callback?: (error?: Error) => void) {
|
|
callback?.(new Error('flush failed'))
|
|
return true
|
|
},
|
|
}
|
|
const transport = new JsonRpcLineTransport(new PassThrough(), output as never)
|
|
|
|
await expect(transport.flush()).rejects.toThrow('flush failed')
|
|
})
|
|
|
|
it('rejects pending requests when the input closes', async () => {
|
|
const { aToB, b } = transportPair()
|
|
b.start()
|
|
|
|
const pending = b.request('never-replies', {})
|
|
aToB.end()
|
|
|
|
await expect(pending).rejects.toThrow('JSON-RPC input closed')
|
|
b.close()
|
|
})
|
|
|
|
it('rejects pending requests when the input errors', async () => {
|
|
const { aToB, b } = transportPair()
|
|
b.start()
|
|
|
|
const pending = b.request('never-replies', {})
|
|
aToB.emit('error', new Error('input broke'))
|
|
|
|
await expect(pending).rejects.toThrow('input broke')
|
|
b.close()
|
|
})
|
|
|
|
it('rejects pending requests when the transport closes', async () => {
|
|
const { b } = transportPair()
|
|
|
|
const pending = b.request('never-replies', {})
|
|
b.close()
|
|
|
|
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
|
|
})
|
|
|
|
it('rejects a request when writing the frame throws', async () => {
|
|
const input = new PassThrough()
|
|
const output = {
|
|
write() {
|
|
throw new Error('write exploded')
|
|
},
|
|
}
|
|
const transport = new JsonRpcLineTransport(input, output as never)
|
|
|
|
await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded')
|
|
})
|
|
|
|
it('stringifies non-Error write failures', async () => {
|
|
const input = new PassThrough()
|
|
const output = {
|
|
write() {
|
|
throw 'write string'
|
|
},
|
|
}
|
|
const transport = new JsonRpcLineTransport(input, output as never)
|
|
|
|
await expect(transport.request('write-fails', {})).rejects.toThrow('write string')
|
|
})
|
|
|
|
it('uses a fallback message for malformed JSON-RPC error responses', async () => {
|
|
const { aToB, bToA, b } = transportPair()
|
|
b.start()
|
|
|
|
const pending = b.request('remote-error', {})
|
|
const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
|
|
const request = JSON.parse(String(requestChunk)) as { id: string }
|
|
aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`)
|
|
|
|
await expect(pending).rejects.toThrow('JSON-RPC error')
|
|
b.close()
|
|
})
|
|
|
|
it('ignores responses that do not match a pending request', async () => {
|
|
const { aToB, b } = transportPair()
|
|
b.start()
|
|
|
|
aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n')
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
|
|
b.close()
|
|
})
|
|
})
|