fix(sdk-client): address ds-review-bot findings

- api: resolve a relative workspace cwd to absolute before the handshake —
  the child spawns relative to the parent cwd, but the wire cwd is resolved
  again inside the child, so a relative value double-resolved
  (worker -> worker/worker).
- api: make the documented handshake retry real — HarnessClient.close() is
  permanent, so a failed initialize now reaps the runtime and swaps in a
  fresh client; DeepSeekHarness.close() is terminal and stops the respawns.
- api: validate session.event envelopes, assistant/message content, and
  session.finished reasons at the wire boundary — a malformed runtime
  surfaces as SdkProtocolError instead of type-invalid TurnResult data or a
  TypeError out of finalResponse.
- client: a throwing subscribe() filter fails and detaches only its own
  subscription (normalized to Error); sibling fan-out and the transport read
  loop are undisturbed.
- client: NotificationSubscription.close() drops its queued notifications,
  matching its documented contract; runtime-death fail() still leaves
  already-delivered items drainable.
- client: subscribe() after close()/runtime death returns a born-failed
  subscription so next() rejects instead of parking forever.
- client/transport: bounded requests abandon via AbortSignal — the transport
  drops the pending entry at timeout, so repeated bounded calls against a
  hung method retain no per-call state.

One test per finding; per-file coverage stays 100% on both packages.
This commit is contained in:
Tianyi Cui
2026-07-27 17:48:07 +08:00
parent 24d2384294
commit cf2b9e211d
9 changed files with 347 additions and 44 deletions

View File

