From e7ee55f93663d6145b4ef3f6ff29d664c3c3469d Mon Sep 17 00:00:00 2001 From: Julius Brussee <104168679+JuliusBrussee@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:57:10 +0200 Subject: [PATCH] fix(#538): handle stdin 'error' in mode-tracker (hand-applied from PR #560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #560 shipped the regression test but its source change never made it into the diff — the branch only adds tests/test_mode_tracker_stdin.js. Applied the one-line silent-exit error listener the PR describes and took the author's test verbatim. Co-authored-by: ousamabenyounes --- src/hooks/caveman-mode-tracker.js | 4 ++ tests/test_mode_tracker_stdin.js | 85 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/test_mode_tracker_stdin.js diff --git a/src/hooks/caveman-mode-tracker.js b/src/hooks/caveman-mode-tracker.js index 9fcd8b5..c54bf7d 100644 --- a/src/hooks/caveman-mode-tracker.js +++ b/src/hooks/caveman-mode-tracker.js @@ -20,6 +20,10 @@ const prevPath = path.join(claudeDir, '.caveman-active.prev'); let input = ''; process.stdin.on('data', chunk => { input += chunk; }); +// Abnormal stdin close (broken pipe, parent crash) emits 'error'; without a +// listener Node throws it as an uncaught exception and the hook exits +// non-zero — a spurious hook failure (#538). Hooks must always exit 0. +process.stdin.on('error', () => process.exit(0)); process.stdin.on('end', () => { try { const data = JSON.parse(input); diff --git a/tests/test_mode_tracker_stdin.js b/tests/test_mode_tracker_stdin.js new file mode 100644 index 0000000..49d7c6e --- /dev/null +++ b/tests/test_mode_tracker_stdin.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Tests for the stdin 'error' handler in caveman-mode-tracker.js. +// Covers issue #538: an abnormal stdin close (broken pipe, parent crash) emits +// an 'error' event on process.stdin; without a listener Node throws it as an +// uncaught exception and the hook exits non-zero — a spurious hook failure. +// +// Run: node tests/test_mode_tracker_stdin.js + +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +const HOOK_PATH = path.resolve(__dirname, '..', 'src', 'hooks', 'caveman-mode-tracker.js'); +const CLEAN_EXIT = 0; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + console.error(` ✗ ${name}`); + console.error(` ${e.message}`); + } +} + +console.log('caveman-mode-tracker stdin error handling\n'); + +// Load the REAL hook in a child, then emit an 'error' on process.stdin to +// simulate an abnormal close. stdin is left open (never closed) so the only +// event that fires is the injected 'error' — isolating the handler under test. +function runWithStdinError() { + const harness = + `require(${JSON.stringify(HOOK_PATH)});` + + `setImmediate(() => process.stdin.emit('error', new Error('EPIPE (simulated)')));`; + return spawnSync(process.execPath, ['-e', harness], { + stdio: ['pipe', 'ignore', 'pipe'], + encoding: 'utf8', + }); +} + +test('stdin "error" event does not crash the hook (exit 0)', () => { + const res = runWithStdinError(); + assert.strictEqual( + res.status, + CLEAN_EXIT, + `expected clean exit on stdin error, got status=${res.status} signal=${res.signal}\n` + + `stderr: ${(res.stderr || '').trim()}` + ); + assert.ok( + !/Unhandled 'error' event/.test(res.stderr || ''), + `hook leaked an uncaught stdin error:\n${(res.stderr || '').trim()}` + ); +}); + +// Regression guard: the new listener must not disturb the normal path — a valid +// prompt piped on stdin, then a clean EOF, still exits 0. +test('normal stdin (valid JSON + clean EOF) still exits 0', () => { + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-tracker-stdin-')); + try { + const res = spawnSync(process.execPath, [HOOK_PATH], { + input: JSON.stringify({ prompt: 'hello there' }), + env: { ...process.env, CLAUDE_CONFIG_DIR: tmpConfig }, + stdio: ['pipe', 'ignore', 'pipe'], + encoding: 'utf8', + }); + assert.strictEqual( + res.status, + CLEAN_EXIT, + `expected clean exit on normal input, got status=${res.status}\n` + + `stderr: ${(res.stderr || '').trim()}` + ); + } finally { + fs.rmSync(tmpConfig, { recursive: true, force: true }); + } +}); + +console.log(`\n${passed} passed, ${failed} failed`); +process.exit(failed === 0 ? 0 : 1);