mirror of
https://github.com/affaan-m/ECC.git
synced 2026-11-09 01:26:00 +00:00
fix(hooks): stop hook stdin reads when the caller never sends EOF
readStdinRaw() only settled on end, error or close. If the parent kept stdin open without writing EOF, every hook that reads through it waited until the harness killed it (an 8-10 s stall per call). Add an idle deadline (default 3000 ms, ECC_HOOK_INPUT_IDLE_TIMEOUT_MS, 0 disables). It is reset on each chunk, unref()ed, and on expiry detaches from and pauses the stream so a held-open pipe cannot keep the hook alive. Input that already parses as a complete JSON object is accepted; anything else is marked truncated so fail-closed safety hooks still block. bash-hook-dispatcher.js now reads through the shared reader instead of its own end/error-only loop. Fixes #3665 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90606c8315
commit
0ca9eeb3ce
4 files changed
+256
-20
No files matched your search
@@ -2,6 +2,7 @@
|
||||
'use strict';
|
||||
|
||||
const { isHookEnabled } = require('../lib/hook-flags');
|
||||
const { readStdinRaw } = require('./hook-input');
|
||||
const {
|
||||
buildPreToolUseAdditionalContext,
|
||||
combineAdditionalContext,
|
||||
@@ -72,21 +73,6 @@ const POST_BASH_HOOKS = [
|
||||
},
|
||||
];
|
||||
|
||||
function readStdinRaw() {
|
||||
return new Promise(resolve => {
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => resolve(raw));
|
||||
process.stdin.on('error', () => resolve(raw));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeHookResult(previousRaw, output) {
|
||||
if (typeof output === 'string' || Buffer.isBuffer(output)) {
|
||||
return {
|
||||
@@ -183,7 +169,9 @@ function runPostBash(rawInput) {
|
||||
|
||||
async function main() {
|
||||
const mode = process.argv[2];
|
||||
const raw = await readStdinRaw();
|
||||
// The shared reader also settles on close and on an idle deadline, so a
|
||||
// parent that never closes stdin cannot hang the dispatcher (#3665).
|
||||
const { raw } = await readStdinRaw(process.stdin, { maxStdin: MAX_STDIN });
|
||||
|
||||
const result = mode === 'post'
|
||||
? runPostBash(raw)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
const { StringDecoder } = require('string_decoder');
|
||||
|
||||
const DEFAULT_MAX_STDIN = 1024 * 1024;
|
||||
// Stay below the shortest per-hook timeout in hooks/hooks.json (5 s) so a
|
||||
// stalled stdin is settled by the reader, not by the harness killing the hook.
|
||||
const DEFAULT_STDIN_IDLE_TIMEOUT_MS = 3000;
|
||||
|
||||
function resolveMaxStdin(value, options = {}) {
|
||||
const writeDiagnostic = options.writeDiagnostic || (() => {});
|
||||
@@ -24,8 +27,38 @@ function resolveMaxStdin(value, options = {}) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveStdinIdleTimeout(value, options = {}) {
|
||||
const writeDiagnostic = options.writeDiagnostic || (() => {});
|
||||
if (value === undefined || value === '') return DEFAULT_STDIN_IDLE_TIMEOUT_MS;
|
||||
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
writeDiagnostic(
|
||||
'[Hook] ECC_HOOK_INPUT_IDLE_TIMEOUT_MS must be a non-negative safe integer; using the 3000 ms default\n'
|
||||
);
|
||||
return DEFAULT_STDIN_IDLE_TIMEOUT_MS;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// A JSON object cannot be the prefix of a longer valid document, so a payload
|
||||
// that already parses as one is complete even though EOF never arrived.
|
||||
function isCompleteJsonObject(text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed !== null && typeof parsed === 'object';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readStdinRaw(stream = process.stdin, options = {}) {
|
||||
const maxStdin = options.maxStdin || DEFAULT_MAX_STDIN;
|
||||
const idleTimeoutMs = options.idleTimeoutMs === undefined
|
||||
? resolveStdinIdleTimeout(process.env.ECC_HOOK_INPUT_IDLE_TIMEOUT_MS)
|
||||
: options.idleTimeoutMs;
|
||||
const writeDiagnostic = options.writeDiagnostic
|
||||
|| (stream === process.stdin ? message => process.stderr.write(message) : () => {});
|
||||
const decoder = new StringDecoder('utf8');
|
||||
let raw = '';
|
||||
let acceptedBytes = 0;
|
||||
@@ -33,7 +66,20 @@ function readStdinRaw(stream = process.stdin, options = {}) {
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
stream.on('data', chunk => {
|
||||
let idleTimer = null;
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
};
|
||||
const armIdleTimer = () => {
|
||||
if (!(idleTimeoutMs > 0)) return;
|
||||
clearIdleTimer();
|
||||
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
|
||||
// Never keep the process alive just to wait for this deadline.
|
||||
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
||||
};
|
||||
const onData = chunk => {
|
||||
armIdleTimer();
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (typeof options.onChunk === 'function') options.onChunk(buffer);
|
||||
const remaining = Math.max(0, maxStdin - acceptedBytes);
|
||||
@@ -43,10 +89,11 @@ function readStdinRaw(stream = process.stdin, options = {}) {
|
||||
acceptedBytes += accepted.length;
|
||||
}
|
||||
if (accepted.length < buffer.length) truncated = true;
|
||||
});
|
||||
};
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearIdleTimer();
|
||||
if (!truncated) raw += decoder.end();
|
||||
resolve({ raw, truncated });
|
||||
};
|
||||
@@ -55,16 +102,44 @@ function readStdinRaw(stream = process.stdin, options = {}) {
|
||||
truncated = true;
|
||||
finish();
|
||||
};
|
||||
// The writer kept stdin open without sending EOF (#3665). Stop reading
|
||||
// and release the stream so a held-open pipe cannot keep the hook alive.
|
||||
// Anything short of a complete JSON object is marked incomplete so safety
|
||||
// hooks remain fail closed.
|
||||
function onIdleTimeout() {
|
||||
idleTimer = null;
|
||||
if (settled) return;
|
||||
stream.removeListener('data', onData);
|
||||
if (typeof stream.pause === 'function') stream.pause();
|
||||
if (!truncated) {
|
||||
const pending = raw + decoder.end();
|
||||
if (isCompleteJsonObject(pending)) {
|
||||
raw = pending;
|
||||
settled = true;
|
||||
resolve({ raw, truncated: false });
|
||||
return;
|
||||
}
|
||||
raw = pending;
|
||||
}
|
||||
writeDiagnostic(
|
||||
`[Hook] stdin stayed open without EOF for ${idleTimeoutMs} ms; treating the hook input as incomplete\n`
|
||||
);
|
||||
finishIncomplete();
|
||||
}
|
||||
stream.on('data', onData);
|
||||
stream.once('end', finish);
|
||||
// A transport error or premature close can leave a syntactically plausible
|
||||
// prefix behind. Mark it incomplete so safety hooks remain fail closed.
|
||||
stream.once('error', finishIncomplete);
|
||||
stream.once('close', finishIncomplete);
|
||||
armIdleTimer();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MAX_STDIN,
|
||||
DEFAULT_STDIN_IDLE_TIMEOUT_MS,
|
||||
readStdinRaw,
|
||||
resolveMaxStdin
|
||||
resolveMaxStdin,
|
||||
resolveStdinIdleTimeout
|
||||
};
|
||||
Reference in new issue
Block a user