@@ -16,6 +16,16 @@
* - `FAKE_MALFORMED`: `initialize` returns `{}` (no serverInfo); `prompt` returns `{}` (no accepted).
* - `FAKE_MALFORMED_PROMPT`: `initialize` is normal; only `prompt` returns `{}` (no accepted).
* - `FAKE_INIT_ERROR`: `initialize` answers a JSON-RPC error response with code 7.
* - `FAKE_INIT_ERROR_ONCE_FILE`: fail `initialize` (code 7) only when this
* marker file does NOT exist yet, creating it — so the first runtime
* process fails the handshake and a respawned one succeeds (retry probe).
* - `FAKE_ECHO_CWD_IN_INIT`: reply `serverInfo.version` = this process's cwd
* (wire-visible spawn-cwd probe).
* - `FAKE_MALFORMED_EVENT`: the turn's `session.event` carries a number as
* the event; `FAKE_MALFORMED_MESSAGE`: assistant/message content is not an
* array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data
* member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare
* string (wire-validation probes).
* - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe).
* - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize`
* arrives, then poll for the GO file before answering (deterministic
@@ -78,8 +88,20 @@ function assistantText(): string {
function runTurn(sessionId: string): void {
const text = assistantText()
if (env.FAKE_MALFORMED_EVENT !== undefined) {
notify('session.event', { sessionId, event: 42 })
return
}
event(sessionId, 'turn/start', { turn: 0 })
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text } })
if (env.FAKE_MALFORMED_MESSAGE !== undefined) {
event(sessionId, 'assistant/message', { turn: 0, step: 0, content: 'not-an-array' })
return
}
if (env.FAKE_MESSAGE_WITHOUT_DATA !== undefined) {
notify('session.event', { sessionId, event: { type: 'assistant/message', seq: seq++, time: 0 } })
return
}
event(sessionId, 'assistant/message', {
turn: 0,
step: 0,
@@ -110,7 +132,9 @@ function runTurn(sessionId: string): void {
notify('session.finished', {
sessionId,
status: env.FAKE_STATUS ?? 'ok',
...(reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }),
...(env.FAKE_MALFORMED_REASON !== undefined
? { reason: 'not-a-record' }
: reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }),
})
}
@@ -144,10 +168,19 @@ reader.on('line', (line) => {
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted init failure', data: { hint: 'fake' } } })
return
}
if (env.FAKE_INIT_ERROR_ONCE_FILE !== undefined && !existsSync(env.FAKE_INIT_ERROR_ONCE_FILE)) {
writeFileSync(env.FAKE_INIT_ERROR_ONCE_FILE, 'failed-once\n')
write({ jsonrpc: '2.0', id: frame.id, error: { code: 7, message: 'scripted first-boot failure' } })
return
}
if (env.FAKE_MALFORMED !== undefined) {
respond({})
return
}
if (env.FAKE_ECHO_CWD_IN_INIT !== undefined) {
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: process.cwd() } })
return
}
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
return
case 'session/prompt': {

View File

@@ -5,9 +5,9 @@
* and session-tree scoping, error surfaces, timeouts, and the dispose ladder.
*/
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import {
@@ -122,6 +122,31 @@ describe('DeepSeekHarness', () => {
expect(records).toEqual([{ cwd: dir, provider: 'custom-provider', model: 'custom-model' }])
})
it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => {
// vitest workers forbid chdir, so derive a RELATIVE path from the real
// process cwd to a temp worker dir; resolution is lexical either way.
const dir = await tempDir('sdk-client-relcwd-')
const recordFile = join(dir, 'init.jsonl')
const inner = join(dir, 'worker')
await mkdir(inner)
const relativeCwd = relative(process.cwd(), inner)
expect(isAbsolute(relativeCwd)).toBe(false)
const harness = new DeepSeekHarness({
launch: fakeLaunch({ FAKE_RECORD_INIT: recordFile, FAKE_ECHO_CWD_IN_INIT: '1' }, { cwd: relativeCwd }),
})
cleanups.push(() => harness.close())
await harness.start()
const identity = await harness.client.initialize({ cwd: inner, provider: 'p', model: 'm' })
await harness.close()
// The child spawned under the temp worker dir (its physical cwd)...
expect(identity.serverInfo.version).toBe(await realpath(inner))
// ...and the handshake wire cwd went out ABSOLUTE, so the child cannot
// re-resolve a relative string into dir/worker/worker.
const records = (await readFile(recordFile, 'utf8')).trim().split('\n')
.map(line => (JSON.parse(line) as { cwd: string }).cwd)
expect(records).toEqual([resolvePath(relativeCwd), inner])
})
it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => {
const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
const failure = await harness.run('boom').then(
@@ -134,6 +159,23 @@ describe('DeepSeekHarness', () => {
await expect(harness.run('later')).rejects.toThrow()
})
it('retries a failed handshake with a fresh runtime process', async () => {
const dir = await tempDir('sdk-client-retry-')
const marker = join(dir, 'first-boot-failed')
const harness = harnessWith({ FAKE_INIT_ERROR_ONCE_FILE: marker, FAKE_TEXT: 'second boot answer' })
const firstClient = harness.client
// First start: the scripted runtime fails the handshake and is reaped.
await expect(harness.start()).rejects.toThrow('scripted first-boot failure')
// Retry spawns a NEW subprocess through a fresh client (close is permanent).
const result = await harness.run('again')
expect(harness.client).not.toBe(firstClient)
expect(result.status).toBe('ok')
expect(result.finalResponse).toBe('second boot answer')
await harness.close()
// close() is terminal: a handshake failure after it must not respawn.
await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError)
})
it('rejects a malformed initialize result as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED: '1' })
await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
@@ -162,6 +204,22 @@ describe('HarnessClient', () => {
await client.close()
})
it('a timed-out request leaves no pending transport state', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
for (let round = 0; round < 3; round++) {
await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('x') }, 50))
.rejects.toThrow(RequestTimeoutError)
}
// Abandonment removed each pending entry at its timeout; a hung method
// retains nothing per call. (Private map read is the observable here —
// no wire surface reports transport bookkeeping.)
const transport = (client as unknown as { transport: { pending: Map<string, unknown> } }).transport
expect(transport.pending.size).toBe(0)
await client.close()
})
it('applies the client-wide request timeout when no per-call bound is given', async () => {
const client = new HarnessClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
cleanups.push(() => client.close())
@@ -255,6 +313,10 @@ describe('HarnessClient', () => {
expect(finished.method).toBe('session.finished')
expect(finishedOnly.tryNext()).toBeUndefined()
// A bare unbounded request with omitted params sends `{}` on the wire.
const identity = await client.request('initialize') as { serverInfo: { name: string } }
expect(identity.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
// Async iteration consumes queued items and then parks.
const collected: string[] = []
for await (const notification of all) {
@@ -269,6 +331,56 @@ describe('HarnessClient', () => {
await client.close()
})
it('contains a throwing filter to its own subscription', async () => {
const client = new HarnessClient(fakeLaunch())
cleanups.push(() => client.close())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const broken = client.subscribe(() => { throw new Error('filter exploded') })
// A non-Error throw is normalized rather than crashing dispatch.
const brokenNonError = client.subscribe(() => { throw 'string boom' })
const healthy = client.subscribe(n => n.method === 'session.finished')
await client.prompt('filter-contain', normalizeInput('go'))
// The sibling subscription and the read loop are undisturbed.
expect((await healthy.next()).method).toBe('session.finished')
// Each broken subscription failed with ITS OWN error and detached.
await expect(broken.next()).rejects.toThrow('filter exploded')
await expect(brokenNonError.next()).rejects.toThrow('string boom')
healthy.close()
await client.close()
})
it('close() drops queued notifications; runtime death keeps them drainable', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const closed = client.subscribe()
const drainable = client.subscribe()
await client.prompt('queue-drop', normalizeInput('go'))
expect(closed.tryNext()).toBeDefined()
closed.close()
// Manual close drops the rest of the queue outright.
expect(closed.tryNext()).toBeUndefined()
await expect(closed.next()).rejects.toThrow('notification subscription closed')
// Runtime teardown, by contrast, only stops FUTURE delivery: what was
// already delivered before close() stays drainable.
await client.close()
expect(drainable.tryNext()).toBeDefined()
})
it('subscriptions created after termination are born failed', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
await client.close()
// No producer can ever feed this subscription; next() must not park forever.
await expect(client.subscribe().next()).rejects.toThrow(TransportClosedError)
const dead = new HarnessClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1' }))
cleanups.push(() => dead.close())
await dead.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).catch(() => {})
await expect(dead.subscribe().next()).rejects.toThrow(TransportClosedError)
})
it('closes subscriptions with the runtime and rejects parked waiters', async () => {
const client = new HarnessClient(fakeLaunch())
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
@@ -310,6 +422,28 @@ describe('HarnessClient', () => {
})
})
describe('wire payload validation', () => {
it('rejects a non-object session.event envelope as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED_EVENT: '1' })
await expect(harness.run('bad-event')).rejects.toThrow(SdkProtocolError)
})
it('rejects an assistant/message without a content array as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED_MESSAGE: '1' })
await expect(harness.run('bad-message')).rejects.toThrow(SdkProtocolError)
})
it('rejects an assistant/message without a data member as a protocol error', async () => {
const harness = harnessWith({ FAKE_MESSAGE_WITHOUT_DATA: '1' })
await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
})
it('rejects a malformed session.finished reason as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED_REASON: '1' })
await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError)
})
})
describe('stderr tail bound', () => {
it('keeps only the newest lines up to the limit', async () => {
const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')