Skip to content

fix: SIGPIPE under pipefail made the chart version guard skip a real chart change - #522

Merged
LukasWodka merged 1 commit into
developfrom
fix/sigpipe-fail-open-guards
Jul 31, 2026
Merged

fix: SIGPIPE under pipefail made the chart version guard skip a real chart change#522
LukasWodka merged 1 commit into
developfrom
fix/sigpipe-fail-open-guards

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

Three producer | early-exiting-consumer pipelines ran under set -o pipefail. When the consumer exits on its first match/line it closes the pipe; once the producer still has more than the ~64KB pipe buffer to write it takes SIGPIPE, and the pipeline exits 141 — a success case reported as a failure.

Fixes two Bugbot findings from the #519 review. Restructured to remove the pipes rather than sprinkling || true, which would only trade one fail-open for another.

SiteChange
.github/workflows/chart-version-guard.ymlClassify the changed-file list with a bash read loop + case — no pipe, no subprocess. Plus explicit fail-closed::error:: paths for a missing base SHA / failed git diff.
scripts/lib/install-client-helm.sh · _pull_failure_detailprintf … | head -n 3head -n 3 <<< "$bad"
scripts/lib/gpu-nvidia.sh · _gpu_stack_signature… | head -1 || true → capture whole, first line via ${var%%$'\n'*}, || var="" for the absent/timeout contract

scripts/manifest.sha256 regenerated with scripts/gen-manifest.sh (R8) — never hand-edited.

Why finding 1 is the serious one: the guard failed OPEN

