Skip to content

fix(hooks): drop pipe break that triggers EACCES "printf: write error: Permission denied" on Windows/Cygwin#2725

Closed
kinyenzm wants to merge 2 commits into
thedotmack:mainfrom
kinyenzm:fix/windows-cygwin-printf-eacces
Closed

fix(hooks): drop pipe break that triggers EACCES "printf: write error: Permission denied" on Windows/Cygwin#2725
kinyenzm wants to merge 2 commits into
thedotmack:mainfrom
kinyenzm:fix/windows-cygwin-printf-eacces

Conversation

@kinyenzm

Copy link
Copy Markdown

Summary

On Windows (Git-Bash / MSYS / Cygwin) the plugin-root resolution prelude embedded in the hooks and the MCP launcher fails with:

/usr/bin/bash: line N: printf: write error: Permission denied

…which aborts the UserPromptSubmit (and other) hooks. This is not a path/username encoding problem (it reproduces on plain ASCII usernames too) — it's a pipe-lifetime race.

Root cause

The candidate-enumeration block pipes a producer subshell into a while loop that breaks on the first match:

_P=$({ [ -n "$_E" ] && printf '%s\n' "$_E"; ls -dt "$_C/.../claude-mem"/[0-9]*/ 2>/dev/null; printf '%s\n' "$_C/.../marketplaces/thedotmack/plugin"; } \
  | while IFS= read -r _R; do ...; [ -f "$_Q/scripts/X" ] && { printf '%s\n' "$_Q"; break; }; done)

When CLAUDE_PLUGIN_ROOT is set (the host injects it in hook context), the first candidate matches and break fires immediately. That closes the read end of the pipe while the producer subshell is still trying to write the remaining candidate lines (ls -dt + the marketplace printf). The next write hits a broken pipe.

On Linux a write to a broken pipe is EPIPE ("Broken pipe") / SIGPIPE. On Cygwin/MSYS the same write returns EACCES, which printf reports as write error: Permission denied. Hence the misleading message — and why issue #2709 mis-attributed it to GBK/UTF-8 username encoding.

Reproduced 40/40 on GNU bash 5.3.9 (x86_64-pc-cygwin) once CLAUDE_PLUGIN_ROOT is exported.

Fix

Don't break. Drain every candidate (there are only a handful) and print the first match exactly once via a _F guard:

... [ -f "$_Q/scripts/X" ] && [ -z "$_F" ] && { _F=1; printf '%s\n' "$_Q"; }; done

The producer subshell always runs to completion, so no write ever lands on a closed pipe. The first match still wins → the contractual fallback ORDER is unchanged. The change is POSIX-clean (no bashisms), so the mcp host's sh -c launcher is fixed by the same edit.

Patched the single source of truth (src/build/hook-shell-template.ts) and regenerated the three host-managed files.

Files

  • src/build/hook-shell-template.ts — generator (candidateBlock) + rationale comment
  • plugin/hooks/hooks.json — regenerated (7 command strings)
  • plugin/hooks/codex-hooks.json — regenerated (7 command strings)
  • plugin/.mcp.json — regenerated (mcp-search launcher)

Verification

  • Canonical generator check (the invariant tests/infrastructure/plugin-distribution.test.ts enforces): all 15 command strings across the three files match buildShellCommand() output byte-for-byte after the edit.
  • Runtime, with CLAUDE_PLUGIN_ROOT set, node/exec calls neutered, on Cygwin bash + sh:
    • hooks.json SessionStart command (bash): 0/30 EACCES, _P resolves correctly (cygpath-converted Windows path).
    • .mcp.json mcp-search command (sh): 0/30 EACCES, _P resolves correctly (POSIX path, no cygpath — matches existing behavior).
  • JSON validity confirmed for all three files.

Fixes #2707. Related #2709.

🤖 Generated with Claude Code

Hook/MCP plugin-root resolution piped a candidate list into a `while`
loop that `break`s on the first match:

    _P=$({ printf ...; ls -dt ...; printf ...; } | while read _R; do
       ... && { printf '%s\n' "$_Q"; break; }; done)

On Cygwin/MSYS shells (Git-Bash on Windows) the early `break` closes
the pipe's read end while the producer subshell is still writing the
remaining candidate lines. The next `printf`/`ls` then writes to a
broken pipe, which Cygwin surfaces as EACCES rather than EPIPE:

    printf: write error: Permission denied

...failing the hook. It is a pipe-lifetime race, not a path/username
encoding problem — it reproduces 40/40 once CLAUDE_PLUGIN_ROOT is set
(so the first candidate matches and `break` fires immediately, leaving
the most pending producer output).

Fix: don't `break`. Drain every candidate (only a handful) and print
the FIRST match exactly once via a `_F` guard, so the producer always
completes and no broken-pipe write happens. First match still wins, so
the contractual fallback ORDER is unchanged. The change is POSIX-clean
(no bashisms), so the `mcp` host's `sh -c` launcher is fixed too.

Patched the single source of truth (src/build/hook-shell-template.ts)
and regenerated the three host-managed files; verified all 15 command
strings still match the canonical generator byte-for-byte, and that the
regenerated commands produce 0/30 EACCES under both bash and sh with
CLAUDE_PLUGIN_ROOT set.

Fixes #2707. Related #2709.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 30, 2026 19:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes hook failures on Cygwin/MSYS (Git-Bash on Windows) where break in a pipe-fed while loop caused EACCES "write error: Permission denied" errors (issues #2707, #2709). Replaces the break with a _F guard that allows the producer subshell to drain all candidates while still returning only the first match.

Changes:

  • Updated candidateBlock() template generator to emit a _F-guarded first-match selection instead of break, with explanatory comment.
  • Regenerated all plugin/hooks/hooks.json and plugin/hooks/codex-hooks.json hook commands to use the new pattern.
  • Applied the same fix to the MCP launcher in plugin/.mcp.json.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
src/build/hook-shell-template.ts Replaces break with _F guard in the candidate-selection shell snippet and adds rationale comment.
plugin/hooks/hooks.json Regenerated Claude Code hook commands using the new no-break pattern.
plugin/hooks/codex-hooks.json Regenerated Codex hook commands using the new no-break pattern.
plugin/.mcp.json Applies the no-break fix to the MCP server launcher command.

@greptile-apps

