Skip to content

Commit bb3f4b0

Browse files
authored
fix(hooks): ref the stdin fallback timer so it actually fires (#790) (#806)
The #443 mitigation put a 1s fallback timer on the stdin hooks, but .unref()'ed it. On Windows, when the PowerShell wrapper swallows the piped JSON and neither 'data' nor 'end' fires, the only thing keeping the event loop alive is the ref'd stdin handle itself, and an unref'd timer competing with it never gets scheduled. The fallback was dead code in exactly the case it exists for, so the hook hung until Claude Code's external 5s watchdog killed it (observed at exactly 5000ms in hook telemetry) instead of exiting at ~1s. Drop .unref() on the fallback timer in ponytail-mode-tracker.js and ponytail-subagent.js so it fires regardless of stdin's ref state. To keep the normal path fast, 'end' now exits explicitly instead of just calling finish(), so the ref'd timer adds no latency where stdin does reach EOF.
1 parent 6743c04 commit bb3f4b0

3 files changed

Lines changed: 63 additions & 7 deletions

File tree

‎hooks/ponytail-mode-tracker.js‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,21 @@ function finish() {
142142
}
143143

144144
process.stdin.on('data', chunk => { input += chunk; });
145-
process.stdin.on('end', finish);
145+
// Exit on 'end', not just finish(): the fallback timer below must stay ref'd
146+
// (see #790) so it can actually fire when stdin is stuck, and a ref'd timer
147+
// would otherwise keep the process alive for its full 1000ms on this normal
148+
// fast path.
149+
process.stdin.on('end', () => { finish(); process.exit(0); });
146150

147151
// Never hang the session. On Windows, Claude Code runs this hook through a
148152
// PowerShell `if {}` wrapper that can swallow the piped prompt JSON, so stdin
149153
// 'end' never fires and the hook blocks forever — freezing the session (#443).
150154
// On error, or after a short fallback, process whatever arrived (recovering the
151-
// mode if data came without EOF) and exit. unref() keeps the timer from adding
152-
// latency to the normal path, where 'end' fires first. Mirrors the best-effort,
153-
// never-block contract the other lifecycle hooks already follow.
155+
// mode if data came without EOF) and exit. The fallback timer MUST stay ref'd:
156+
// on Windows a stuck ref'd stdin handle keeps the event loop alive, and an
157+
// unref'd timer competing with it is never scheduled — so the fallback was
158+
// dead code in exactly the #443/#790 case it exists for, and the hook hung
159+
// until Claude Code's external 5s watchdog killed it (#790). Mirrors the
160+
// best-effort, never-block contract the other lifecycle hooks already follow.
154161
process.stdin.on('error', () => { finish(); process.exit(0); });
155-
setTimeout(() => { finish(); process.exit(0); }, 1000).unref();
162+
setTimeout(() => { finish(); process.exit(0); }, 1000);

‎hooks/ponytail-subagent.js‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ function finish() {
8383
}
8484

8585
process.stdin.on('data', chunk => { input += chunk; });
86-
process.stdin.on('end', finish);
86+
// Exit on 'end' (not just finish()) so the ref'd fallback timer below can't
87+
// add its full 1000ms to the normal fast path.
88+
process.stdin.on('end', () => { finish(); process.exit(0); });
8789
// Never block the session (#443): recover on stdin error or a short fallback.
90+
// The fallback stays ref'd: on Windows a stuck ref'd stdin keeps the loop
91+
// alive and an unref'd timer is never scheduled, so the hook hung to the
92+
// external watchdog instead of exiting at 1s (#790).
8893
process.stdin.on('error', () => { finish(); process.exit(0); });
89-
setTimeout(() => { finish(); process.exit(0); }, 1000).unref();
94+
setTimeout(() => { finish(); process.exit(0); }, 1000);

‎tests/hooks.test.js‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,4 +560,48 @@ try {
560560
if (prevEnvModeRev === undefined) delete process.env.PONYTAIL_DEFAULT_MODE; else process.env.PONYTAIL_DEFAULT_MODE = prevEnvModeRev;
561561
}
562562

563+
// Normal path stays fast: 'end' exits explicitly, so the (now ref'd) fallback
564+
// timer never adds its full 1000ms to a prompt that does reach EOF.
565+
{
566+
const t0 = Date.now();
567+
const r = run('ponytail-mode-tracker.js', codexEnv, JSON.stringify({ prompt: 'hi' }));
568+
assert.equal(r.status, 0, r.stderr);
569+
const elapsed = Date.now() - t0;
570+
assert.ok(elapsed < 800, `normal path took ${elapsed}ms; ref'd fallback timer is delaying EOF exits`);
571+
}
572+
573+
// #790: when stdin never emits 'end' (the Windows PowerShell wrapper can
574+
// swallow the piped JSON, #443), the hook must still exit on its own via the
575+
// 1000ms fallback timer — not hang until the host's external watchdog kills
576+
// it. An unref'd timer never fired in that state on Windows (a ref'd stdin
577+
// keeps the loop alive and the unref'd timer is never scheduled), so the
578+
// fallback was dead code exactly where it was needed. The timer is now ref'd;
579+
// the normal 'end' path exits explicitly (checked above) so the ref'd timer
580+
// adds no latency. spawnSync's timeout acts as the watchdog: status null /
581+
// SIGTERM means the hook hung past 2500ms instead of exiting on its 1s
582+
// fallback. The unref starvation itself is Windows-only (a POSIX pipe lets the
583+
// unref'd timer fire), so the ref'd-timer contract is also asserted
584+
// statically below.
585+
{
586+
const hangHome = path.join(temp, 'hang-home');
587+
fs.mkdirSync(hangHome, { recursive: true });
588+
const hangEnv = { HOME: hangHome, USERPROFILE: hangHome, CLAUDE_CONFIG_DIR: hangHome };
589+
for (const script of ['ponytail-mode-tracker.js', 'ponytail-subagent.js']) {
590+
const r = spawnSync(process.execPath, [path.join(root, 'hooks', script)], {
591+
env: { ...process.env, ...hangEnv },
592+
timeout: 2500,
593+
encoding: 'utf8',
594+
});
595+
assert.notEqual(r.status, null, `${script} never exited on its fallback with open stdin (#790)`);
596+
assert.equal(r.status, 0, `${script} exited non-zero on its fallback (#790): ${r.stderr}`);
597+
assert.ok(r.signal === null || r.signal === undefined, `${script} was watchdog-killed; 1s fallback did not fire (#790)`);
598+
599+
const src = fs.readFileSync(path.join(root, 'hooks', script), 'utf8');
600+
assert.ok(
601+
!src.includes('.unref('),
602+
`${script} fallback timer must stay ref'd (#790): an unref'd timer never fires while a stuck ref'd stdin keeps the loop alive on Windows`,
603+
);
604+
}
605+
}
606+
563607
console.log('hook compatibility checks passed');

0 commit comments

Comments
 (0)