In the guard the 141 lands on an if ! branch, so a genuine client/templates/** edit was reported as "guard N/A" and the version-bump check was skipped — waving through exactly the dark ship the gate was added to stop (PR #472 / the perIngestionTables flag block).

Evidence

Threshold — measured on the runner's own toolchain (ubuntu:24.04, bash 5.2.21, GNU grep 3.11, coreutils 9.4)

Pipeline exit status under set -o pipefail, producer = printf, match on line 1:

changed-file listgrep -q (match)head -n 3head -1
7,222 B000
65,622 B141141141
220,922 B141141141
1,488,922 B141141141

Finding 1 — end-to-end, real git repos, the workflow's run: block extracted verbatim from the YAML

Chart template changed, version NOT bumped ⇒ the gate must exit 1. Padding placed in docs/ so it sorts afterclient/templates/** (the match lands on line 1, with the rest of the stream still to write):

F1 21 paths / 903 B match line 1/21 OLD exit=1 NEW exit=1
F2 1301 paths / 58,723 B match line 1/1301 OLD exit=0 ⇐ FAIL-OPEN NEW exit=1 ✓
F3 4001 paths / 182,923 B match line 1/4001 OLD exit=0 ⇐ FAIL-OPEN NEW exit=1 ✓

OLD exit=0 printed No chart template/values change in this PR — guard N/A. — with a template edit sitting in the diff and no version bump.

The mirror direction is broken too. A SIGPIPE on the grep -qx 'client/Chart.yaml'match short-circuits the &&, so a PR that did bump the version gets failed with a message asserting it did not (client/Chart.yaml sorts first in byte order, so the match is on line 1):

E template + bump + 1300 pad / 95,141 B OLD exit=1 ⇐ false positive NEW exit=0 ✓

Gate semantics are otherwise preserved — all five control cases agree:

A template changed, NO bump OLD=1 NEW=1 (blocks)
B template changed + bump OLD=0 NEW=0 (passes)
C no chart change OLD=0 NEW=0 ("guard N/A")
D tmpl + NO bump + 1300 pad OLD=1 NEW=1 (blocks)

Finding 2 — _pull_failure_detail

The real function, extracted from the lib, set -euo pipefail, mocked kubectl emitting 20,000 ImagePullBackOff pods (1,188,889 B). One process per shape so no outer || can suspend errexit:

body=pipe (old) call=$(f) x509 reason emitted ✓
body=pipe (old) call=$(f || true) x509 reason emitted ✓
body=pipe (old) call=f > file *** ABORTED exit=141 — x509 reason LOST ***
body=here (new) call=$(f) x509 reason emitted ✓
body=here (new) call=$(f || true) x509 reason emitted ✓
body=here (new) call=f > file x509 reason emitted ✓

Isolating the inner pipeline confirms the mechanism in every redirection shape:

bad= 335 B pipe>/dev/null=0 pipe>file=0 pipe-in-$()=0 herestring=0
bad= 116,889 B pipe>/dev/null=141 pipe>file=141 pipe-in-$()=141 herestring=0
bad=1,188,889 B pipe>/dev/null=141 pipe>file=141 pipe-in-$()=141 herestring=0

Finding 2 — _gpu_stack_signature (behaviour-preserving hardening)

Identical output old vs new across five probe shapes, including 20,000 lines (1.1 MB) of chatty nvidia-ctk output:

mode=normal old=[…1.15.0|550.54.14] new=[…1.15.0|550.54.14]
mode=multi old=[…1.15.0|550.54.14] new=[…1.15.0|550.54.14]
mode=absent old=[] new=[]
mode=timeout old=[] new=[]
mode=chatty old=[1.15.0|550.54.14] new=[1.15.0|550.54.14]

Gates

  • bats scripts/tests/*.bats669/669 pass, 0 failures (incl. the three _gpu_stack_signature tests and set -e safe with no versions (#431 Bugbot))
  • shellcheck --severity=warning on both touched libs — clean; the only hit is the pre-existing cross-file SC2034 K3D_GPU_FLAGS, byte-identical on develop
  • shellcheck --severity=error on the full CI file set — clean
  • actionlint .github/workflows/chart-version-guard.yml — clean
  • bash -n both libs — parse
  • bash scripts/check-style.sh — clean
  • scripts/gen-manifest.sh --check — up to date (failed before regeneration, as expected)
  • New constructs re-verified on bash 3.2.57 (the macOS installer floor)

Two premises that did not hold up

  1. "especially on large release-train diffs" overstates the reachability for this repo. The threshold is ~65 KB of changed paths (~900 files). tracebloc/client has 175 tracked files total, 5,756 bytes of full path list — so even a diff touching every file is ~11× under the threshold, and the largest --name-only list in the last 400 merges to main is 3,819 bytes. The fail-open is real and correctly diagnosed, but latent today, not an active dark-ship. It goes live if client/charts/ ever gains vendored subcharts or the repo grows. Fixing it because a guard must fail closed by construction, not because the repo happens to be small.

  2. _pull_failure_detail does not currently lose the x509 reason. The finding says the function "exits before emitting the scoped pull events". At the only call site — install-client-helm.sh:575, fail_detail="$(_pull_failure_detail "$ns" || true)" — the || true suspends errexit for the entire function body, so the 141 never aborts anything and the x509 line is still emitted (proven above: call=$(f || true) emits it even at 1.19 MB). The abort is only reachable from a bare-statement call. It also needs ~1,300+ pods in ImagePullBackOff in one namespace. Worth fixing because returning 1 for "no pull failure" makes a bare call the natural next refactor — which would then abort the install mid-step with a bare 141.

Also spotted, deliberately not touched (out of scope)

scripts/lib/install-client-helm.sh:158, _extract_yaml_value:

line=$(grep -E "^${key}:""$file"2>/dev/null | head -1)

Same shape, but the latent hazard is a different one and unrelated to SIGPIPE: when the key is absent, grep exits 1, pipefail makes the pipeline 1, and the assignment returns 1 — yet line 159 ([[ -z "$line" ]] && return) shows "not found" is an expected path, so that line is unreachable in the shape that fails.

Measured, same method as above:

bare call, key absent, errexit live -> OUTER EXIT=1 (aborts; line 159 never runs)
v="$(probe … )", key absent <- real shape -> OUTER EXIT=0 (survives, returns "")

All three live call sites (lines 209, 651, 652) use the $( ) form, so this is latent, not live — exactly the same masking as finding 2. Flagged for its own ticket rather than folded in here, since it needs a different fix (|| line="", not a herestring) and is outside both findings' scope.

Notes

Test plan

  • bats scripts/tests/*.bats — 669/669
  • shellcheck --severity=error (CI set) + --severity=warning (touched files)
  • actionlint on the changed workflow
  • bash scripts/check-style.sh
  • scripts/gen-manifest.sh --check
  • Old-vs-new differential on real git repos for the guard (5 cases + 3 fail-open cases)
  • Old-vs-new differential for both installer functions on ubuntu:24.04 and bash 3.2

🤖 Generated with Claude Code

…ange
Three `producer | early-exiting-consumer` pipelines ran under `set -o pipefail`.
When the consumer exits on its first match/line it closes the pipe, and once the
producer has more than the ~64KB pipe buffer left to write it takes SIGPIPE and
the pipeline exits 141 — a *success* case reported as failure. Measured on
ubuntu-24.04 (bash 5.2.21 / GNU grep 3.11 / coreutils 9.4): 65,622 bytes is
already enough.
WHY this matters most in the chart guard: there the 141 lands on the `if !`
branch, so a genuine `client/templates/**` edit is reported as "guard N/A" and
the version-bump check is SKIPPED — the guard fails OPEN and waves through
exactly the dark ship it was added to stop (PR #472 / the perIngestionTables
flag block). Demonstrated end-to-end on a real repo: at 1,301 changed paths
(58,723 bytes) with a template edit and NO version bump, the old body exits 0
"guard N/A"; the new one exits 1 and blocks. The mirror direction is broken too:
a SIGPIPE on the `grep -qx client/Chart.yaml` MATCH short-circuits the `&&`, so
a PR that DID bump the version is failed with a message that says it did not.
Restructured rather than papered over with `|| true`, which would only convert a
fail-open into a different fail-open:
- chart-version-guard.yml — classify the changed-file list with a bash `read`
loop + `case` (no pipe, no subprocess, so neither SIGPIPE nor a grep rc=2 can
be mistaken for "no match"), and fail CLOSED with ::error:: on a missing base
SHA or a failed `git diff`: "don't know" must never read as "nothing changed".
- _pull_failure_detail — `head -n 3 <<< "$bad"`. With errexit live the old
pipeline aborted the function AT that line, dropping the scoped pull event
underneath it, i.e. the one actionable reason (x509 / blocked registry / auth).
- _gpu_stack_signature — capture whole, take the first line with `%%$'\n'*`. This
site was NOT reachable in practice (the trailing `|| true` already absorbed the
141), but that `|| true` swallowed every real failure code alike; `|| ..._out=""`
states the actual contract (absent tool / timeout ⇒ empty ⇒ don't cache).
Behaviour is unchanged below the buffer threshold: 669/669 bats tests pass, and
_gpu_stack_signature is byte-identical across normal / multi-line / absent /
timeout / 20k-line-chatty probe output. Verified on bash 3.2 (macOS floor) too.
scripts/manifest.sha256 regenerated via scripts/gen-manifest.sh (R8).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Jul 31, 2026
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

👋 Heads-up — Code review queue is at 33 / 30

Above the WIP limit. The team convention is to review existing PRs before opening new work.

Open PRs currently in Code review (oldest first):

Pull from review before opening new work. (This is a nudge from the kanban WIP check, not a block.)

@cursorcursorBot 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit e023105. Configure here.

@LukasWodka
LukasWodka merged commit cd067bd into developJul 31, 2026
43 checks passed
LukasWodka added a commit that referenced this pull request Jul 31, 2026
develop advanced between resolving the first merge and pushing it: #521 (macOS
installs pinned kubectl/k3d/helm via the shared verified path) and #522 (SIGPIPE
under pipefail made the chart guard skip a real chart change). Both touch
scripts/, so the install-k8s.ps1 hash line in scripts/manifest.sha256 collided
again — the fifth time today.
Only the manifest conflicted. Neither PR touches scripts/lib/preflight.sh
(they changed setup-macos.sh, gpu-nvidia.sh, install-client-helm.sh), so there is
no semantic overlap with this branch's memory work; re-verified that
_pf_total_mem_kb is still undefined and that no file carries conflict markers.
Gates re-run on the merged tree, not carried over from the previous merge:
bats scripts/tests/*.bats -> plan 692, ok 692, not ok 0 (complete TAP run; the
suite grew from 683 because #521 added setup-macos.bats);
shellcheck --severity=error over the CI file set -> rc=0; Pester -> 403 passed /
0 failed; check-style clean; check-drift no drift; gen-manifest.sh --check current.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

/fr-pass

shujaatTracebloc pushed a commit that referenced this pull request Aug 5, 2026
…able (#525)
* fix(installer): make the absent-key path in _extract_yaml_value reachable
`_extract_yaml_value` piped into `grep`. On an ABSENT key grep exits 1; under
`set -o pipefail` that rc propagates out of the pipeline and out of the
assignment, so under `set -e` the function aborts at the assignment — making
the very next line, `[[ -z "$line" ]] && return`, unreachable in exactly the
shape it exists to handle.
Latent, not live: all three call sites (lines 209, 651, 652) use the `$( )`
command-substitution form, which suspends errexit for the function body. But
the documented contract is "empty when the key is absent", so a bare call is
the natural next refactor — and it would abort the install mid-step.
Fix is the house idiom already used in assess.sh and common.sh
`_chart_version`: `|| line=""` on the assignment. Catching any non-zero also
keeps the path reachable if `head -1` ever SIGPIPEs grep (141), the sibling
shape fixed in #522. Contract written down above the function.
Verified (bash 3.2.57, GNU grep):
- bare call, absent key, errexit live -> before: exit 1 (aborts, `return`
never runs) · after: exit 0, empty output, execution continues
- `v="$(_extract_yaml_value …)"`, absent key -> exit 0, "" (unchanged)
- found-key, quoting, and unreadable-file paths unchanged
Adds a bats case pinning the BARE-statement call under `set -euo pipefail`.
Mutation-tested: it fails against the unfixed function, so it cannot rot into
a no-op. Regenerated scripts/manifest.sha256 (R8 gate).
Fixes#523
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ci: re-trigger — no workflow fired on the PR-open event (empty commit)
Actions dispatched nothing for this PR: 0 runs on the branch 10 minutes after
open, while a sibling PR opened 3 minutes later got all 7. Not a paths/types
filter (standard-checks + chart-version-guard have no paths filter and also
did not fire), not a draft, not an incident (status green), and PR head ==
remote head == local head. GitHub-side miss on the open event; `synchronize`
re-dispatches all six gating workflows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(installer): drop head from the pipeline — a duplicate key must keep its value (Bugbot, #525)
The first fix (`grep | head -1 || line=""`) traded one failure for another:
on a DUPLICATE key, head exits after the first line and SIGPIPEs grep (141);
under pipefail the `|| line=""` fallback then wiped the successfully captured
value, so detect_installed_client could miss a clientId and fail open toward
overwrite. Capture every match and take the first line in the shell
(`${line%%$'\n'*}`) — no downstream consumer, so grep's rc is 1 exactly when
there is no match, which is the one case the fallback exists for. Regression
test pins the duplicate-key bare-call shape; manifest regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka
LukasWodka deleted the fix/sigpipe-fail-open-guards branch August 14, 2026 13:53
Sign up for freeto 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.

3 participants

@LukasWodka@saadqbal@claude