2026-07-11 14:08:12 +08:00
|
|
|
import { createServer } from 'node:http'
|
|
|
|
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
|
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
|
|
import { join } from 'node:path'
|
|
|
|
|
import { tmpdir } from 'node:os'
|
|
|
|
|
import { PassThrough, Writable } from 'node:stream'
|
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
import { Context } from 'cordis'
|
2026-07-15 15:57:57 +08:00
|
|
|
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
2026-07-11 14:08:12 +08:00
|
|
|
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
|
|
|
|
import * as jsonrpc from '../src/index.ts'
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-14 00:40:36 +08:00
|
|
|
* Mount the real namespace plugin with in-memory stdio and exit seams. Covers
|
|
|
|
|
* the full transport/server path, response-before-exit shutdown exactly once,
|
|
|
|
|
* and bare-fiber disposal without process exit.
|
2026-07-11 14:08:12 +08:00
|
|
|
*/
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** One ordered frame, write completion, or exit observation. */
|
2026-07-11 14:08:12 +08:00
|
|
|
type WireEvent =
|
|
|
|
|
| { kind: 'frame'; frame: Record<string, unknown> }
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
| { kind: 'write-complete'; ids: (string | number)[] }
|
2026-07-11 14:08:12 +08:00
|
|
|
| { kind: 'exit'; code: number }
|
|
|
|
|
|
|
|
|
|
interface ApplyHarness {
|
|
|
|
|
ctx: Context
|
2026-07-14 00:40:36 +08:00
|
|
|
/** The plugin fiber used by the bare-dispose case. */
|
2026-07-11 14:08:12 +08:00
|
|
|
fiber: Awaited<ReturnType<Context['plugin']>>
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Frames, write completions, and exits in observation order. */
|
2026-07-11 14:08:12 +08:00
|
|
|
events: WireEvent[]
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
outputErrors: Error[]
|
2026-07-11 14:08:12 +08:00
|
|
|
send(frame: Record<string, unknown>): void
|
|
|
|
|
sendRaw(text: string): void
|
|
|
|
|
frames(): Record<string, unknown>[]
|
|
|
|
|
exits(): number[]
|
|
|
|
|
waitForFrame(predicate: (frame: Record<string, unknown>) => boolean, description: string): Promise<Record<string, unknown>>
|
|
|
|
|
dispose(): Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Poll asynchronous output for up to five seconds. */
|
2026-07-11 14:08:12 +08:00
|
|
|
async function waitFor<T>(get: () => T | undefined, description: string): Promise<T> {
|
|
|
|
|
const deadline = Date.now() + 5000
|
|
|
|
|
for (;;) {
|
|
|
|
|
const value = get()
|
|
|
|
|
if (value !== undefined) return value
|
|
|
|
|
if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`)
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Drain asynchronous work before a negative assertion. */
|
2026-07-11 14:08:12 +08:00
|
|
|
async function settle(): Promise<void> {
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 25))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Mount the real plugin on a minimal harness with in-memory stdio and exit. */
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
async function mountPlugin(
|
|
|
|
|
storageDir: string,
|
|
|
|
|
options: { writeDelayMs?: number; failFlush?: boolean } = {},
|
|
|
|
|
): Promise<ApplyHarness> {
|
2026-07-11 14:08:12 +08:00
|
|
|
const ctx = new Context()
|
2026-07-14 19:50:25 +08:00
|
|
|
await ctx.plugin(agentCore, { workspaceContext: false })
|
2026-07-11 14:08:12 +08:00
|
|
|
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 50))
|
|
|
|
|
|
|
|
|
|
const input = new PassThrough()
|
|
|
|
|
const events: WireEvent[] = []
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
const outputErrors: Error[] = []
|
2026-07-11 14:08:12 +08:00
|
|
|
let pendingOutput = ''
|
2026-07-14 00:40:36 +08:00
|
|
|
// Record frame admission separately from write completion so delayed output
|
|
|
|
|
// tests the flush barrier.
|
2026-07-11 14:08:12 +08:00
|
|
|
const output = new Writable({
|
|
|
|
|
write(chunk: Buffer, _encoding, callback) {
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
const ids: (string | number)[] = []
|
2026-07-11 14:08:12 +08:00
|
|
|
pendingOutput += chunk.toString('utf8')
|
|
|
|
|
for (;;) {
|
|
|
|
|
const newline = pendingOutput.indexOf('\n')
|
|
|
|
|
if (newline < 0) break
|
|
|
|
|
const line = pendingOutput.slice(0, newline).trim()
|
|
|
|
|
pendingOutput = pendingOutput.slice(newline + 1)
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
if (line) {
|
|
|
|
|
const frame = JSON.parse(line) as Record<string, unknown>
|
|
|
|
|
events.push({ kind: 'frame', frame })
|
|
|
|
|
if (typeof frame.id === 'string' || typeof frame.id === 'number') ids.push(frame.id)
|
|
|
|
|
}
|
2026-07-11 14:08:12 +08:00
|
|
|
}
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
const complete = (): void => {
|
|
|
|
|
if (options.failFlush === true && chunk.length === 0) {
|
|
|
|
|
callback(new Error('flush callback failed'))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
events.push({ kind: 'write-complete', ids })
|
|
|
|
|
callback()
|
|
|
|
|
}
|
|
|
|
|
if ((options.writeDelayMs ?? 0) > 0) setTimeout(complete, options.writeDelayMs)
|
|
|
|
|
else complete()
|
2026-07-11 14:08:12 +08:00
|
|
|
},
|
|
|
|
|
})
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
output.on('error', (error: Error) => { outputErrors.push(error) })
|
2026-07-11 14:08:12 +08:00
|
|
|
const exit = (code: number): void => { events.push({ kind: 'exit', code }) }
|
|
|
|
|
|
|
|
|
|
const fiber = await ctx.plugin(jsonrpc, { input, output, exit })
|
|
|
|
|
|
|
|
|
|
const frames = (): Record<string, unknown>[] =>
|
|
|
|
|
events.flatMap(event => event.kind === 'frame' ? [event.frame] : [])
|
|
|
|
|
return {
|
|
|
|
|
ctx,
|
|
|
|
|
fiber,
|
|
|
|
|
events,
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
outputErrors,
|
2026-07-11 14:08:12 +08:00
|
|
|
send: (frame) => { input.write(`${JSON.stringify(frame)}\n`) },
|
|
|
|
|
sendRaw: (text) => { input.write(text) },
|
|
|
|
|
frames,
|
|
|
|
|
exits: () => events.flatMap(event => event.kind === 'exit' ? [event.code] : []),
|
|
|
|
|
waitForFrame: (predicate, description) => waitFor(() => frames().find(predicate), description),
|
|
|
|
|
dispose: async () => { await ctx.fiber.dispose() },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const servers: Server[] = []
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
|
|
|
|
vi.unstubAllEnvs()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
/** Keyless SSE endpoint for completing a prompt turn. */
|
2026-07-11 14:08:12 +08:00
|
|
|
async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> {
|
|
|
|
|
const requests: unknown[] = []
|
|
|
|
|
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
|
|
|
|
let body = ''
|
|
|
|
|
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
|
|
|
|
request.on('end', () => {
|
|
|
|
|
requests.push(JSON.parse(body))
|
|
|
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
|
|
|
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
|
|
|
|
|
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
|
|
|
|
|
response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
|
|
|
|
|
response.write('data: [DONE]\n\n')
|
|
|
|
|
response.end()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
servers.push(server)
|
|
|
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
|
|
|
const address = server.address()
|
|
|
|
|
if (address === null || typeof address === 'string') throw new Error('no port')
|
|
|
|
|
return { url: `http://127.0.0.1:${address.port}`, requests }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('dsh-jsonrpc plugin apply', () => {
|
|
|
|
|
it('serves initialize over the injected stdio pair', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-init-'))
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
|
|
|
const harness = await mountPlugin(storageDir)
|
|
|
|
|
try {
|
2026-07-14 21:57:52 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'apply-model' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
|
|
|
|
|
expect(response).toEqual({
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: 'init-1',
|
|
|
|
|
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } },
|
|
|
|
|
})
|
|
|
|
|
expect(harness.exits()).toEqual([])
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-'))
|
|
|
|
|
const llmServer = await mockCompletionServer()
|
|
|
|
|
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
|
|
|
|
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
|
|
|
|
|
const harness = await mountPlugin(storageDir)
|
|
|
|
|
try {
|
2026-07-14 21:57:52 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'dsagent-model' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
|
|
|
|
|
|
|
|
|
|
harness.send({
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
id: 2,
|
|
|
|
|
method: 'session/prompt',
|
|
|
|
|
params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] },
|
|
|
|
|
})
|
|
|
|
|
const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response')
|
|
|
|
|
expect(response.result).toEqual({ accepted: true })
|
|
|
|
|
|
|
|
|
|
expect(llmServer.requests).toHaveLength(1)
|
|
|
|
|
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
|
|
|
|
|
expect(body.model).toBe('dsagent-model')
|
|
|
|
|
expect(body.messages.at(-1)?.role).toBe('user')
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
// Notifications use the same transport and arrive as id-less frames.
|
2026-07-11 14:08:12 +08:00
|
|
|
const notifications = harness.frames().filter(frame => frame.id === undefined)
|
|
|
|
|
expect(notifications.some(frame => frame.method === 'session.event')).toBe(true)
|
|
|
|
|
expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({
|
|
|
|
|
jsonrpc: '2.0',
|
|
|
|
|
params: { sessionId: 'main', status: 'ok' },
|
|
|
|
|
})
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-'))
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
const harness = await mountPlugin(storageDir, { writeDelayMs: 10 })
|
2026-07-11 14:08:12 +08:00
|
|
|
try {
|
2026-07-14 00:40:36 +08:00
|
|
|
// One chunk makes the two deferred exit callbacks race.
|
2026-07-11 14:08:12 +08:00
|
|
|
const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' }
|
|
|
|
|
const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' }
|
|
|
|
|
harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call')
|
|
|
|
|
expect(harness.exits()).toEqual([0])
|
|
|
|
|
|
2026-07-14 00:40:36 +08:00
|
|
|
// Both response writes and the flush barrier complete before exit.
|
2026-07-11 14:08:12 +08:00
|
|
|
const exitIndex = harness.events.findIndex(event => event.kind === 'exit')
|
|
|
|
|
const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1')
|
|
|
|
|
const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2')
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1'))
|
|
|
|
|
const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2'))
|
|
|
|
|
const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0)
|
2026-07-11 14:08:12 +08:00
|
|
|
expect(firstResponse).toBeGreaterThanOrEqual(0)
|
|
|
|
|
expect(secondResponse).toBeGreaterThanOrEqual(0)
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
expect(firstComplete).toBeGreaterThan(firstResponse)
|
|
|
|
|
expect(secondComplete).toBeGreaterThan(secondResponse)
|
|
|
|
|
expect(flushComplete).toBeGreaterThan(firstComplete)
|
|
|
|
|
expect(flushComplete).toBeGreaterThan(secondComplete)
|
|
|
|
|
expect(exitIndex).toBeGreaterThan(flushComplete)
|
2026-07-11 14:08:12 +08:00
|
|
|
|
|
|
|
|
await settle()
|
|
|
|
|
expect(harness.exits()).toEqual([0])
|
|
|
|
|
|
|
|
|
|
const before = harness.frames().length
|
2026-07-14 21:57:52 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
await settle()
|
|
|
|
|
expect(harness.frames().length).toBe(before)
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
it('still disposes and exits once when the flush callback fails', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-flush-failure-'))
|
|
|
|
|
const harness = await mountPlugin(storageDir, { failFlush: true })
|
|
|
|
|
try {
|
|
|
|
|
harness.send({ jsonrpc: '2.0', id: 'sd-fail', method: 'shutdown' })
|
|
|
|
|
|
|
|
|
|
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure')
|
|
|
|
|
await settle()
|
|
|
|
|
expect(harness.exits()).toEqual([0])
|
|
|
|
|
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
|
|
|
|
|
|
|
|
|
|
const before = harness.frames().length
|
2026-07-14 21:57:52 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
fix(sdk): harden runtime lifecycle and JSON-RPC
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.
2026-07-13 20:53:20 +08:00
|
|
|
await settle()
|
|
|
|
|
expect(harness.frames().length).toBe(before)
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-11 14:08:12 +08:00
|
|
|
it('stops serving on a bare fiber dispose (HMR-style unload) without calling exit', async () => {
|
|
|
|
|
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-'))
|
|
|
|
|
const harness = await mountPlugin(storageDir)
|
|
|
|
|
try {
|
2026-07-14 00:40:36 +08:00
|
|
|
// Prove the handler-rejection path is live before disposal.
|
2026-07-11 14:08:12 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' })
|
|
|
|
|
const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method')
|
|
|
|
|
expect(error.error).toMatchObject({
|
|
|
|
|
code: -32603,
|
|
|
|
|
message: 'unknown DeepSeek Harness SDK runtime method: nope/unknown',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await harness.fiber.dispose()
|
|
|
|
|
|
|
|
|
|
const before = harness.frames().length
|
2026-07-14 21:57:52 +08:00
|
|
|
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
2026-07-11 14:08:12 +08:00
|
|
|
await settle()
|
|
|
|
|
expect(harness.frames().length).toBe(before)
|
|
|
|
|
expect(harness.exits()).toEqual([])
|
|
|
|
|
} finally {
|
|
|
|
|
await harness.dispose()
|
|
|
|
|
await rm(storageDir, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|