From b28901e420718ce2fbabaa53c1b162f58af385eb Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 24 Oct 2026 12:45:39 -0700 Subject: [PATCH] fix(evals): stop surviving children after controller CLI exit Retain the full allocation and bound inherited pipes when the CLI exits before its owned provider process group. Cover both retained and absent artifacts. Co-Authored-By: Paperclip --- .../controlled-evals/controller.test.mjs | 23 +++++++++++++++++-- .../scripts/controlled-evals/run.mjs | 9 ++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs b/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs index 5fac90ff3b1..87759f68c81 100644 --- a/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs +++ b/packages/paperclip-runner/scripts/controlled-evals/controller.test.mjs @@ -19,8 +19,9 @@ import {readFile,writeFile} from 'node:fs/promises'; const args=new Map();for(let i=2;i{});process.stdout.write('ready');setInterval(()=>{},1000)"],{stdio:['ignore','pipe','ignore']}); - await once(descendant.stdout,'data'); + const orphan=${Boolean(options.orphanExit)}; + const descendant=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{});"+(orphan?"process.send('ready');":"process.stdout.write('ready');")+"setInterval(()=>{},1000)"],{stdio:orphan?['ignore','inherit','inherit','ipc']:['ignore','pipe','ignore']}); + if(orphan)await once(descendant,'message');else await once(descendant.stdout,'data'); await writeFile(request.session.workingDirectory+'/descendant.pid',String(descendant.pid)); } @@ -36,6 +37,7 @@ if(!${Boolean(options.missingArtifact)})await writeFile(args.get('--output'),JSO requestRouting:${options.missingRoute ? 'null' : "{mode:'controller_gateway',provider:descriptor.provider,declaredUpstreamRoute:descriptor.upstreamRoute,campaignId:descriptor.campaignId,executionId:descriptor.executionId,attemptId:descriptor.attemptId,descriptorSha256:createHash('sha256').update(descriptorRaw).digest('hex')}"}, snapshot:{status:'closed',process:{runnerExited:true,runnerPid:process.pid,providerPid:process.pid}},statuses, leaked:${options.leakToken ? 'process.env.PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN' : 'null'}})); +if(${Boolean(options.orphanExit)})process.exit(0); `; async function fixture(t, { count = 1, allocation = '0.01', providerProfile = 'codex', route = 'direct', ...options } = {}) { @@ -143,6 +145,23 @@ for (const fault of ['missingArtifact', 'missingRoute', 'leakToken']) test(`${fa if (fault === 'leakToken') { assert.equal(result.report.secretExcluded, false); assert.equal(artifact.leaked, '[redacted]'); } }); +for (const missingArtifact of [false, true]) test(`CLI exit stops surviving owned processes, missing artifact=${missingArtifact}`, async t => { + const f = await fixture(t, { requests: 0, resistantDescendant: true, orphanExit: true, missingArtifact }); + const pidFile = join(f.requests[0].request.session.workingDirectory, 'descendant.pid'); + let pid; + t.after(() => { if (pid) { try { process.kill(pid, 'SIGKILL'); } catch {} } }); + const result = await f.run(); + pid = Number(await readFile(pidFile, 'utf8')); + assert.equal(result.report.child.stopped, 'provider_group_survived_cli_exit'); + for (let i = 0; i < 50; i++) { + try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') break; throw error; } + await new Promise(resolve => setTimeout(resolve, 20)); + } + assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }); + assert.equal(result.report.status, 'incomplete'); + assert.equal((await summarizeControlledCampaign(f.manifest)).committedUsd, '0.014000000'); +}); + test('cancellation closes the proxy, bounds the owned child, and preserves unknown request holds', async t => { const f = await fixture(t, { requests: 1, waitForever: true }); const abort = new AbortController(); let calls = 0; diff --git a/packages/paperclip-runner/scripts/controlled-evals/run.mjs b/packages/paperclip-runner/scripts/controlled-evals/run.mjs index 42fda084873..7945910e17b 100644 --- a/packages/paperclip-runner/scripts/controlled-evals/run.mjs +++ b/packages/paperclip-runner/scripts/controlled-evals/run.mjs @@ -42,14 +42,13 @@ async function runChild(input) { if (input.signal?.aborted) onAbort(); try { const [code, signal] = await once(child, 'exit'); - if (stopped) { - let groupPresent = true; - try { process.kill(-child.pid, 0); } catch (error) { if (error.code === 'ESRCH') groupPresent = false; } - if (groupPresent) await escalation; - } + const groupPresent = !absent(-child.pid); + if (groupPresent && !stopped) stop('provider_group_survived_cli_exit'); + if (groupPresent) await escalation; return { code, signal, pid: child.pid, stopped, stdout: Buffer.concat(output.stdout).toString(), stderr: Buffer.concat(output.stderr).toString() }; } finally { clearTimeout(timeout); clearTimeout(force); input.signal?.removeEventListener('abort', onAbort); + child.stdout.destroy(); child.stderr.destroy(); } }