fix(jsonrpc): drain runtime before protocol exit

This commit is contained in:
_Kerman
2026-07-30 18:27:36 +08:00
parent 349cf35135
commit 250dcfd1ce
9 changed files with 29 additions and 21 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
2026-07-10-single-file-executable-sdk-runtime-distribution.md: f749d6a72b4c32a189a9f848595076457819d9b9
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2b511573bc68e5378279cec8d22ce960af0966e9
2026-07-10-single-file-executable-sdk-runtime-distribution.md: f3da981c478ef08672a82f23ab9cd42e0f38ebab
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 7db7efde7e555985a541b14a678d550fe5891159

View File

@@ -27,7 +27,7 @@ Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's t
The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin:
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process).
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130).
Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic.

View File

@@ -27,7 +27,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)vercel/pkg 归档后
确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件:
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`HMR 式卸载只停止服务,不退出进程)。
- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并刷新 `shutdown` 响应后 dispose 根运行时以排空持久化,再调用 `exit(0)`HMR 式卸载只停止服务,不退出进程)。
- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)`@deepseek-ai/dsh-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()``boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有stdin EOF/SIGTERM → dispose 后返回 0SIGINT → 130
配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——“实际启动的插件由外部 `cordis.yml` 决定”是硬语义。

View File

@@ -218,16 +218,15 @@ describe('dsh-subagent-dsh-sdk provider', () => {
}
})
it('keeps accumulated streamed text when the turn is cut short before a full message', async () => {
// The fake streams one text-delta chunk and then violates the protocol on
// the same pipe; frame order guarantees the chunk was dispatched before
// the failure settles, so the accumulated partial text (no complete
// assistant/message ever arrived) must survive into the error result.
it('does not attribute streamed text when prompt acceptance is malformed', async () => {
// The fake streams one text-delta chunk but never returns the MessageId
// needed to establish this run's durable inbox receipt. The text therefore
// lies outside an owned activity interval and cannot become its output.
const ctx = await setup({ FAKE_STREAM_THEN_MALFORMED: '1' }, { shutdownTimeoutMs: 100, disposeEofGraceMs: 200, disposeGraceMs: 200 })
const run = await ctx.subagents.start('dsh-sdk', request())
const result = await run.result
expect(result.stopReason).toBe('error')
expect(text(result.output)).toBe('streamed then cut short')
expect(result.output).toEqual([])
await run.dispose()
await ctx.fiber.dispose()
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
README.md: 976c63ee4f00336cd68e30288e569d514e7ee65b
README.zh.md: e7862aaa335c3277863647e0931eb815ef2ccc1a
README.md: 7eb0336a770397df280fbaf95a6ad88243d28944
README.zh.md: 993d227d464c460d2c1a3b309542a9c59d1fd18c

View File

@@ -18,7 +18,7 @@ Stdout carries only JSON-RPC frames. The deployment must not compose a stdout lo
## Shutdown and exit semantics
The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process.
The plugin answers `shutdown`, flushes the response, disposes the root context so SDK-owned agents, subscriptions, and persistence reach quiescence, then exits with code 0. EOF and signal exits belong to the app bin, which also disposes the root context. Unloading only this plugin stops serving without exiting the process.
## Wire notes

View File

@@ -18,7 +18,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger诊断应写
## 关闭与退出语义
插件响应 `shutdown`将 SDK 持有的 agent 和订阅 dispose资源释放至完全停稳关闭传输层,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
插件响应 `shutdown`刷新响应并 dispose资源释放根上下文使 SDK 持有的 agent、订阅和持久化全部停稳,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
## 协议说明

View File

@@ -2,7 +2,7 @@
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
* whether to load it; see the single-executable Agent Note and package README.
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
* This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin
* This plugin answers `shutdown`, disposes the complete root runtime, and exits 0; the app bin
* owns EOF and signal exits. Keep named plugin exports with no default export so
* Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
*
@@ -40,14 +40,15 @@ export const Config: Schema<JsonRpcConfig> = Schema.object({
/**
* Serve SDK requests over the configured streams. Effect disposal shuts down
* SDK-created agents and closes the transport. A `shutdown` response is flushed
* before this plugin's fiber is disposed and the process exits 0; the app bin
* before the root runtime is disposed and the process exits 0; the app bin
* owns root-context disposal for EOF and signals.
*/
export function apply(ctx: Context, config: JsonRpcConfig): void {
// Cordis applies the schema default before invoking the plugin.
const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
// The later transport callback must dispose this plugin's fiber, not its ambient context.
const fiber = ctx.fiber
// Protocol shutdown owns the complete runtime process, so it must await the
// root lifecycle (including persistence) before exiting.
const rootFiber = ctx.root.fiber
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
const input = config.input ?? process.stdin
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
@@ -60,12 +61,13 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
})
// Share one exit task and attempt flush and disposal independently before exiting.
// Share one exit task so racing shutdown requests cannot dispose the root or
// exit the process more than once.
let exitTask: Promise<void> | undefined
const disposeAndExit = (): Promise<void> => {
exitTask ??= (async () => {
await Promise.allSettled([Promise.resolve().then(() => transport.flush())])
await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())])
await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())])
exit(0)
})()
return exitTask

View File

@@ -20,6 +20,7 @@ import * as jsonrpc from '../src/index.ts'
type WireEvent =
| { kind: 'frame'; frame: Record<string, unknown> }
| { kind: 'write-complete'; ids: (string | number)[] }
| { kind: 'root-disposed' }
| { kind: 'exit'; code: number }
interface ApplyHarness {
@@ -99,6 +100,7 @@ async function mountPlugin(
output.on('error', (error: Error) => { outputErrors.push(error) })
const exit = (code: number): void => { events.push({ kind: 'exit', code }) }
ctx.effect(() => () => { events.push({ kind: 'root-disposed' }) }, 'jsonrpc test root-disposal witness')
const fiber = await ctx.plugin(jsonrpc, { input, output, exit })
const frames = (): Record<string, unknown>[] =>
@@ -229,16 +231,19 @@ describe('dsh-jsonrpc plugin apply', () => {
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)
const rootDisposed = harness.events.findIndex(event => event.kind === 'root-disposed')
expect(firstResponse).toBeGreaterThanOrEqual(0)
expect(secondResponse).toBeGreaterThanOrEqual(0)
expect(firstComplete).toBeGreaterThan(firstResponse)
expect(secondComplete).toBeGreaterThan(secondResponse)
expect(flushComplete).toBeGreaterThan(firstComplete)
expect(flushComplete).toBeGreaterThan(secondComplete)
expect(exitIndex).toBeGreaterThan(flushComplete)
expect(rootDisposed).toBeGreaterThan(flushComplete)
expect(exitIndex).toBeGreaterThan(rootDisposed)
await settle()
expect(harness.exits()).toEqual([0])
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
@@ -259,6 +264,7 @@ describe('dsh-jsonrpc plugin apply', () => {
await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure')
await settle()
expect(harness.exits()).toEqual([0])
expect(harness.events.filter(event => event.kind === 'root-disposed')).toHaveLength(1)
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
const before = harness.frames().length
@@ -284,6 +290,7 @@ describe('dsh-jsonrpc plugin apply', () => {
})
await harness.fiber.dispose()
expect(harness.events.some(event => event.kind === 'root-disposed')).toBe(false)
const before = harness.frames().length
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })