Skip to content

Commit 812c079

Browse files
committed
fix: preserve protected pipe EOF after successful writes
1 parent 57449a1 commit 812c079

2 files changed

Lines changed: 39 additions & 6 deletions

File tree

scripts/zcode-companion.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,13 @@ export function writeInternalResponse(value, fd = 4, options = {}) {
269269
let offset = 0; let settled = false; let closing = false;
270270
/** @type {(()=>void)|null} */
271271
let cancelPending = null;
272-
const dispose = () => { if (socket && !socket.destroyed) socket.destroy(); };
272+
/** @param {unknown} error */
273+
const dispose = (error) => {
274+
if (!socket || socket.destroyed) return;
275+
if (error) socket.destroy(); else socket.unref();
276+
};
273277
/** @param {unknown} [error] */
274-
const finish = (error) => { if (settled) return; settled = true; clearTimeout(timer); dispose(); if (error) reject(error); else resolvePromise(undefined); };
278+
const finish = (error) => { if (settled) return; settled = true; clearTimeout(timer); dispose(error); if (error) reject(error); else resolvePromise(undefined); };
275279
/** @param {unknown} cause @param {string} [code] */
276280
const failure = (cause, code = 'INTERNAL_RESPONSE_WRITE_FAILED') => new PluginError(code, 'Could not deliver the protected internal response.', { category: code.endsWith('TIMEOUT') ? 'timeout' : 'runtime', remedy: 'Retry the command through its installed skill.', cause });
277281
const timer = setTimeout(() => {

tests/recovery.test.mjs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
// @ts-nocheck
22
import assert from 'node:assert/strict';
3-
import { spawn } from 'node:child_process';
3+
import { execFile, spawn } from 'node:child_process';
4+
import { closeSync, constants, openSync } from 'node:fs';
45
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
6+
import { Socket } from 'node:net';
57
import { tmpdir } from 'node:os';
68
import { join } from 'node:path';
79
import test from 'node:test';
10+
import { promisify } from 'node:util';
811
import { fileURLToPath } from 'node:url';
912

1013
import { createIdentityStore } from '../scripts/lib/identity.mjs';
@@ -23,6 +26,7 @@ const companionCli = fileURLToPath(new URL('../scripts/zcode-companion.mjs', imp
2326
const fakeZCode = fileURLToPath(new URL('./fixtures/fake-zcode-cli.mjs', import.meta.url));
2427
const cancelAttemptChild = fileURLToPath(new URL('./fixtures/cancel-attempt-child.mjs', import.meta.url));
2528
const cancelLockHolder = fileURLToPath(new URL('./fixtures/cancel-lock-holder.mjs', import.meta.url));
29+
const execFileAsync = promisify(execFile);
2630

2731
function spawnCancelAttempt(args) {
2832
const child = spawn(process.execPath, [cancelAttemptChild, ...args], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }); let stdout = ''; let stderr = '';
@@ -33,12 +37,15 @@ function spawnCancelAttempt(args) {
3337

3438
function runWriterProbe(mode) {
3539
const child = spawn(process.execPath, [writerProbe, mode], { stdio: ['ignore', 'pipe', 'pipe', 'ignore', 'pipe'] });
36-
let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
40+
let stdout = ''; let stderr = ''; let internalError = null; let exited = false; let streamClosed = !child.stdio[4]; let exitCode;
41+
child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
3742
if (mode === 'early-close') child.stdio[4].destroy();
3843
if (mode === 'slow-read') { child.stdio[4].pause(); setTimeout(() => { child.stdio[4].on('data', () => {}); child.stdio[4].resume(); }, 50); }
3944
return new Promise((resolve, reject) => {
4045
const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`writer probe ${mode} exceeded hard timeout`)); }, 2_000);
41-
child.once('error', (error) => { clearTimeout(timer); reject(error); }); child.once('exit', (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
46+
const settle = () => { if (!exited || !streamClosed) return; clearTimeout(timer); resolve({ code: exitCode, stdout, stderr, internalError }); };
47+
child.stdio[4]?.once('error', (error) => { internalError = error; }); child.stdio[4]?.once('close', () => { streamClosed = true; settle(); });
48+
child.once('error', (error) => { clearTimeout(timer); reject(error); }); child.once('exit', (code) => { exitCode = code; exited = true; settle(); });
4249
});
4350
}
4451

@@ -354,9 +361,31 @@ test('internal response writer cancels a pending write before closing its descri
354361
lateCallback?.(null, 1);
355362
});
356363

364+
test('successful internal response writes unref the protected socket instead of resetting its parent reader', async () => {
365+
if (process.platform === 'win32') return;
366+
const root = await mkdtemp(join(tmpdir(), 'zcode-fd4-')); const fifo = join(root, 'pipe');
367+
await execFileAsync('mkfifo', [fifo]);
368+
const readerFd = openSync(fifo, constants.O_RDONLY | constants.O_NONBLOCK); const writerFd = openSync(fifo, constants.O_WRONLY | constants.O_NONBLOCK);
369+
const originalDestroy = Socket.prototype.destroy; const originalUnref = Socket.prototype.unref;
370+
let destroyed = 0; let unrefed = 0;
371+
const isWriterSocket = (socket) => socket?._handle?.fd === writerFd;
372+
Socket.prototype.destroy = function (...args) { if (isWriterSocket(this)) destroyed += 1; return originalDestroy.apply(this, args); };
373+
Socket.prototype.unref = function (...args) { if (isWriterSocket(this)) unrefed += 1; return originalUnref.apply(this, args); };
374+
try {
375+
await writeInternalResponse({ ok: true }, writerFd, { timeoutMs: 100 });
376+
assert.equal(destroyed, 0);
377+
assert.equal(unrefed, 1);
378+
} finally {
379+
Socket.prototype.destroy = originalDestroy; Socket.prototype.unref = originalUnref;
380+
try { closeSync(readerFd); } catch { /* writer path may already be closed */ }
381+
try { closeSync(writerFd); } catch { /* expected while proving success does not close it */ }
382+
await rm(root, { force: true, recursive: true });
383+
}
384+
});
385+
357386
test('real fd4 writer is bounded for no-reader, slow-reader, and early-close pipes', async () => {
358387
const noRead = await runWriterProbe('no-read'); assert.equal(noRead.code, 0); assert.match(noRead.stdout, /INTERNAL_RESPONSE_WRITE_TIMEOUT/);
359-
const slowRead = await runWriterProbe('slow-read'); assert.equal(slowRead.code, 0); assert.match(slowRead.stdout, /ok/);
388+
const slowRead = await runWriterProbe('slow-read'); assert.equal(slowRead.code, 0); assert.match(slowRead.stdout, /ok/); assert.equal(slowRead.internalError, null);
360389
const earlyClose = await runWriterProbe('early-close'); assert.equal(earlyClose.code, 0); assert.match(earlyClose.stdout, /INTERNAL_RESPONSE_WRITE_FAILED/);
361390
});
362391

0 commit comments

Comments
 (0)