mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(ptc-runtime-node): fail fast on mismatched bootstraps and late replies
- Reject a control line that does not start with "{", so another
release's length-prefixed frame fails as a protocol error instead of
waiting for a newline until the deadline.
- Bump SSH_PROTOCOL_VERSION to 3: executable helpers embed the PTC
worker, so an older helper is now rejected while connecting.
- Send a small error instead of a late binding result from a finished
persistent evaluation, so it cannot overflow the pipe shared with
the next evaluation.
- Leave per-call binding waits pending after the result instead of
rejecting them; unawaited calls crashed the child and leaked the
trace into the result logs.
- Pin birpc to 4.2.0 so the host and the bundled child agree, and
regenerate THIRD_PARTY_NOTICES.md.
- Update stale frame wording.
This commit is contained in:
1 parent
82b23cf4d2
commit
427d62d4a7
28 files changed
+105
-48
No files matched your search
@@ -78,6 +78,7 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`ajv`](https://github.com/ajv-validator/ajv) | MIT |
|
||||
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
|
||||
| [`big.js`](https://github.com/MikeMcl/big.js) | MIT |
|
||||
| [`birpc`](https://github.com/antfu-collective/birpc) | MIT |
|
||||
| [`buffer`](https://github.com/feross/buffer) | MIT |
|
||||
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
|
||||
| [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause |
|
||||
|
||||
@@ -2604,7 +2604,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Maximum control frame, outstanding argument and queued control-output bytes. */
|
||||
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
|
||||
maxMessageBytes?: number
|
||||
/** Maximum simultaneous host binding calls accepted from a program. */
|
||||
maxPendingCalls?: number
|
||||
|
||||
@@ -2606,7 +2606,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Maximum control frame, outstanding argument and queued control-output bytes. */
|
||||
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
|
||||
maxMessageBytes?: number
|
||||
/** Maximum simultaneous host binding calls accepted from a program. */
|
||||
maxPendingCalls?: number
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 246231cf2a529550
|
||||
zh: 5d27b8e444ecd0f7
|
||||
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/configuration:
|
||||
en: 161f3b32c782bab4
|
||||
zh: bd923e3d31261032
|
||||
en: 7e332cfe9cf45896
|
||||
zh: 82cfb08dce3425ab
|
||||
/deepseek-ai-dsh-ptc-runtime-node/use-this-package/execution-and-results:
|
||||
en: c79baab0c0a49ca7
|
||||
zh: b90b78d0d75280fb
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 9020b491a39e4bf5
|
||||
zh: bcd1b2b1a3bde7f3
|
||||
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control:
|
||||
en: a11171255bbe78ec
|
||||
zh: 101a178062b7eed0
|
||||
en: cf48f246710f89d9
|
||||
zh: 423c25a365155223
|
||||
/deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps:
|
||||
en: f9d796233bfb3b79
|
||||
zh: f7c5803a6ccdf9c2
|
||||
@@ -54,8 +54,8 @@
|
||||
en: 40294c1c31b74824
|
||||
zh: d91e7b78c0619c7e
|
||||
/deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work:
|
||||
en: 7b8d67207e35bbb3
|
||||
zh: 246cd770e79078ac
|
||||
en: c99f20872d7694d0
|
||||
zh: b3a3193572afd8f3
|
||||
/deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work/dev-note:
|
||||
en: 6373aba4e08f7205
|
||||
zh: 2a8c33d1f2839df4
|
||||
@@ -51,7 +51,7 @@ Configure the provider row after its required services are available:
|
||||
| `maxTimeoutMs` | `600,000` | Elapsed deadline ceiling applied by the resolver |
|
||||
| `maxOutputBytes` | `67,108,864` | Combined serialized logs and completion or diagnostic budget |
|
||||
| `maxOldGenerationSizeMb` | `512` | V8 old-generation heap limit in MiB |
|
||||
| `maxMessageBytes` | `134,217,728` | Limit for a control frame, outstanding argument bytes and queued control writes |
|
||||
| `maxMessageBytes` | `134,217,728` | Limit for a control message, outstanding binding-argument bytes and queued control writes |
|
||||
| `maxPendingCalls` | `128` | Maximum simultaneous host binding calls |
|
||||
| `graceMs` | `3,000` | Managed termination and output-drain grace |
|
||||
| `launch` | Local carrier | Explicit `{ kind: "node-script", executable, bootstrapPath? }` or `{ kind: "embedded", executable }` in the subprocess world |
|
||||
@@ -111,7 +111,7 @@ For per-call execution, the host strips erasable types, resolves the executable
|
||||
|
||||
The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds incomplete messages and the native write queue, validates UTF-8 and exposed methods, and rejects pending calls when the pipe closes. The host validates binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval.
|
||||
|
||||
Per-call completion rejects unfinished binding waits and stops captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
|
||||
Per-call completion leaves unfinished binding waits pending and stops binding calls and captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger.
|
||||
|
||||
### Source and built bootstraps
|
||||
|
||||
@@ -171,6 +171,7 @@ These limits qualify the execution guarantees and retained output.
|
||||
- **In-memory environments retain one process per live owner** — idle owners keep their process and heap until explicit release, owner disposal or failure. Fan-out can retain many processes; idle expiration remains deferred.
|
||||
- **Output caps reject rather than retain every byte** — spill can preserve only the bounded result delivered by this provider.
|
||||
- **Bindings are bounded at transport admission** — control limits do not bound the memory a host binding allocates while producing its result.
|
||||
- **The control protocol has no version handshake** — a preinstalled `launch.bootstrapPath` or SSH helper must come from the client's release; another release's bootstrap fails at its first control message.
|
||||
- **The console shim has five methods** — `log`, `info`, `warn`, `error` and `debug`.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -51,7 +51,7 @@ kind: "package-reference"
|
||||
| `maxTimeoutMs` | `600,000` | 解析器应用的经过时间截止上限 |
|
||||
| `maxOutputBytes` | `67,108,864` | 序列化日志与完成值或诊断的合计预算 |
|
||||
| `maxOldGenerationSizeMb` | `512` | V8 老生代堆上限,单位 MiB |
|
||||
| `maxMessageBytes` | `134,217,728` | 控制帧、未完成参数字节和排队控制写入的上限 |
|
||||
| `maxMessageBytes` | `134,217,728` | 控制消息、未完成绑定参数字节和排队控制写入的上限 |
|
||||
| `maxPendingCalls` | `128` | 同时进行的 Host 绑定调用数量上限 |
|
||||
| `graceMs` | `3,000` | 受管终止与输出排空宽限时间 |
|
||||
| `launch` | 本地载体 | 子进程环境中显式的 `{ kind: "node-script", executable, bootstrapPath? }` 或 `{ kind: "embedded", executable }` |
|
||||
@@ -111,7 +111,7 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与
|
||||
|
||||
Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。
|
||||
|
||||
单次执行完成后会拒绝尚未完成的绑定等待,并停止捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
|
||||
单次执行完成后,尚未完成的绑定等待保持挂起,并停止绑定调用和捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。
|
||||
|
||||
### 源代码与构建后 bootstrap
|
||||
|
||||
@@ -171,6 +171,7 @@ Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host
|
||||
- **内存保留环境为每个存活所有者保留一个进程**——空闲所有者保留进程及堆内存,直到显式释放、所有者销毁或执行失败。并行创建多个所有者会保留多个进程;空闲过期机制留待后续处理。
|
||||
- **输出上限拒绝超量内容,而不保留每个字节**——溢出只能保存本提供方交付的有界结果。
|
||||
- **绑定在传输接纳时受限**——控制限制不约束 Host 绑定生成结果期间分配的内存。
|
||||
- **控制协议没有版本握手**——预装的 `launch.bootstrapPath` 或 SSH helper 必须来自与客户端相同的版本;其他版本的 bootstrap 会在第一条控制消息时失败。
|
||||
- **console shim 有五个方法**——`log`、`info`、`warn`、`error` 与 `debug`。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"birpc": "^4.2.0"
|
||||
"birpc": "4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
|
||||
@@ -281,9 +281,8 @@ export function makeNamespaces(
|
||||
const errorClass = errorClasses.get(global)
|
||||
try {
|
||||
let detached: ReturnType<typeof snapshotPtcJsonValue>
|
||||
try { detached = snapshotPtcJsonValue(args) } catch (error: unknown) {
|
||||
try { detached = snapshotPtcJsonValue(args) } catch {
|
||||
// Model-defined getters can fail while arguments are detached.
|
||||
void error
|
||||
detached = undefined
|
||||
}
|
||||
if (detached === undefined) throw new CapturedError('binding arguments must be lossless JSON')
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface Config {
|
||||
maxOutputBytes?: number
|
||||
/** V8 old-generation heap limit in MiB; native allocations are excluded. */
|
||||
maxOldGenerationSizeMb?: number
|
||||
/** Maximum control frame, outstanding argument and queued control-output bytes. */
|
||||
/** Maximum control message, outstanding binding-argument and queued control-output bytes. */
|
||||
maxMessageBytes?: number
|
||||
/** Maximum simultaneous host binding calls accepted from a program. */
|
||||
maxPendingCalls?: number
|
||||
|
||||
@@ -321,7 +321,7 @@ export class NodePtcRuntime extends PtcRuntime {
|
||||
})
|
||||
channel = transport
|
||||
void launched.done.then((outcome) => {
|
||||
// Allow queued control-frame callbacks to run before classifying a command exit.
|
||||
// Allow queued control messages to run before classifying a command exit.
|
||||
setImmediate(() => { processFinished(outcome) })
|
||||
}, (error: unknown) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) })
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Lossless-JSON snapshots for the dependency-free source bootstrap closure.
|
||||
* Lossless-JSON snapshots shared by the host and the source bootstrap closure.
|
||||
* @module @deepseek-ai/dsh-ptc-runtime-node/json-wire
|
||||
*/
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export function resolveLaunch(config?: LaunchConfig): LaunchConfig {
|
||||
* Select explicit arguments without inheriting host loader or inspector flags.
|
||||
* @param fs - Filesystem mapping host bootstrap assets into the process world.
|
||||
* @param config - resolved worker invocation.
|
||||
* @param maxMessageBytes - Validated frame and queued-write limit.
|
||||
* @param maxMessageBytes - Validated message and queued-write limit.
|
||||
* @param persistent - Select the in-memory REPL bootstrap instead of the one-shot evaluator.
|
||||
* @returns Arguments following the resolved Node executable.
|
||||
*/
|
||||
|
||||
@@ -247,7 +247,12 @@ class Kernel {
|
||||
const fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined
|
||||
evaluation.pending += 1
|
||||
evaluation.pendingBytes += bytes
|
||||
try { return await evaluation.invoke(fn, args) } finally {
|
||||
try {
|
||||
const value = await evaluation.invoke(fn, args)
|
||||
// The child already rejected this wait; a late result must not fill the pipe shared with the next evaluation.
|
||||
if (this.active !== evaluation) throw new Error('PTC evaluation ended before the binding completed')
|
||||
return value
|
||||
} finally {
|
||||
evaluation.pending -= 1
|
||||
evaluation.pendingBytes -= bytes
|
||||
}
|
||||
@@ -259,7 +264,7 @@ class Kernel {
|
||||
void handle.done.then(async (outcome) => {
|
||||
this.exitOutcome = outcome
|
||||
await Promise.all([drainOutput(handle.stdout, this.config.graceMs), drainOutput(handle.stderr, this.config.graceMs)])
|
||||
// The terminal control frame can already be queued when process exit arrives.
|
||||
// The run result can already be queued when process exit arrives.
|
||||
setImmediate(() => {
|
||||
if (!this.closed) this.fail(this.confined !== undefined && classifyRunnerFailure(outcome.exitCode, this.stderr, this.confined.runnerFailureRules) !== undefined ? 'sandbox-unavailable' : 'worker-exit', this.exitMessage(outcome))
|
||||
})
|
||||
|
||||
@@ -25,20 +25,16 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce
|
||||
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit')
|
||||
clearProgramEnvironment(processState)
|
||||
const hostClosed = Promise.withResolvers<void>()
|
||||
// Set once the result is returned or the endpoint closes; the child then sends nothing more.
|
||||
// Set once the result is returned or the endpoint closes; later calls stay pending and output is dropped.
|
||||
let ended = false
|
||||
const connection = createProcessRpc<HostMethods, ProgramMethods>(stream, maxMessageBytes, {
|
||||
async run(_cwd, data) {
|
||||
const result = await runProgram({
|
||||
invoke: (global, name, args) => {
|
||||
if (ended) throw new Error('PTC evaluation has ended')
|
||||
return connection.invoke(global, name, args)
|
||||
},
|
||||
invoke: (global, name, args) => ended ? new Promise(() => {}) : connection.invoke(global, name, args),
|
||||
log: (text) => { if (!ended) void connection.log(text) },
|
||||
outputLimit: () => { if (!ended) void connection.outputLimit() },
|
||||
}, data, processState)
|
||||
ended = true
|
||||
void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) })
|
||||
return result
|
||||
},
|
||||
}, ['ready', 'log', 'outputLimit'], (_error, kind) => {
|
||||
|
||||
@@ -45,8 +45,8 @@ export function createProcessRpc<Remote extends object, Local extends object>(
|
||||
else stream.write(line)
|
||||
},
|
||||
deserialize: (line: string) => {
|
||||
const message: unknown = parse(line)
|
||||
if (!record(message)) throw new Error('invalid RPC frame')
|
||||
// A line that parses after the leading-brace check is a JSON object.
|
||||
const message = parse(line) as Record<string, unknown>
|
||||
if (message.t === 'q') {
|
||||
if (typeof message.m !== 'string' || !Object.hasOwn(functions, message.m) || !Array.isArray(message.a)) throw new Error('invalid RPC method call')
|
||||
} else if (message.t !== 's') {
|
||||
@@ -68,6 +68,8 @@ export function createProcessRpc<Remote extends object, Local extends object>(
|
||||
let parts: Buffer[] = []
|
||||
let size = 0
|
||||
const buffer = (part: Buffer): void => {
|
||||
// Every message is a JSON object; another release's length-prefixed frame would otherwise wait for a newline.
|
||||
if (size === 0 && part.length > 0 && part[0] !== 0x7b) throw new Error('control message is not a JSON object; the Node bootstrap may be from another release')
|
||||
size += part.length
|
||||
if (size > maxMessageBytes) throw new Error(`RPC message exceeds ${maxMessageBytes} bytes`)
|
||||
parts.push(part)
|
||||
|
||||
@@ -299,6 +299,20 @@ describe('Node runtime host failures', () => {
|
||||
expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' })
|
||||
})
|
||||
|
||||
it('fails at once when a bootstrap from another release sends a length-prefixed frame', async () => {
|
||||
const h = await setup()
|
||||
const body = Buffer.from(JSON.stringify({ type: 'ready' }))
|
||||
const header = Buffer.alloc(4)
|
||||
header.writeUInt32BE(body.length)
|
||||
h.spawn.mockImplementation(() => {
|
||||
queueMicrotask(() => { h.control.push(Buffer.concat([header, body])) })
|
||||
return h.handle
|
||||
})
|
||||
expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({
|
||||
kind: 'protocol', message: 'control message is not a JSON object; the Node bootstrap may be from another release',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.control.push(Buffer.from([0xff, 10])) })
|
||||
@@ -306,7 +320,7 @@ describe('Node runtime host failures', () => {
|
||||
})
|
||||
|
||||
it.each<[unknown, string]>([
|
||||
[null, 'invalid RPC frame'],
|
||||
[null, 'not a JSON object'],
|
||||
[{ t: 'q', m: 'ready', a: [] }, 'duplicate program readiness'],
|
||||
[{ t: 'q', m: 'log', a: [1] }, 'invalid program log'],
|
||||
[{ t: 'q', m: 'invoke', a: [null, 'test', []], i: 'call' }, 'undeclared binding'],
|
||||
|
||||
@@ -345,6 +345,24 @@ describe('persistent PTC host protocol', () => {
|
||||
expect(result.error?.message).toContain('fatal output')
|
||||
})
|
||||
|
||||
it('does not send a late binding result into the next evaluation', async () => {
|
||||
const h = await setup({ maxMessageBytes: 1024 })
|
||||
const release = Promise.withResolvers<string>()
|
||||
onTestFinished(() => { release.resolve('') })
|
||||
const slow = { ...request, bindings: [{ global: 'tools', functions: { test: () => release.promise } }] }
|
||||
h.onBoot(() => { h.emit(call()); h.complete({}) })
|
||||
expect((await h.start(slow)).error).toBeUndefined()
|
||||
const booted = Promise.withResolvers<undefined>()
|
||||
h.onBoot(() => { booted.resolve(undefined) })
|
||||
const next = h.start(slow, NO_INITIAL_FRAME)
|
||||
await booted.promise
|
||||
release.resolve('x'.repeat(2000))
|
||||
await setImmediate()
|
||||
h.complete({ value: encodePtcJsonWire(2) })
|
||||
expect(await next).toMatchObject({ value: 2 })
|
||||
expect(h.messages).toContainEqual({ t: 's', i: '1', e: { message: 'PTC evaluation ended before the binding completed' } })
|
||||
})
|
||||
|
||||
it('terminates the process when the control pipe fails during an evaluation', async () => {
|
||||
const h = await setup()
|
||||
h.onBoot(() => { h.control.emit('error', new Error('control transport failed')) })
|
||||
|
||||
@@ -49,6 +49,14 @@ describe('persistent Node REPL', () => {
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the environment when an evaluation ends with an unawaited binding call', async () => {
|
||||
const { run } = await setup()
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
expect((await run('var kept = 1; void tools.value({}); kept', { bindings: bindings(() => release.promise) })).value).toBe(1)
|
||||
expect((await run('kept')).value).toBe(1)
|
||||
})
|
||||
|
||||
it('retains declarations, closures, object identity, and imports across evaluations', async () => {
|
||||
const { run, cwd } = await setup()
|
||||
expect(await run('const box: { count: number } = { count: 1 }; let alias = box; function increment() { return ++box.count }; const path = await import("node:path");')).toMatchObject({ logs: [] })
|
||||
|
||||
@@ -55,12 +55,12 @@ it('clears the environment and returns a binding result after captured output',
|
||||
expect(h.state.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects unfinished calls and suppresses later logs while awaiting host shutdown', async () => {
|
||||
it('keeps unfinished calls pending and suppresses later logs while awaiting host shutdown', async () => {
|
||||
const h = await setup()
|
||||
const release = Promise.withResolvers<ReturnType<typeof encodePtcJsonWire>>()
|
||||
h.methods.invoke = () => release.promise
|
||||
try {
|
||||
expect(decodePtcJsonWire((await h.run('void tools.echo({}).catch(() => console.log("late rejection")); setImmediate(() => console.log("late timer")); return 42;')).value)).toBe(42)
|
||||
expect(decodePtcJsonWire((await h.run('void tools.echo({}); void tools.echo({}).catch(() => console.log("late rejection")); setImmediate(() => console.log("late timer")); return 42;')).value)).toBe(42)
|
||||
await nextTurn()
|
||||
expect(h.child.destroyed).toBe(false)
|
||||
release.resolve(encodePtcJsonWire(42))
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { Duplex } from 'node:stream'
|
||||
export function rawPeer(stream: Duplex, receive: (message: unknown) => void) {
|
||||
const lines = createInterface({ input: stream, crlfDelay: Infinity })
|
||||
lines.on('line', (line) => { receive(JSON.parse(line)) })
|
||||
// The endpoint under test can destroy the shared stream pair; it reports that failure itself.
|
||||
lines.on('error', (error: Error) => { void error })
|
||||
return {
|
||||
send: (message: unknown): Promise<void> => new Promise((resolve, reject) => {
|
||||
stream.write(`${JSON.stringify(message)}\n`, (error) => { if (error) reject(error); else resolve() })
|
||||
|
||||
@@ -49,7 +49,7 @@ it('does not invoke a method received beside traffic that closed the endpoint',
|
||||
const allowed = vi.fn()
|
||||
const endpoint = createProcessRpc(local, 4096, { allowed }, [], (_error, kind) => { failure.resolve(kind) })
|
||||
onTestFinished(() => { endpoint.$close() })
|
||||
remote.write(`null\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`)
|
||||
remote.write(`${JSON.stringify({ t: 'unknown' })}\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`)
|
||||
expect(await failure.promise).toBe('protocol')
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(allowed).not.toHaveBeenCalled()
|
||||
@@ -137,7 +137,8 @@ it.each([
|
||||
[Buffer.from([0xff, 10]), false],
|
||||
[Buffer.from('{'), true],
|
||||
[Buffer.from([0xe2]), true],
|
||||
[Buffer.from('x'.repeat(65)), false],
|
||||
[Buffer.from(`{${'x'.repeat(64)}`), false],
|
||||
[Buffer.from([0, 0, 0, 2, 123, 125]), false],
|
||||
])('rejects invalid or oversized JSON lines before dispatch (%j, EOF %s)', async (bytes, eof) => {
|
||||
const { local, remote } = endpoints()
|
||||
const failed = Promise.withResolvers<string>()
|
||||
|
||||
@@ -103,6 +103,15 @@ describe('Node program process', () => {
|
||||
expect(result.value).toEqual({ value: { n: 42 }, rejected: [true, 'ToolCallError', 'fail', 'denied'] })
|
||||
})
|
||||
|
||||
it('returns cleanly while an unawaited binding call is still running', async () => {
|
||||
const { run } = await setup()
|
||||
const release = Promise.withResolvers<null>()
|
||||
onTestFinished(() => { release.resolve(null) })
|
||||
const result = await run({ program: 'void tools.slow({}); return 1', bindings: bindings({ slow: () => release.promise }) })
|
||||
expect(result).toMatchObject({ logs: [], value: 1 })
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retains raw native stdout and stderr separately from control frames', async () => {
|
||||
const { run } = await setup()
|
||||
const result = await run({ program: 'const fs = await import("node:fs"); fs.writeSync(1,"native-out你好"); fs.writeSync(2,"native-err🙂"); return 42;', bindings: [] })
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { Readable, Writable } from 'node:stream'
|
||||
import { z } from 'zod'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Wire version shared by the installed helper and client package. */
|
||||
export const SSH_PROTOCOL_VERSION = 2
|
||||
/** Wire version shared by the client package, the installed helper and its embedded PTC worker. */
|
||||
export const SSH_PROTOCOL_VERSION = 3
|
||||
|
||||
/** Maximum prepared or running process handles owned by one helper. */
|
||||
export const SSH_MAX_PROCESS_HANDLES = 128
|
||||
|
||||
@@ -68,7 +68,7 @@ async function setup(phase: 'connect' | 'authenticate', pauseControl?: 'forward'
|
||||
if (method === 'heartbeat') { heartbeatCalls++; return holdHeartbeat ? heartbeat.promise : null }
|
||||
if (method === 'ordinary') { ordinaryEntered.resolve(undefined); return ordinary.promise }
|
||||
if (method !== 'hello') throw new Error(`unexpected helper operation: ${method}`)
|
||||
return { protocol: 2, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' }
|
||||
return { protocol: 3, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' }
|
||||
})
|
||||
const rawSocket = new PendingSocket()
|
||||
const secureSocket = new PendingSocket()
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export async function createHelperHarness(handshake = true, leaseMs = 30_000, ki
|
||||
void serving.catch(() => {})
|
||||
const client = new SshRpcPeer(output, input, 64 * 1024 * 1024, 128)
|
||||
const sockets = new Set<Socket>()
|
||||
const hello = () => client.request('hello', { protocol: 2, workspace: root, leaseMs }, helloSchema)
|
||||
const hello = () => client.request('hello', { protocol: 3, workspace: root, leaseMs }, helloSchema)
|
||||
let closing: Promise<void> | undefined
|
||||
const close = () => {
|
||||
closing ??= (async () => {
|
||||
|
||||
@@ -49,13 +49,13 @@ describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bou
|
||||
const test = await createHelperHarness(false)
|
||||
try {
|
||||
for (const params of [
|
||||
{ protocol: 1, workspace: test.root, leaseMs: 3000 },
|
||||
{ protocol: 2, workspace: 'relative', leaseMs: 3000 },
|
||||
{ protocol: 2, workspace: test.root, leaseMs: 2999 },
|
||||
{ protocol: 2, workspace: test.root, leaseMs: 3000 },
|
||||
{ protocol: 3, workspace: 'relative', leaseMs: 3000 },
|
||||
{ protocol: 3, workspace: test.root, leaseMs: 2999 },
|
||||
]) await expect(test.client.request('hello', params, helloSchema)).rejects.toThrow()
|
||||
const bootstrapPath = `${test.root}/bootstrap.js`
|
||||
await writeFile(bootstrapPath, 'export const identity = "test"\n')
|
||||
const facts = await test.client.request('hello', { protocol: 2, workspace: test.root, leaseMs: 3000, bootstrapPath }, helloSchema)
|
||||
const facts = await test.client.request('hello', { protocol: 3, workspace: test.root, leaseMs: 3000, bootstrapPath }, helloSchema)
|
||||
expect(facts.bootstrapHash).toBe(createHash('sha256').update(await readFile(bootstrapPath)).digest('hex'))
|
||||
} finally { await test.close() }
|
||||
})
|
||||
@@ -63,7 +63,7 @@ describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bou
|
||||
it('refuses an external bootstrap for an executable helper', async () => {
|
||||
const test = await createHelperHarness(false, 30_000, 'executable')
|
||||
try {
|
||||
await expect(test.client.request('hello', { protocol: 2, workspace: test.root, leaseMs: 3000, bootstrapPath: '/external/process.js' }, helloSchema))
|
||||
await expect(test.client.request('hello', { protocol: 3, workspace: test.root, leaseMs: 3000, bootstrapPath: '/external/process.js' }, helloSchema))
|
||||
.rejects.toThrow('embedded PTC bootstrap')
|
||||
expect((await test.hello()).kind).toBe('executable')
|
||||
} finally { await test.close() }
|
||||
|
||||
@@ -56,7 +56,7 @@ const config: Config = {
|
||||
requestTimeoutMs: 1000, maxFrameBytes: 4096, maxPending: 8, leaseMs: 30_000,
|
||||
}
|
||||
const hello = {
|
||||
protocol: 2, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0',
|
||||
protocol: 3, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0',
|
||||
kind: 'node-script', executable: '/canonical/node', root: '/tmp/remote-helper', workspace: '/canonical/workspace',
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ describe.skipIf(process.platform === 'win32')('SSH connection startup', () => {
|
||||
expect(argv).toContain('StrictHostKeyChecking=yes')
|
||||
expect(argv).toContain('ForwardAgent=no')
|
||||
expect(argv.at(-1)).toBe("'/remote/node' '--disable-sigusr1' '/remote/helper'\\''s file.js'")
|
||||
expect(test.calls[0]?.params).toEqual({ protocol: 2, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' })
|
||||
expect(test.calls[0]?.params).toEqual({ protocol: 3, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' })
|
||||
})
|
||||
|
||||
it('permits filesystem-only deployments and refuses an unconfigured PTC bootstrap getter', async () => {
|
||||
@@ -179,13 +179,13 @@ describe.skipIf(process.platform === 'win32')('SSH connection startup', () => {
|
||||
expect(test.service.ptcLaunch).toEqual({ kind: 'embedded', executable: '/canonical/dsh-ssh-helper' })
|
||||
const argv = transport.spawn.mock.calls[0]?.[1] as string[]
|
||||
expect(argv.at(-1)).toBe("NODE_OPTIONS='--disable-sigusr1' '/remote/helper with spaces'")
|
||||
expect(test.calls[0]?.params).toEqual({ protocol: 2, workspace: '/remote/workspace', leaseMs: 30_000 })
|
||||
expect(test.calls[0]?.params).toEqual({ protocol: 3, workspace: '/remote/workspace', leaseMs: 30_000 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ hello: { hash: 'c'.repeat(64) }, error: 'helper digest' },
|
||||
{ hello: { bootstrapHash: 'c'.repeat(64) }, error: 'bootstrap digest' },
|
||||
{ hello: { protocol: 1 }, error: 'Invalid' },
|
||||
{ hello: { protocol: 2 }, error: 'Invalid' },
|
||||
{ hello: { kind: 'executable' }, error: 'launch kind' },
|
||||
])('refuses a mismatched helper identity before readiness: $error', async ({ hello, error }) => {
|
||||
const test = setup({ hello })
|
||||
|
||||
Generated
+1
-1
@@ -12088,7 +12088,7 @@ importers:
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
birpc:
|
||||
specifier: ^4.2.0
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
|
||||
Reference in new issue
Block a user