diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts index a1fb8e1fde..ab9a8d6f4b 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -78,7 +78,6 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, const evaluation: Evaluation = { id, logs, active: true, finish(result) { - if (!evaluation.active) return evaluation.active = false for (const call of pending.values()) call.reject(new Error('PTC evaluation ended before the binding completed')) pending.clear() @@ -109,8 +108,10 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, }) }, () => { channel.close(); closed.resolve() }) stream.once('close', () => { closed.resolve() }) - await channel.send({ type: 'ready' }) - try { await closed.promise } finally { + try { + await channel.send({ type: 'ready' }) + await closed.promise + } finally { if (current) current.active = false repl.close() input.destroy() diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index 1bfebba520..9b5ea72850 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -52,7 +52,6 @@ class Kernel { const output = new OutputLedger(this.config.maxOutputBytes) const logs: string[] = [] const result = Promise.withResolvers() - let settled = false const timer = spec.timeoutMs === null ? undefined : setTimeout(() => { evaluation.finish({ kind: 'timeout', message: `execution deadline reached (${spec.timeoutMs}ms); retained variables were cleared` }, undefined, true) }, spec.timeoutMs) @@ -70,8 +69,6 @@ class Kernel { } }, finish: (error, value, discard = false) => { - if (settled) return - settled = true clearTimeout(timer) spec.signal?.removeEventListener('abort', onAbort) this.active = undefined @@ -139,7 +136,7 @@ class Kernel { stream.on('error', (error: Error) => this.fail('worker-exit', error.message)) } this.channel = new JsonChannel(handle.control, this.config.maxMessageBytes, (raw, bytes) => this.receive(raw, bytes), (error, kind) => { - if (!this.closed) this.fail(kind === 'protocol' ? 'protocol' : 'worker-exit', kind === 'protocol' ? error.message : 'persistent Node process stopped') + this.fail(kind === 'protocol' ? 'protocol' : 'worker-exit', kind === 'protocol' ? error.message : 'persistent Node process stopped') }) void handle.done.then(() => { setImmediate(() => { if (!this.closed) this.fail('worker-exit', 'persistent Node process stopped') }) @@ -147,16 +144,16 @@ class Kernel { } private receive(raw: unknown, bytes: number): void { - if (this.closed) return if (!record(raw)) { this.fail('protocol', 'invalid persistent PTC frame'); return } + const evaluation = this.active + if (!evaluation) { this.fail('protocol', 'frame arrived without an active PTC evaluation'); return } if (!this.ready) { if (raw.type !== 'ready') { this.fail('protocol', 'expected persistent PTC readiness'); return } this.ready = true - if (this.active) this.dispatch(this.active) + this.dispatch(evaluation) return } - const evaluation = this.active - if (!evaluation || raw.id !== evaluation.id || !record(raw.payload)) { this.fail('protocol', 'frame does not belong to the active PTC evaluation'); return } + if (raw.id !== evaluation.id || !record(raw.payload)) { this.fail('protocol', 'frame does not belong to the active PTC evaluation'); return } const payload = raw.payload switch (payload.type) { case 'log': diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 29a2a61f50..3da4c4ee6a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -138,7 +138,10 @@ describe('persistent PTC host protocol', () => { }) it('returns current binding successes, exceptions and unavailable bindings', async () => { - const cases: Record[] = [{ test: async () => 42 }, { test: async () => { throw new Error('denied') } }, {}] + const cases: Record[] = [ + { test: async () => 42 }, { test: async () => { throw new Error('denied') } }, + { test: () => Promise.reject('denied') }, {}, + ] for (const functions of cases) { const h = await setup() h.receive((raw) => { @@ -167,7 +170,7 @@ describe('persistent PTC host protocol', () => { it('cleans up after launch failure and cancellation during executable resolution', async () => { const h = await setup() - h.resolveExecutable.mockRejectedValue(new Error('missing executable')) + h.resolveExecutable.mockRejectedValue('missing executable') expect((await h.start(request, NO_INITIAL_FRAME)).error?.message).toContain('missing executable') const waiting = await setup() const ready = Promise.withResolvers() @@ -204,6 +207,7 @@ describe('persistent PTC host protocol', () => { it('rejects invalid resolved inputs and deployment bounds', async () => { const h = await setup() + expect(h.runtime.executionInstructions).toContain('Variables, functions, and imported modules remain in memory') await expect(h.runtime.run({ ...request, cwd: '.', timeoutMs: null })).rejects.toThrow('resolved inputs') await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: Infinity })).rejects.toThrow('resolved inputs') for (const config of [ @@ -214,3 +218,18 @@ describe('persistent PTC host protocol', () => { } }) }) + +it('rejects corrupt framed traffic before it reaches the evaluator', async () => { + const h = await setup() + h.onBoot(() => { h.control.emit('data', Buffer.from([0, 0, 0, 1, 255])) }) + expect((await h.start()).error?.kind).toBe('protocol') +}) + +it('discards unsolicited idle traffic and awaits the retained process exit', async () => { + const h = await setup() + h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + expect((await h.start()).error).toBeUndefined() + h.emit(frame({ type: 'log', text: 'late' })) + await h.direct.promise + expect(h.terminate).toHaveBeenCalledTimes(1) +}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts index 208e2254a6..271bf3c5c1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts @@ -103,3 +103,9 @@ it.each([0, 1.5, 4294967296])('rejects an invalid control limit %s', async (limi await expect(runPersistentMain(stream, limit, { env: {}, stdout: { write: () => true }, stderr: { write: () => true }, chdir: () => {} })).rejects.toThrow('invalid control message limit') } finally { stream.destroy() } }) + +it('shuts down an idle REPL without creating an evaluation', async () => { + const h = await setup() + await h.close() + expect(h.seen).toEqual([]) +}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts index ccc1a429e5..8e14e6a141 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts @@ -1,7 +1,9 @@ import { defineConfig } from 'tsdown' -/** Separate entry bundles keep the private process bootstrap self-contained. */ +/** Each public provider and private bootstrap is a self-contained published bundle. */ export default defineConfig([ - { entry: ['lib/types/index.js', 'lib/types/persistent.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false }, - { entry: { process: 'lib/types/process-entry.js', 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, + { entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false }, + { entry: ['lib/types/persistent.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false }, + { entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, + { entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, ])