Skip to content
This repository was archived by the owner on Aug 7, 2026. It is now read-only.

Commit e026f49

Browse files
committed
Merge fix/sigpipe-crashes: eliminate SIGPIPE crashes in session-init.sh and context-lib.sh
2 parents 28782a7 + 0c983c1 commit e026f49

4 files changed

Lines changed: 620 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Fixed
11+
- `fix/sigpipe-crashes`: SIGPIPE (exit 141) crashes in session-init.sh and context-lib.sh when MASTER_PLAN.md has large sections — replaced 20 pipe patterns with SIGPIPE-safe equivalents (awk inline limits, bash builtins, single-pass awk); added 14-test SIGPIPE resistance suite
1112
- `fix/stale-marker-blocking-tester`: Stale `.active-*` marker race condition blocking tester dispatch — reorder `finalize_trace` before timeout-heavy ops in check-implementer.sh and check-guardian.sh, add marker cleanup in `refinalize_trace()`, add completed-status fast path in task-track.sh Gate B
1213

1314
### Added

hooks/context-lib.sh

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@
1111
# library eliminates drift and reduces maintenance surface. Functions are
1212
# exported for subshell access. Cache keyed on HEAD+mod_time for performance.
1313
#
14+
# @decision DEC-SIGPIPE-001
15+
# @title Replace echo|grep and awk|head pipe patterns with SIGPIPE-safe equivalents
16+
# @status accepted
17+
# @rationale Under set -euo pipefail, any pipe where the reader closes before the
18+
# writer finishes (SIGPIPE) propagates exit 141 and kills the hook. Two patterns
19+
# were dangerous: (1) `echo "$var" | grep -qE` in tight while-read loops over
20+
# large plan sections — each spawns a subshell+pipe, and on macOS the shell
21+
# delivers SIGPIPE to the writer when grep exits early; (2) multi-stage pipes
22+
# like `grep | tail | sed | paste` in get_research_status(). Fixes applied:
23+
# Pattern B — replace `echo "$_line" | grep -qE 'pat'` with `[[ "$_line" =~ pat ]]`
24+
# (no subshell, no pipe). Pattern E — replace multi-stage pipe with a single awk
25+
# program that collects, filters, and formats in one process. See DEC-SIGPIPE-001
26+
# in session-init.sh for Pattern A (awk|head → inline awk limit) and Pattern C
27+
# (echo|sed → bash parameter expansion).
28+
#
1429
# Provides:
1530
# get_git_state <project_root> - Populates GIT_BRANCH, GIT_DIRTY_COUNT,
1631
# GIT_WORKTREES, GIT_WT_COUNT
@@ -127,7 +142,10 @@ get_plan_status() {
127142
if [[ -n "$_active_section" ]]; then
128143
local _in_init=false _init_status=""
129144
while IFS= read -r _line; do
130-
if echo "$_line" | grep -qE '^\#\#\#\s+Initiative:'; then
145+
# Pattern B: [[ =~ ]] replaces echo "$_line" | grep -qE (DEC-SIGPIPE-001).
146+
# Each grep in a tight read loop spawns a subshell+pipe; when the section
147+
# is large (1000+ lines), any broken pipe propagates exit 141 under pipefail.
148+
if [[ "$_line" =~ ^'###'[[:space:]]+'Initiative:' ]]; then
131149
# Finalize previous initiative
132150
if [[ "$_in_init" == "true" ]]; then
133151
if [[ "$_init_status" == "active" ]]; then
@@ -138,12 +156,13 @@ get_plan_status() {
138156
fi
139157
_in_init=true
140158
_init_status=""
141-
elif [[ "$_in_init" == "true" && -z "$_init_status" ]] && \
142-
echo "$_line" | grep -qE '^\*\*Status:\*\*'; then
159+
elif [[ "$_in_init" == "true" && -z "$_init_status" && "$_line" =~ ^\*\*Status:\*\* ]]; then
143160
# First Status line after the Initiative header is the initiative status
144-
if echo "$_line" | grep -qiE 'active'; then
161+
# Case-insensitive match via [[ =~ ]] — bash 3.2 compatible (no ${var,,}).
162+
# macOS ships bash 3.2 which lacks ,, (lowercase) operator.
163+
if [[ "$_line" =~ [Aa]ctive ]]; then
145164
_init_status="active"
146-
elif echo "$_line" | grep -qiE 'completed'; then
165+
elif [[ "$_line" =~ [Cc]ompleted ]]; then
147166
_init_status="completed"
148167
fi
149168
fi
@@ -635,7 +654,21 @@ get_research_status() {
635654
RESEARCH_EXISTS=true
636655
RESEARCH_ENTRY_COUNT=$(grep -c '^### \[' "$log" 2>/dev/null || true)
637656
RESEARCH_ENTRY_COUNT=${RESEARCH_ENTRY_COUNT:-0}
638-
RESEARCH_RECENT_TOPICS=$(grep '^### \[' "$log" | tail -3 | sed 's/^### \[[^]]*\] //' | paste -sd ', ' - 2>/dev/null || echo "")
657+
# Pattern E: replace grep|tail|sed|paste multi-stage pipe with awk (DEC-SIGPIPE-001).
658+
# Multi-stage pipes under set -euo pipefail can SIGPIPE when upstream produces more
659+
# output than downstream reads. awk handles the full pipeline in one process: collect
660+
# matching lines into an array, print the last 3 joined by ', '.
661+
RESEARCH_RECENT_TOPICS=$(awk '/^\#\#\# \[/{
662+
# Strip the "### [date] " prefix: remove up to and including first "] "
663+
sub(/^\#\#\# \[[^]]*\] /, "")
664+
topics[++n] = $0
665+
}
666+
END {
667+
start = (n > 3) ? n - 2 : 1
668+
sep = ""
669+
for (i = start; i <= n; i++) { printf "%s%s", sep, topics[i]; sep = ", " }
670+
print ""
671+
}' "$log" 2>/dev/null || echo "")
639672
}
640673

641674
# --- Constants ---
@@ -2487,18 +2520,19 @@ compress_initiative() {
24872520

24882521
# Extract the initiative block from Active Initiatives section
24892522
# Block starts at "### Initiative: <name>" and ends before the next "### Initiative:" or "## "
2523+
# Pattern B: [[ =~ ]] replaces echo "$_line" | grep -qE throughout this function (DEC-SIGPIPE-001).
24902524
local _init_block=""
24912525
local _in_block=false
24922526
local _started_line
24932527
while IFS= read -r _line; do
2494-
if echo "$_line" | grep -qE "^### Initiative: ${init_name}$"; then
2528+
if [[ "$_line" == "### Initiative: ${init_name}" ]]; then
24952529
_in_block=true
24962530
_init_block="${_line}"$'\n'
24972531
continue
24982532
fi
24992533
if [[ "$_in_block" == "true" ]]; then
25002534
# Stop at next ### Initiative: or ## section header
2501-
if echo "$_line" | grep -qE '^### Initiative:|^## '; then
2535+
if [[ "$_line" =~ ^'### Initiative:'|^'## ' ]]; then
25022536
break
25032537
fi
25042538
_init_block+="${_line}"$'\n'
@@ -2533,33 +2567,33 @@ compress_initiative() {
25332567
local _appended=false
25342568

25352569
while IFS= read -r _line; do
2536-
# Track section boundaries
2537-
if echo "$_line" | grep -qE '^## Active Initiatives'; then
2570+
# Track section boundaries — Pattern B: [[ =~ ]] replaces echo|grep-qE (DEC-SIGPIPE-001)
2571+
if [[ "$_line" == "## Active Initiatives" ]]; then
25382572
_in_active=true
25392573
_in_completed=false
25402574
printf '%s\n' "$_line" >> "$_tmp_file"
25412575
continue
25422576
fi
2543-
if echo "$_line" | grep -qE '^## Completed Initiatives'; then
2577+
if [[ "$_line" == "## Completed Initiatives" ]]; then
25442578
_in_active=false
25452579
_in_completed=true
25462580
printf '%s\n' "$_line" >> "$_tmp_file"
25472581
continue
25482582
fi
2549-
if echo "$_line" | grep -qE '^## ' && ! echo "$_line" | grep -qE '^## Active|^## Completed'; then
2583+
if [[ "$_line" =~ ^'## ' && "$_line" != "## Active Initiatives" && "$_line" != "## Completed Initiatives" ]]; then
25502584
_in_active=false
25512585
_in_completed=false
25522586
fi
25532587

25542588
# In Active section: skip the target initiative block
25552589
if [[ "$_in_active" == "true" ]]; then
2556-
if echo "$_line" | grep -qE "^### Initiative: ${init_name}$"; then
2590+
if [[ "$_line" == "### Initiative: ${init_name}" ]]; then
25572591
_skip_block=true
25582592
continue
25592593
fi
25602594
if [[ "$_skip_block" == "true" ]]; then
2561-
# End of block: next ### Initiative: or ## section
2562-
if echo "$_line" | grep -qE '^### Initiative:|^## '; then
2595+
# End of block: next ### Initiative: or ## section header
2596+
if [[ "$_line" =~ ^'### Initiative:'|^'## ' ]]; then
25632597
_skip_block=false
25642598
# Don't skip this line — it starts the next block
25652599
printf '%s\n' "$_line" >> "$_tmp_file"
@@ -2569,11 +2603,11 @@ compress_initiative() {
25692603
fi
25702604
fi
25712605

2572-
# In Completed section: append compressed row after the table header if not yet done
2606+
# In Completed section: append compressed row after the table separator if not yet done
25732607
if [[ "$_in_completed" == "true" && "$_appended" == "false" ]]; then
25742608
printf '%s\n' "$_line" >> "$_tmp_file"
2575-
# After the header row (| --- | line), append the compressed row
2576-
if echo "$_line" | grep -qE '^\|[-| ]+\|'; then
2609+
# After the separator row (| --- | line), append the compressed row
2610+
if [[ "$_line" =~ ^\|[-\ |]+\| ]]; then
25772611
printf '%s\n' "$_compressed_row" >> "$_tmp_file"
25782612
_appended=true
25792613
fi

hooks/session-init.sh

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ set -euo pipefail
2020
_HOOKS_DIR="$(dirname "$0")"
2121
for _lib in source-lib.sh log.sh context-lib.sh; do
2222
if ! bash -n "$_HOOKS_DIR/$_lib" 2>/dev/null; then
23-
_SYNTAX_ERR=$(bash -n "$_HOOKS_DIR/$_lib" 2>&1 | head -3)
23+
# Pattern A: avoid bash -n | head -3 SIGPIPE; capture all stderr and truncate in bash
24+
_SYNTAX_ERR=$(bash -n "$_HOOKS_DIR/$_lib" 2>&1 || true)
25+
_SYNTAX_ERR="${_SYNTAX_ERR:0:500}" # truncate to ~3 lines worth inline
2426
_HAS_MARKERS=$(grep -c '^<\{7\}\|^=\{7\}\|^>\{7\}' "$_HOOKS_DIR/$_lib" 2>/dev/null || echo 0)
2527
_REMEDIATION="Run: bash -n ~/.claude/hooks/$_lib"
2628
[[ "$_HAS_MARKERS" -gt 0 ]] && _REMEDIATION="Merge conflict markers detected in $_lib. Remove <<<<<<< ======= >>>>>>> lines."
@@ -224,13 +226,21 @@ if [[ "$PLAN_EXISTS" == "true" ]]; then
224226
# --- New living-document format: tiered injection ---
225227

226228
# 1. Identity section (~10 lines)
227-
_IDENTITY=$(awk '/^## Identity/{f=1} f && /^## / && !/^## Identity/{exit} f{print}' \
228-
"$_PLAN_FILE" 2>/dev/null | head -15)
229+
# @decision DEC-SIGPIPE-001
230+
# @title Move head -N limit into awk to prevent SIGPIPE with set -euo pipefail
231+
# @status accepted
232+
# @rationale `awk ... | head -N` causes SIGPIPE when the section is larger than N
233+
# lines: head closes the pipe after N lines, awk gets SIGPIPE, and set -euo pipefail
234+
# propagates exit 141 killing the hook. Fix: embed the line limit directly in awk
235+
# using a counter (`if(++c<=N) print; else exit`). awk sees EOF normally, no SIGPIPE.
236+
# Applied to all awk|head patterns on MASTER_PLAN.md (Pattern A).
237+
_IDENTITY=$(awk '/^## Identity/{f=1} f && /^## / && !/^## Identity/{exit} f{if(++c<=15) print; else exit}' \
238+
"$_PLAN_FILE" 2>/dev/null)
229239
[[ -n "$_IDENTITY" ]] && CONTEXT_PARTS+=("$_IDENTITY")
230240

231241
# 2. Architecture section (~10 lines)
232-
_ARCH=$(awk '/^## Architecture/{f=1} f && /^## / && !/^## Architecture/{exit} f{print}' \
233-
"$_PLAN_FILE" 2>/dev/null | head -15)
242+
_ARCH=$(awk '/^## Architecture/{f=1} f && /^## / && !/^## Architecture/{exit} f{if(++c<=15) print; else exit}' \
243+
"$_PLAN_FILE" 2>/dev/null)
234244
[[ -n "$_ARCH" ]] && CONTEXT_PARTS+=("$_ARCH")
235245

236246
# 3. Active Initiatives: compact summary (REQ-P0-004 — bounded under 250 lines total)
@@ -246,8 +256,9 @@ if [[ "$PLAN_EXISTS" == "true" ]]; then
246256
# the 250-line target (REQ-P0-004). T13 caught this with realistic fixtures.
247257
# Fix: extract name+status+goal+phase-counts (~5 lines per initiative) rather
248258
# than dumping the entire block. Full detail is always available via Read tool.
249-
_ACTIVE_HEADER=$(awk '/^## Active Initiatives/{found=1; print; next} found && /^## /{exit} found{print}' \
250-
"$_PLAN_FILE" 2>/dev/null | head -3)
259+
# Pattern A: limit embedded in awk to avoid SIGPIPE (DEC-SIGPIPE-001)
260+
_ACTIVE_HEADER=$(awk '/^## Active Initiatives/{found=1; print; next} found && /^## /{exit} found{if(++c<=3) print; else exit}' \
261+
"$_PLAN_FILE" 2>/dev/null)
251262
[[ -n "$_ACTIVE_HEADER" ]] && CONTEXT_PARTS+=("$_ACTIVE_HEADER")
252263

253264
# Parse each ### Initiative: block for a compact summary
@@ -288,21 +299,29 @@ if [[ "$PLAN_EXISTS" == "true" ]]; then
288299
# Phase header — next Status: belongs to this phase
289300
_IN_PHASE=true
290301
elif [[ -n "$_CUR_INIT" ]]; then
291-
if [[ "$_IN_PHASE" == "true" ]] && echo "$_line" | grep -qE '^\*\*Status:\*\*'; then
302+
# Pattern B: [[ =~ ]] replaces echo "$_line" | grep -qE to avoid SIGPIPE
303+
# (DEC-SIGPIPE-001). Each grep spawns a subshell and pipe; in a tight
304+
# read loop over thousands of lines, any broken pipe propagates exit 141.
305+
# Pattern C: bash parameter expansion replaces echo "$_line" | sed for
306+
# status/goal extraction — no subshell, no pipe, no SIGPIPE risk.
307+
if [[ "$_IN_PHASE" == "true" && "$_line" =~ ^\*\*Status:\*\* ]]; then
292308
# Phase-level status — count it
293-
if echo "$_line" | grep -qE '\bplanned\b'; then
309+
if [[ "$_line" =~ [[:space:]]planned([[:space:]]|$) ]]; then
294310
_PLANNED_PHASES=$((_PLANNED_PHASES + 1))
295-
elif echo "$_line" | grep -qE '\bin-progress\b'; then
311+
elif [[ "$_line" =~ [[:space:]]in-progress([[:space:]]|$) ]]; then
296312
_INPROG_PHASES=$((_INPROG_PHASES + 1))
297-
elif echo "$_line" | grep -qE '\bcompleted\b'; then
313+
elif [[ "$_line" =~ [[:space:]]completed([[:space:]]|$) ]]; then
298314
_DONE_PHASES=$((_DONE_PHASES + 1))
299315
fi
300316
_IN_PHASE=false # consumed
301-
elif [[ "$_IN_PHASE" == "false" ]] && echo "$_line" | grep -qE '^\*\*Status:\*\*'; then
302-
# Initiative-level status
303-
_CUR_STATUS=$(echo "$_line" | sed 's/\*\*Status:\*\*[[:space:]]*//')
304-
elif echo "$_line" | grep -qE '^\*\*Goal:\*\*'; then
305-
_CUR_GOAL=$(echo "$_line" | sed 's/\*\*Goal:\*\*[[:space:]]*//')
317+
elif [[ "$_IN_PHASE" == "false" && "$_line" =~ ^\*\*Status:\*\* ]]; then
318+
# Initiative-level status — Pattern C: parameter expansion strips prefix
319+
_CUR_STATUS="${_line#\*\*Status:\*\* }"
320+
_CUR_STATUS="${_CUR_STATUS#\*\*Status:\*\*}"
321+
elif [[ "$_line" =~ ^\*\*Goal:\*\* ]]; then
322+
# Pattern C: parameter expansion strips **Goal:** prefix
323+
_CUR_GOAL="${_line#\*\*Goal:\*\* }"
324+
_CUR_GOAL="${_CUR_GOAL#\*\*Goal:\*\*}"
306325
fi
307326
fi
308327
done <<< "$_ACTIVE_SECTION"
@@ -321,9 +340,10 @@ if [[ "$PLAN_EXISTS" == "true" ]]; then
321340
fi
322341

323342
# 5. Completed Initiatives: one-liner table rows only (not full blocks)
324-
# || true: grep returns 1 when table is empty; pipefail would kill the script.
325-
_COMPLETED_ROWS=$(awk '/^## Completed Initiatives/{f=1} f{print}' \
326-
"$_PLAN_FILE" 2>/dev/null | grep -E '^\|' | grep -vE '^\|\s*Initiative\s*\||\|\s*-+\s*\|' | head -60 || true)
343+
# Pattern A: limit embedded in awk (c<=60) to avoid SIGPIPE (DEC-SIGPIPE-001).
344+
# awk also filters header/separator rows inline, removing the grep|head pipeline.
345+
_COMPLETED_ROWS=$(awk '/^## Completed Initiatives/{f=1; next} f && /^\|/ && !/^\|\s*Initiative\s*\|/ && !/\|\s*-+\s*\|/ {if(++c<=60) print; else exit}' \
346+
"$_PLAN_FILE" 2>/dev/null || true)
327347
if [[ -n "$_COMPLETED_ROWS" ]]; then
328348
_COMPLETED_COUNT=$(echo "$_COMPLETED_ROWS" | wc -l | tr -d ' ')
329349
CONTEXT_PARTS+=("Completed initiatives (${_COMPLETED_COUNT}):")
@@ -345,7 +365,8 @@ if [[ "$PLAN_EXISTS" == "true" ]]; then
345365
fi
346366
else
347367
# --- Old format: preamble + phase count (backward compatibility) ---
348-
PREAMBLE=$(awk '/^---$|^## Original Intent/{exit} {print}' "$_PLAN_FILE" | head -30)
368+
# Pattern A: limit embedded in awk to avoid SIGPIPE (DEC-SIGPIPE-001)
369+
PREAMBLE=$(awk '/^---$|^## Original Intent/{exit} {if(++c<=30) print; else exit}' "$_PLAN_FILE")
349370
[[ -n "$PREAMBLE" ]] && CONTEXT_PARTS+=("$PREAMBLE")
350371

351372
if [[ "$PLAN_LIFECYCLE" == "dormant" ]]; then

0 commit comments

Comments
 (0)