Files
ECC/scripts/hooks/hook-input.js
T
Gaurav DubeyandClaude Opus 5.5 0ca9eeb3ce 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>
2026-10-22 22:39:06 +00:00

146 lines
4.9 KiB
JavaScript

'use strict';
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 || (() => {});
if (value === undefined || value === '') return DEFAULT_MAX_STDIN;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
writeDiagnostic(
'[Hook] ECC_HOOK_INPUT_MAX_BYTES must be a positive safe integer; using the 1 MiB default\n'
);
return DEFAULT_MAX_STDIN;
}
if (parsed > DEFAULT_MAX_STDIN) {
writeDiagnostic(
'[Hook] ECC_HOOK_INPUT_MAX_BYTES exceeds the 1 MiB safety maximum; clamping to 1 MiB\n'
);
return DEFAULT_MAX_STDIN;
}
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;
let truncated = options.truncated === true;
return new Promise(resolve => {
let settled = false;
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);
const accepted = buffer.subarray(0, remaining);
if (accepted.length > 0) {
raw += decoder.write(accepted);
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 });
};
const finishIncomplete = () => {
if (settled) return;
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,
resolveStdinIdleTimeout
};