Files
deepseek-harness/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts
T
_Kerman 82b23cf4d2 refactor(ptc-runtime-node): drop evaluation ids from the control protocol
One pipe delivers each evaluation's messages before its result, and the
child refuses calls from finished evaluations, so the per-message
evaluation id protected nothing. Remove it with the checks, counters and
helpers it required, measure outgoing messages only when posting, and
replace rethrowing validation blocks with early refusals.
2026-10-20 15:15:04 +08:00

126 lines
6.3 KiB
TypeScript

/** In-memory Node REPL with per-evaluation bindings and output attribution. */
import { AsyncLocalStorage } from 'node:async_hooks'
import { start } from 'node:repl'
import { PassThrough, Writable, type Duplex } from 'node:stream'
import { createProcessRpc } from './rpc.ts'
import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException } from './bootstrap.ts'
import type { BindingErrorConstructor, PatchableStream } from './bootstrap.ts'
import type { ProgramResult, HostMethods, ProgramMethods } from './protocol.ts'
import { clearProgramEnvironment } from './environment.ts'
/** Process state owned by the REPL child; tests supply instance-local streams and environment. */
export interface PersistentProcess {
env: NodeJS.ProcessEnv
stdout: PatchableStream
stderr: PatchableStream
chdir(directory: string): void
}
interface Evaluation {
logs: LogBuffer
maxOutputBytes: number
active: boolean
finish(result: ProgramResult): void
}
/**
* Serve sequential evaluations until the owning host closes the control channel.
* @param stream - Inherited private control endpoint.
* @param maxMessageBytes - Maximum control message and queued output bytes.
* @param processState - Child environment, output and directory control, bound only in the REPL context.
* @returns After the channel closes and the REPL releases its streams.
*/
export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, processState: PersistentProcess): Promise<void> {
if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit')
clearProgramEnvironment(processState)
const context = new AsyncLocalStorage<Evaluation>()
const closed = Promise.withResolvers<void>()
const errors = new Map<string, { name: string; memberNameProperty: string; constructor: BindingErrorConstructor }>()
const errorLookup = { get: (name: string) => errors.get(name)?.constructor }
const input = new PassThrough()
const output = new Writable({ write(chunk: Buffer, _encoding, callback) {
const current = context.getStore()
const message = chunk.toString().trim()
if (current?.active && message) current.finish(prepareException(message, current.logs.remainingOutputBytes(), current.maxOutputBytes))
callback()
} })
const repl = start({ input, output, terminal: false, prompt: '', useGlobal: false, ignoreUndefined: true })
// Interactive result setters print notices; program assignments use ordinary retained globals.
for (const name of ['_', '_error']) {
Object.defineProperty(repl.context, name, { configurable: true, enumerable: true, writable: true, value: undefined })
}
let current: Evaluation | undefined
const host: Pick<HostMethods, 'invoke'> = {
invoke: (global, name, args) => {
const evaluation = context.getStore()
if (!evaluation?.active) throw new Error('PTC evaluation has ended; call this function from a new run_code evaluation')
return connection.invoke(global, name, args)
},
}
const logSink = { push(text: string): void { const evaluation = context.getStore(); if (evaluation?.active) evaluation.logs.push(text) } }
repl.context.console = makeConsoleShim(logSink)
Object.defineProperty(repl.context, 'process', { configurable: true, enumerable: true, writable: true, value: processState })
captureStreamWrites(logSink, processState.stdout)
captureStreamWrites(logSink, processState.stderr)
const connection = createProcessRpc<HostMethods, ProgramMethods>(stream, maxMessageBytes, {
async run(cwd, data) {
const completion = Promise.withResolvers<ProgramResult>()
const logs = new LogBuffer(data.maxOutputBytes, (text) => { void connection.log(text) }, () => { void connection.outputLimit() })
const evaluation: Evaluation = {
logs, maxOutputBytes: data.maxOutputBytes, active: true,
finish(result) {
if (!evaluation.active) return
evaluation.active = false
void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) })
completion.resolve(result)
},
}
current = evaluation
context.run(evaluation, () => {
try {
processState.chdir(cwd)
for (const namespace of data.namespaces) {
const descriptor = namespace.errorClass
if (!descriptor) { errors.delete(namespace.global); continue }
const previous = errors.get(namespace.global)
if (previous?.name === descriptor.name && previous.memberNameProperty === descriptor.memberNameProperty) continue
const constructor = makeBindingErrorClass(descriptor)
errors.set(namespace.global, { name: descriptor.name, memberNameProperty: descriptor.memberNameProperty, constructor })
}
const namespaces = makeNamespaces(data, host, errorLookup)
for (const [index, namespace] of data.namespaces.entries()) {
Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] })
if (namespace.errorClass) {
Object.defineProperty(repl.context, namespace.errorClass.name, {
configurable: true, value: errorLookup.get(namespace.global),
})
}
}
// Node's REPL treats a leading brace as an object expression before transforming await.
const program = data.code.trimStart().startsWith('{') ? `;${data.code}` : data.code
repl.eval(`${program}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => {
evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)
: prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes))
})
} catch (error: unknown) {
evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes))
}
})
return await completion.promise
},
}, ['ready', 'log', 'outputLimit'], () => {
// An inactive evaluation sends nothing more over the closed endpoint.
if (current) current.active = false
closed.resolve()
})
try {
await connection.ready()
await closed.promise
} finally {
repl.close()
input.destroy()
output.destroy()
context.disable()
}
}