Skip to content

perf(hooks): stop blocking the agent's tool loop on the notify response - #142

Merged
murdore merged 1 commit into
releasefrom
fix/notifier-nonblocking-hook
Aug 18, 2026
Merged

perf(hooks): stop blocking the agent's tool loop on the notify response#142
murdore merged 1 commit into
releasefrom
fix/notifier-nonblocking-hook

Conversation

@murdore

@murdore murdore commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Problem

The Claude Code PreToolUse hook took ~1.2 s on every AskUserQuestion. Measured across ~3 months of session transcripts: 2,601 runs, p50 1,193 ms, p90 1,669 ms, max 10,569 ms — the last one hitting the req.setTimeout(10000) ceiling.

Node startup isn't the cause. Cold start plus parsing the 83 KB notifier.cjs measures 40–80 ms, about 5% of it. The rest is the /api/notify round-trip: the pending_requests insert plus the push dispatch.

sendNotification and postManagedEvent are already fire-and-forget at the JS level — nothing awaits them. But the outstanding http.request keeps Node's event loop alive until the response is drained at res.on('end'), and Claude Code blocks the agent until the hook process exits. So they were synchronous in effect.

Fix

Unref the socket in req.end()'s flush callback. The POST is already on the wire by then, so the payload still arrives.

Measured, against the live local server

Process lifetime
before 1,570 ms
after 60 ms

26× faster. Both probe notifications were recorded in pending_requests even though the client exited at 60 ms — nothing dropped. A separate controlled test against a throwaway server that stalls 1.1 s reproduced it exactly: 1,150 ms → 50 ms, full payload received.

What is deliberately NOT changed

sendNotificationAndPoll has the identical two-line shape but genuinely waits for an allow/deny decision and polls /api/response. Unref'ing there would make permission prompts resolve to nothing. Left blocking, and the patch script asserts it stayed that way.

Cost

The === NOTIFICATION SENT === stderr block no longer prints for these two paths, so non-200s from /api/notify go unlogged. Side effect worth knowing: with no output, Claude Code stops persisting these hook runs to session transcripts — which is also why the slow runs were only ever visible for AskUserQuestion in the first place.

Related, not fixed here

The hook was registered with an empty matcher, so it spawned Node on all 263,952 tool calls in the scan window while handleToolStart no-ops for everything except AskUserQuestion — roughly 4.4 hours of blocked loop. That's a client-side settings.json fix ("matcher": "AskUserQuestion"), not a change to this repo.

Summary by CodeRabbit

  • Bug Fixes
    • Improved notification handling so command-line hooks can exit promptly after sending notifications.
    • Prevented processes from waiting unnecessarily on unused notification responses.

sendNotification and postManagedEvent are fire-and-forget at the JS level —
nothing awaits them — but the outstanding http.request keeps Node's event loop
alive until the response is drained, and Claude Code blocks the agent until the
hook process exits. So every AskUserQuestion paid the full /api/notify
round-trip: the pending_requests insert plus the push dispatch.

Measured against the live local server, one real push each:

  before  1570 ms process lifetime
  after     60 ms

Unref the socket in req.end()'s flush callback. The POST is already on the wire
by then, so the payload still arrives — verified: both probe notifications were
recorded in pending_requests even though the client exited at 60 ms.

sendNotificationAndPoll is deliberately NOT changed. It has the same two-line
shape but genuinely waits for an allow/deny decision; unref'ing there would make
permission prompts resolve to nothing.

Cost: the "=== NOTIFICATION SENT ===" stderr block no longer prints for these
two paths, so non-200s from /api/notify go unlogged. Side effect: with no
output, Claude Code stops persisting these hook runs to session transcripts.
@github-actions

Copy link
Copy Markdown

Single Commit Policy - COMPLIANT

Status: Policy requirements met - 1 commit - Valid format - Ready for merge

View validation details

Commit Details

  • Hash: ebb50c4ade83e1ccffed93b8b04478e39df6d2e1
  • Message: perf(hooks): stop blocking the agent's tool loop on the notify response
  • Author: Sachin Sharma

Validation Results

  • Single commit requirement met
  • No merge commits in branch
  • Semantic commit message format verified
  • Ready for squash merge to release branch

Automated validation by Shooter Single Commit Enforcement

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bbe2d767-bc35-49dc-9a9d-9e5113b6f2ad

📥 Commits

Reviewing files that changed from the base of the PR and between 0e59d6c and ebb50c4.

📒 Files selected for processing (1)
  • .claude/hooks/notifier.cjs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The notification hooks now unreference request sockets after both POST payloads flush. Existing payload delivery and error handling remain unchanged.

Changes

Notification request lifecycle

Layer / File(s) Summary
Unreference notification request sockets
.claude/hooks/notifier.cjs
postManagedEvent and sendNotification pass completion callbacks to req.end(). The callbacks unreference the request sockets after payload delivery.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Merge Risk: ⚪ Minimal · up to ebb50

The hook now exits without waiting for notification responses, substantially reducing tool-loop latency while preserving delivery of the notification payload. No actionable merge-blocking risk remains beyond normal checks and review.

Poem

I’m a rabbit with a socket to spare,
I flush each note, then leave it there.
The hook can hop, the process can end,
No waiting tail around the bend.
Thump, thump—notifications send!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing hook notifications from blocking the agent's tool loop.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/notifier-nonblocking-hook

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Tara-ag Tara-ag left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Files reviewed: 1 (.claude/hooks/notifier.cjs)
New issues raised: 1 MINOR
Blocking issues: 0

Findings

The change correctly identifies that sendNotification and postManagedEvent are fire-and-forget at the JS level but were effectively synchronous because the outstanding http.request kept the event loop alive until the response was drained. Unref'ing the socket in req.end()'s flush callback allows the Claude Code hook process to exit as soon as the POST payload is on the wire, which matches the measured 26× improvement.

sendNotificationAndPoll is deliberately left blocking, which is the right call since it genuinely waits for an allow/deny decision.

No blocking criteria are triggered:

  • No hardcoded secrets or credential exposure.
  • No auth bypass or weakening.
  • No command/shell injection.
  • No destructive-by-default changes or permission regressions.

Minor note

The new unref behavior is a runtime change that currently relies on manual measurement. I left an inline suggestion to add a tests/*.cjs integration test that verifies the hook process exits quickly against a slow-responding local server while still delivering the payload. This aligns with the project's MINOR standard that new runtime behavior should have test coverage.

Approving — the fix is safe and the performance gain is well justified.

// and Claude Code blocks the agent's tool loop until the process exits — so
// don't hold the event loop open for a response nothing here consumes.
// Measured: ~1150ms -> ~50ms, with the payload still delivered in full.
req.socket?.unref();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 This new runtime behavior (unref the socket so the hook process can exit before the HTTP response arrives) is currently only verified manually. Per the project standards, new runtime behavior should have a test in tests/*.cjs. Consider adding a small integration test that spawns notifier.cjs against a local slow-responding HTTP server and asserts the process exits in <100 ms while the request body is still received. That would guard against an accidental revert to blocking req.end().

@murdore
murdore merged commit f4581aa into release Aug 18, 2026
24 of 25 checks passed
@murdore
murdore deleted the fix/notifier-nonblocking-hook branch August 18, 2026 21:02
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.36.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants