fix(hooks): drop pipe break that triggers EACCES "printf: write error: Permission denied" on Windows/Cygwin#2725
Conversation
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>
There was a problem hiding this comment.
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 ofbreak, with explanatory comment. - Regenerated all
plugin/hooks/hooks.jsonandplugin/hooks/codex-hooks.jsonhook 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 SummaryThis PR fixes a
Confidence Score: 5/5Safe 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
Reviews (2): Last reviewed commit: "fix(hooks): reset _F before the resoluti..." | Re-trigger Greptile |
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>
Relationship to the other open Windows hook PRsFlagging 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 One thing worth calling out for whoever merges the hook fix: the three host-managed files are generated from 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. |
…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>
|
Merged via #2865 — your |
|
Appreciate the clear writeup, Alex — glad the 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. |
Summary
On Windows (Git-Bash / MSYS / Cygwin) the plugin-root resolution prelude embedded in the hooks and the MCP launcher fails with:
…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
whileloop thatbreaks on the first match:When
CLAUDE_PLUGIN_ROOTis set (the host injects it in hook context), the first candidate matches andbreakfires 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 marketplaceprintf). 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 returnsEACCES, whichprintfreports aswrite 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)onceCLAUDE_PLUGIN_ROOTis exported.Fix
Don't
break. Drain every candidate (there are only a handful) and print the first match exactly once via a_Fguard: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
mcphost'ssh -clauncher 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 commentplugin/hooks/hooks.json— regenerated (7 command strings)plugin/hooks/codex-hooks.json— regenerated (7 command strings)plugin/.mcp.json— regenerated (mcp-search launcher)Verification
tests/infrastructure/plugin-distribution.test.tsenforces): all 15 command strings across the three files matchbuildShellCommand()output byte-for-byte after the edit.CLAUDE_PLUGIN_ROOTset, node/exec calls neutered, on Cygwin bash + sh:hooks.jsonSessionStart command (bash): 0/30 EACCES,_Presolves correctly (cygpath-converted Windows path)..mcp.jsonmcp-search command (sh): 0/30 EACCES,_Presolves correctly (POSIX path, no cygpath — matches existing behavior).Fixes #2707. Related #2709.
🤖 Generated with Claude Code