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
+39 -2
View File
@@ -109,15 +109,47 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
this.notificationHandler = handler
}
request(method: string, params: object): Promise<unknown> {
/**
* Send a request and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
* @param signal - optional abandonment signal: aborting removes the pending
* entry (no state is retained for a response that may never come) and
* rejects with the signal's reason.
* @returns the result; rejects per {@link JsonRpcTransportPeer.request}.
*/
request(method: string, params: object, signal?: AbortSignal): Promise<unknown> {
const id = `req_${randomUUID().replaceAll('-', '')}`
const message = { jsonrpc: '2.0', id, method, params }
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
let detach = (): void => {}
if (signal !== undefined) {
if (signal.aborted) {
reject(abortError(signal.reason))
return
}
const onAbort = (): void => {
this.pending.delete(id)
reject(abortError(signal.reason))
}
signal.addEventListener('abort', onAbort, { once: true })
detach = () => { signal.removeEventListener('abort', onAbort) }
}
this.pending.set(id, {
resolve: (value) => {
detach()
resolve(value)
},
reject: (error) => {
detach()
reject(error)
},
})
try {
this.write(message)
} catch (error) {
this.pending.delete(id)
detach()
reject(error instanceof Error ? error : new Error(String(error)))
}
})
@@ -240,3 +272,8 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
function objectParams(params: unknown): Record<string, unknown> {
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
}
/** Normalize an abort reason into the rejection Error (a non-Error reason is stringified). */
function abortError(reason: unknown): Error {
return reason instanceof Error ? reason : new Error(`JSON-RPC request aborted: ${String(reason)}`)
}