greptile-apps Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a printf: write error: Permission denied (EACCES) crash on Cygwin/MSYS/Git-Bash caused by a pipe-lifetime race: when CLAUDE_PLUGIN_ROOT is set, the first candidate matched immediately and break closed the pipe's read end while the producer subshell was still writing, which Cygwin surfaces as EACCES rather than EPIPE. The fix drains all candidates using a _F guard variable instead of break, so the producer always runs to completion; combined with an explicit _F=; initialization to prevent any inherited environment value from suppressing all matches.

  • src/build/hook-shell-template.ts — Single source of truth updated in candidateBlock(): break replaced with [ -z \"$_F\" ] && { _F=1; printf ...; }, and _F=; prepended before the command substitution, with a detailed rationale comment.
  • plugin/hooks/hooks.json, plugin/hooks/codex-hooks.json, plugin/.mcp.json — All three host-managed files regenerated to match the updated generator output byte-for-byte.

Confidence Score: 5/5

Safe to merge. The change is a targeted, POSIX-clean refactor of the pipe-consumer loop that eliminates the Cygwin/MSYS EACCES crash while preserving first-match-wins semantics across all platforms.

The fix is mechanically correct: _F=; initializes the flag to empty in the outer shell scope and is inherited by the pipe subshell; the guard [ -z "$_F" ] ensures only the first matching candidate is printed; and the loop drains all producer output so no broken-pipe write can occur. All three generated JSON files are updated consistently from the single source of truth. No logic is changed on non-Windows platforms.

No files require special attention.

Important Files Changed

Filename Overview
src/build/hook-shell-template.ts Replaces break with a _F guard to avoid broken-pipe EACCES on Cygwin/MSYS; adds _F=; initializer before the command substitution to prevent environment leakage; well-commented rationale.
plugin/hooks/hooks.json Regenerated from template; consistently adds _F=; initializer and replaces break with [ -z "$_F" ] && { _F=1; ... } across all 6 hook commands.
plugin/hooks/codex-hooks.json Regenerated from template; same _F guard substitution applied consistently across all 5 codex hook commands.
plugin/.mcp.json Regenerated from template; MCP launcher's sh -c command updated with the same _F guard, confirming the POSIX-clean fix covers both bash and sh contexts.

Reviews (2): Last reviewed commit: "fix(hooks): reset _F before the resoluti..." | Re-trigger Greptile

Comment thread src/build/hook-shell-template.ts
Review follow-up. The first-match guard reads `[ -z "$_F" ]` before _F
is ever assigned. The `while` loop runs in the pipe's subshell, which
inherits the parent shell's variables, so an inherited non-empty `_F`
(e.g. an exported var in the host environment) would make the guard
false on every iteration: nothing prints, `_P` stays empty, and the
hook exits "not found".

Prefix the command substitution with `_F=;` so the guard always starts
from a known-empty state. Regenerated the three host-managed files;
canonical generator check still matches byte-for-byte, and the command
resolves correctly under bash and sh even with `_F=1` exported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kinyenzm

Copy link
Copy Markdown
Author

Relationship to the other open Windows hook PRs

Flagging overlap so this doesn't get lost in the Windows cluster (cc the canary #2699):

This PR (#2725) is root-cause instead of suppression: drop the break, drain every candidate, and print the first match exactly once via a _F guard. The producer subshell always runs to completion, so no write ever lands on a closed pipe — nothing to suppress. First match still wins, so the contractual fallback order is unchanged, and it's POSIX-clean so the sh -c MCP launcher is fixed by the same change.

One thing worth calling out for whoever merges the hook fix: the three host-managed files are generated from src/build/hook-shell-template.ts, and tests/infrastructure/plugin-distribution.test.ts asserts they match the generator byte-for-byte. #2725 patches the generator and regenerates all three (hooks.json, codex-hooks.json, .mcp.json), so that invariant holds. A hand-edit of the JSON alone (without the generator) would trip that test. I verified all 15 generated command strings still match byte-for-byte, and that the commands resolve with 0 EACCES under both bash and sh (including with _F inherited as non-empty).

Happy to rebase this onto #2699 (or fold the generator change in there) if that's the preferred path for landing the Windows fixes together — just let me know.

thedotmack added a commit that referenced this pull request Jun 9, 2026
…2725) (#2865)

* fix(hooks): drop pipe `break` that triggers EACCES on Windows/Cygwin

Hook/MCP plugin-root resolution piped a candidate list into a `while`
loop that `break`s on the first match:

    _P=$({ printf ...; ls -dt ...; printf ...; } | while read _R; do
       ... && { printf '%s\n' "$_Q"; break; }; done)

On Cygwin/MSYS shells (Git-Bash on Windows) the early `break` closes
the pipe's read end while the producer subshell is still writing the
remaining candidate lines. The next `printf`/`ls` then writes to a
broken pipe, which Cygwin surfaces as EACCES rather than EPIPE:

    printf: write error: Permission denied

...failing the hook. It is a pipe-lifetime race, not a path/username
encoding problem — it reproduces 40/40 once CLAUDE_PLUGIN_ROOT is set
(so the first candidate matches and `break` fires immediately, leaving
the most pending producer output).

Fix: don't `break`. Drain every candidate (only a handful) and print
the FIRST match exactly once via a `_F` guard, so the producer always
completes and no broken-pipe write happens. First match still wins, so
the contractual fallback ORDER is unchanged. The change is POSIX-clean
(no bashisms), so the `mcp` host's `sh -c` launcher is fixed too.

Patched the single source of truth (src/build/hook-shell-template.ts)
and regenerated the three host-managed files; verified all 15 command
strings still match the canonical generator byte-for-byte, and that the
regenerated commands produce 0/30 EACCES under both bash and sh with
CLAUDE_PLUGIN_ROOT set.

Fixes #2707. Related #2709.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): reset _F before the resolution loop

Review follow-up. The first-match guard reads `[ -z "$_F" ]` before _F
is ever assigned. The `while` loop runs in the pipe's subshell, which
inherits the parent shell's variables, so an inherited non-empty `_F`
(e.g. an exported var in the host environment) would make the guard
false on every iteration: nothing prints, `_P` stays empty, and the
hook exits "not found".

Prefix the command substitution with `_F=;` so the guard always starts
from a known-empty state. Regenerated the three host-managed files;
canonical generator check still matches byte-for-byte, and the command
resolves correctly under bash and sh even with `_F=1` exported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(sqlite): remove dead legacy migration system (migrations.ts)

migration001..010 + the migrations[] array were orphaned by the #534
MigrationRunner refactor — zero importers. Build + full test suite
show no regression (11 pre-existing flaky fails identical on main).

* build: regenerate bundles + restore canonical hook JSONs for template fix

---------

Co-authored-by: Steven Moreno <steven.moreno@kpginc.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: wangchenguang <darion.yaphets@gmail.com>
@thedotmack

Copy link
Copy Markdown
Owner

Merged via #2865 — your _F first-match guard landed in the canonical template (src/build/hook-shell-template.ts) with hook JSONs regenerated; the .mcp.json hunk was superseded by the #2807 Node launcher. This was the right root-cause fix where the earlier printf-suppression approach (a19b0e3, reverted in 4d81293) wasn't. Thanks!

@kinyenzm

Copy link
Copy Markdown
Author

Appreciate the clear writeup, Alex — glad the _F guard was the right cut. Folding the .mcp.json resolution into the #2807 Node launcher makes sense; one less generated surface to keep byte-for-byte in sync with the template is a net win.

Nothing further needed on my end. If the remaining Windows cluster (#2699 / #2709) still wants eyes on the Cygwin EACCES-vs-EPIPE behavior, happy to help.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: UserPromptSubmit hook fails on Windows (PowerShell 7) - printf: write error: Permission denied

3 participants