Skip to content

Commit 28ed9de

Browse files
committed
fix: cancel protected response writes on timeout
1 parent b9c4dc1 commit 28ed9de

2 files changed

Lines changed: 39 additions & 6 deletions

File tree

scripts/zcode-companion.mjs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22
import process from 'node:process';
33
import { createHash, randomBytes } from 'node:crypto';
4-
import { close as closeFd, createReadStream, realpathSync, write as writeFd } from 'node:fs';
4+
import { closeSync as closeFdSync, createReadStream, createWriteStream, realpathSync } from 'node:fs';
55
import { fileURLToPath } from 'node:url';
66
import { join, resolve, sep } from 'node:path';
77

@@ -247,33 +247,55 @@ export function readInternalEnvelope(fd = 3, options = {}) {
247247
stream.once('end', () => finish(() => { try { resolvePromise(JSON.parse(data)); } catch { reject(authorizationInputError()); } }));
248248
});
249249
}
250-
/** @param {unknown} value @param {number} [fd] @param {{maxBytes?:number,timeoutMs?:number,write?:(fd:number,buffer:Buffer,offset:number,length:number,position:null,callback:(error:NodeJS.ErrnoException|null,bytesWritten:number)=>void)=>void,close?:(fd:number,callback:(error?:NodeJS.ErrnoException|null)=>void)=>void}} [options] */
250+
/** @param {unknown} value @param {number} [fd] @param {{maxBytes?:number,timeoutMs?:number,write?:(fd:number,buffer:Buffer,offset:number,length:number,position:null,callback:(error:NodeJS.ErrnoException|null,bytesWritten:number)=>void)=>void|{cancel?:()=>void},close?:(fd:number,callback:(error?:NodeJS.ErrnoException|null)=>void)=>void}} [options] */
251251
export function writeInternalResponse(value, fd = 4, options = {}) {
252252
const maxBytes = options.maxBytes ?? 1024 * 1024; const timeoutMs = options.timeoutMs ?? 1_000;
253253
if (!Number.isSafeInteger(fd) || fd < 3 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 1024 * 1024 || !Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) return Promise.reject(new PluginError('INTERNAL_RESPONSE_OPTIONS_INVALID', 'Internal response writer options are invalid.', { category: 'validation', remedy: 'Use a protected descriptor, a limit up to 1 MiB, and a positive deadline.' }));
254254
const data = Buffer.from(`${JSON.stringify(value)}\n`);
255255
if (data.length > maxBytes) return Promise.reject(new PluginError('INTERNAL_RESPONSE_TOO_LARGE', 'Internal response exceeded its limit.', { category: 'runtime', remedy: 'Inspect the job through status/result.' }));
256-
const write = options.write ?? writeFd; const close = options.close ?? closeFd;
256+
/** @type {import('node:fs').WriteStream|null} */
257+
let stream = null;
258+
const write = options.write ?? ((_fd, buffer, offset, length, _position, callback) => {
259+
if (!stream) { stream = createWriteStream(/** @type {any} */ (null), { fd, autoClose: false }); stream.on('error', () => {}); }
260+
stream.write(buffer.subarray(offset, offset + length), (error) => callback(error ? /** @type {NodeJS.ErrnoException} */ (error) : null, error ? 0 : length));
261+
return { cancel: () => stream?.destroy() };
262+
});
263+
const close = options.close ?? ((targetFd, callback) => {
264+
try { closeFdSync(targetFd); callback(); } catch (error) { callback(/** @type {NodeJS.ErrnoException} */ (error)); }
265+
});
257266
return new Promise((resolvePromise, reject) => {
258267
let offset = 0; let settled = false; let closing = false;
268+
/** @type {(()=>void)|null} */
269+
let cancelPending = null;
270+
const dispose = () => { if (stream && !stream.destroyed) stream.destroy(); };
259271
/** @param {unknown} [error] */
260-
const finish = (error) => { if (settled) return; settled = true; clearTimeout(timer); if (error) reject(error); else resolvePromise(undefined); };
272+
const finish = (error) => { if (settled) return; settled = true; clearTimeout(timer); dispose(); if (error) reject(error); else resolvePromise(undefined); };
261273
/** @param {unknown} cause @param {string} [code] */
262274
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 });
263275
const timer = setTimeout(() => {
264276
if (settled || closing) return; closing = true;
277+
const cancel = cancelPending; cancelPending = null;
278+
try { cancel?.(); } catch { /* best effort abort */ }
265279
try { close(fd, () => {}); } catch { /* best effort abort */ }
266280
finish(failure(new Error('Internal response write timed out.'), 'INTERNAL_RESPONSE_WRITE_TIMEOUT'));
267281
}, timeoutMs);
268282
const next = () => {
269283
if (settled) return;
270-
write(fd, data, offset, data.length - offset, null, (error, bytesWritten) => {
284+
let completed = false;
285+
/** @type {(()=>void)|null} */
286+
let cancel = null;
287+
const callback = (/** @type {NodeJS.ErrnoException|null} */ error, /** @type {number} */ bytesWritten) => {
288+
completed = true;
289+
if (cancelPending === cancel) cancelPending = null;
271290
if (settled) return;
272291
if (error) return finish(failure(error));
273292
if (!Number.isSafeInteger(bytesWritten) || bytesWritten <= 0) return finish(failure(new Error('Internal response writer made no progress.')));
274293
offset += bytesWritten;
275294
if (offset >= data.length) finish(); else queueMicrotask(next);
276-
});
295+
};
296+
const operation = write(fd, data, offset, data.length - offset, null, callback);
297+
cancel = typeof operation?.cancel === 'function' ? operation.cancel : null;
298+
if (!completed) cancelPending = cancel;
277299
};
278300
next();
279301
});

tests/recovery.test.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,17 @@ test('internal response writer times out without blocking the event loop and clo
343343
assert.equal(closes, 1); assert.equal(ticked, true);
344344
});
345345

346+
test('internal response writer cancels a pending write before closing its descriptor', async () => {
347+
let cancelled = 0; let closes = 0; let lateCallback;
348+
await assert.rejects(writeInternalResponse({ ok: true }, 44, {
349+
timeoutMs: 10,
350+
write: (_fd, _buffer, _offset, _length, _position, callback) => { lateCallback = callback; return { cancel: () => { cancelled += 1; } }; },
351+
close: (_fd, callback) => { closes += 1; callback(); },
352+
}), { code: 'INTERNAL_RESPONSE_WRITE_TIMEOUT' });
353+
assert.equal(cancelled, 1); assert.equal(closes, 1);
354+
lateCallback?.(null, 1);
355+
});
356+
346357
test('real fd4 writer is bounded for no-reader, slow-reader, and early-close pipes', async () => {
347358
const noRead = await runWriterProbe('no-read'); assert.equal(noRead.code, 0); assert.match(noRead.stdout, /INTERNAL_RESPONSE_WRITE_TIMEOUT/);
348359
const slowRead = await runWriterProbe('slow-read'); assert.equal(slowRead.code, 0); assert.match(slowRead.stdout, /ok/);

0 commit comments

Comments
 (0)