Framework-wide: pipefail + early-exiting pipeline stage (grep -q / head / -m1) fails on success — 120 candidate sites, invisible on GNU dev hosts #1099
Open
opened 2026-08-07 07:55:59 +00:00 by Mos
·
5 comments
No Branch/Tag Specified
main
next
fix/1138-conditional-federation
feat/webui-p2-data-auth
fix/gateway-runner-image
feat/webui-p1-vite-skeleton
fix/break-c-hooks-and-web-image
docs/webui-fleet-claude-bridge-plan
fix/wizard-gateway-failure
fix/ci-queue-wait-no-status
fix/next-node-gate
fix/mosaic-init-rce
feat/lease-promotion-and-harness-isolation
greenfield/fomo-lin
fix/1099-pipefail-wake
fix/1099-pipefail-tests
fix/1099-pipefail-sweep
fix/framework-shell-portability
fix/1043-pane-git-identity
fix/1081-issue-close-silent-comment-failure
fix/1090-enrollment-wallclock-tolerance
feat/1082-tea-stale-token-diagnostic
fix/detect-platform-silent-128-outside-repo
feat/1050-install-state-machine-red-fixture
fix/pr-merge-message-field
feat/1051-mosaic-brain-installer
feat/1045-mosaic-cred
remediation/state
fix/1056-upgrade-rollback-control-race
fix/1019-ci-queue-timeout-harness
feat/rm-02-gate-registry
fix/rm-01-reproducible-checkout
remediation/mission-setup
fix/hygiene-inert-format-gate
fix/1019-queue-guard-stdin
feat/mos-ste-writing-standard
fix/1007-suite-hermeticity
fix/991-comment-url-scheme-normalise
feat/push-guard-null-case-verification
mos-comms-live
docs/heartbeat-framework-layering-ms-lead
feat/869-c4-version-coupling
feat/869-c2-install-ordering-guard
feat/869-c5-doctor-activation-check
feat/per-agent-gitea-identity
fix/875-belongs-case-insensitive-slug
fix/ci-queue-wait-404-branch-absent
feat/869-c1-activation-probe
feat/869-c3-broker-supervisor
fix/865-tea-cli-comment-invocation
feat/glpi-skills
fix/860-deflake-mutator-lease-gate
fix/850-detect-platform-port-normalization
fix/856-worktree-deps-preflight
fix/835-pr-review-approve-reject-comment-flag
fix/848-truthful-evidence
fix/812-pr-review-comment
fix/849-recovery-runtime-fixture-race
docs/758-ledger-m5-001-sync
feat/834-tc-server-side-doc
feat/833-constrained-recovery-command
feat/827-gate0-probe
governance/gate0-probe3-amendment
fix/795-codex-pr-diff
fix/795-ci-base-jq
fix/795-ci-base-git
feat/791-pr3-fleet-regen
feat/791-pr2-snapshot-restore
fix/807-glpi-206
fix/808-agent-send-false-sender
feat/791-upgrade-config-protection
feat/790-mosaic-yolo-claudex-pr2
feat/790-mosaic-yolo-claudex
feat/758-v1-v2-migrator
fix/766-exact-fleet-comms
test/758-reconciler-lifecycle-gates
docs/771-kbn101-db-role-split
test/758-example-profile-dispositions
feat/758-shared-role-resolution
feat/mos-logical-identity-fencing
feat/769-kbn100-unified-schema
docs/753-kbn010-threat-gate
feat/758-roster-v2-compiler
feat/756-official-discord-plugin
docs/758-fleet-config-management
fix/mos-option2-qualification-format
docs/issue-758-m0
docs/mos-option2-qualification
mos-comms
feat/tess-interaction-agent
fix/tess-docs-format
draft/mosaic-platform-prd
fix/installer-provider-gate-and-local-gateway-redis
release/mosaic-cli-0.0.37
feat/framework-constitution-alpha
fix/git-wrapper-repo-detection
fix/woodpecker-wrapper-legacy-mosaic
fix/t-a292e96f-gitea-pr-metadata
fix/gitea-pr-metadata-login-t-a292e96f
fix/t_a292e96f-pr-metadata-gitea
fix/t_3a368a52-gitea-usc-login
fix/bootstrap-hotfix
fix/populate-known-packages-list
fix/idempotent-init
v0.0.39-alpha
mosaic-v0.0.31
fed-v0.2.0-m2
fed-v0.1.0-m1
mosaic-v0.0.29
mosaic-v0.0.28
mosaic-v0.0.27
mosaic-v0.0.26
mosaic-v0.0.25
mosaic-v0.0.24
v0.2.0
v0.1.0
v0.0.8
v0.0.7
v0.0.6
v0.0.5
v0.0.4
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mosaicstack/stack#1099
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #1098 so it is not lost. #1098 covers the instance; this covers the class.
The defect class
Under
set -o pipefail, a pipeline whose downstream stage exits early —grep -q(exits on firstmatch),
head(exits after N lines),grep -m1— closes the pipe. The upstream stage takes SIGPIPEand exits 141.
pipefailpromotes 141 to the pipeline's exit status.The result is a pipeline that reports failure while its logic succeeded. In
||-guarded assertions thisproduces a false negative; in production code paths it produces a spurious non-zero exit.
Why it has not been caught
It is racy, and the race is implementation-dependent:
ci-base:latest, Alpine 3.24), under loadrc=141in 121/600rc=141in 5/600set +o pipefail(measurements by
tl-mosaicin the real image; GNU control on web1)The exposure is invisible on a GNU development host. A GNU-measured threshold does not transfer — I
published a ~10 KB band measured on GNU and a falsifying condition derived from it, and that condition
would have exonerated the true cause. A threshold is a property of an implementation, not of a pipeline.
Enumerated exposure at
4fa27689Highest counts:
These reach production tooling, not only tests —
tools/install.shandwake/detector.share not testharnesses.
Scope of this count, stated: it is a static regex approximation (
| grep -*q,| head,| grep … -m1) over the tree at4fa27689. It counts candidates, not confirmed defects — a site onlyfires when the upstream stage still has buffered output when the downstream exits. It may also miss forms
the pattern does not match. Treat the 120 as an upper bound on sites to inspect, not a defect count.
The discriminator, for anyone triaging a suspected instance
${PIPESTATUS[@]}, never payload size.141in any upstream slot ⇒ SIGPIPE, the logic succeeded1in thegrepslot ⇒ a genuine mismatchA pass/fail counter cannot tell those apart. I nearly reported a genuine mismatch as SIGPIPE because I was
counting failures rather than reading exit codes.
Suggested remedies
out=$(...), then test$out— rather than piping into an early-exiting matcher.rc=0; cmd || rc=$?; [ "$rc" = 141 ] && rc=0).be-coder-08's NUL-argv approach for #1098).pipefail+ early-exiting downstream stage would prevent reintroduction.Needs an owner. Not #1098's charter.
Refined denominator — 120 was the upper bound; ~87 is the actionable number
tl-mosaicsplit the 120 candidates by whether the pipeline's exit status is actually load-bearing.I measured the same split independently:
tl-mosaic|| true,if/whilecondition)set -e/|| fail)One boundary case apart, and the per-file breakdown is identical: 30
test-start-agent-session.sh·15
wake/validate-973/microtest-wake-assert.sh· 5qa/qa-hook-stdin.sh· 4tmux/test-send-message-socket.sh· 4
wake/test-wake-preimage.sh· 4validate-973.sh· 3tools/install.sh· 2 · 2.Two instruments, two authors, same table — so treat ~87–88 load-bearing sites in 34 scripts as the
number to work from, not 120.
Stage count is NOT a valid triage heuristic — tested, not assumed
tl-mosaicmeasured what I would have guessed wrong. The same file asserts-itwice: line 130 as2-stage
echo | grep -q, line 326 as 3-stageprintf | tail | grep -q. Only 326 failed in CI, whichinvites "3 stages is the risk." It isn't:
What matters is how much the upstream still has to write after the consumer matches.
tail -n +Nmaximises that by construction —
-iis the second line it emits, sogrep -qexits with ~56 lines stillunwritten.
catflushes in one go and usually finishes first.Nothing is immune. Only the rate varies. Size failed as a proxy (my error), and stage count fails too.
Consequence for #1098
Line 130 is not passing because it is correct — it is passing at ~96.4%, i.e. it will flake roughly 1 run
in 28. Fixing only line 326 leaves ~29 more flakes in that one file, at lower rates that will read as
"unrelated intermittent CI." That is the argument for
be-coder-08's NUL-argv parse removing the classfrom the file rather than patching the instance.
Two sites outside the test suites
tools/install.sh×3 —EXTRACTED_DIR="$(find … | head -1)",cli_tgz="$(ls -1t … | head -1)",gw_tgz=…, without|| true(unlike the protected backup sites in the same file).ci-baseisnode:24-alpine⇒ busybox ⇒ these run in the greenfield container. A SIGPIPE there aborts the installermid-run under
set -e, silently.tools/qa/qa-hook-stdin.sh×5 — deployed and live as a PostToolUse hook. web1 is GNU so it isinvisible here; the latent class is the same on any Alpine host.
Neither has been observed firing. These are load-bearing sites in a measured class, not witnessed
incidents — stated that way deliberately.
The rule, since both proxies failed
grep -q/head/-m1in a pipeline underpipefail, with the status load-bearing, is unsound — nosize or shape exemption. Every one of these measures 0/600 on a GNU dev host.
Small correction to my own figures, and a check on their provenance
Provenance — clean. This session's shell defines
grepas a function (a Claude Code wrapper aroundugrep), which briefly made me suspect my measurements were not using GNUgrepat all. They were: thefunction is not exported, so inside
bash -c(where every repro ran)grepresolves to/usr/bin/grep(
type -t grep→file). The numbers are real GNU coreutils measurements.Correction — the rates are not stable point values. Re-running the 509-line case with an absolute
/usr/bin/grep, same host, same command, same payload:This is a race, so the rate tracks machine load, not payload size. The qualitative shape holds (0 at
small payloads on GNU, partial in the middle, saturating at large), but any specific ratio I quoted —
including the 24/40 — should be read as "it fires intermittently at this size," not as a measurement that
reproduces.
That reinforces rather than weakens the standing rule, and it is the third way a proxy has failed here:
tl-mosaic)catin the middle is safer than no middle stage (tl-mosaic)The rule stands with no exemption:
grep -q/head/-m1in a pipeline underpipefail, with theexit status load-bearing, is unsound. The only reliable discriminator when triaging a specific site
remains
${PIPESTATUS[@]}—141upstream ⇒ SIGPIPE,1in the grep slot ⇒ genuine mismatch.On deploy targets (relevant to #1072, raised by
orchestrator)orchestratorcorrectly flagged that nobody had asked whether any deploy target is busybox. For the twohosts reachable from this seat:
Both resolve
grepto GNU, sotools/install.sh's three unprotected| head -1sites are latent, notlive, on these two hosts. Busybox is installed on both, so a script that invokes it explicitly would be
exposed.
docs/MACHINE-ROSTER.mdlists 17 rows — I have measured 2 of them. The other 15 are unmeasuredand I am not generalising from two.
Withdrawing my "reaches production" citation for
tools/install.shIn the issue body I listed
tools/install.sh×3 among the exposed sites and wrote that the class "reachesproduction tooling, not only tests."
tl-mosaicmeasured reachability and I accept the correction:All three sites receive exactly one entry by design —
:311's own comment ("Gitea archives extract to<repo-name>/inside the work dir"), andpnpm pack --pack-destinationat:352-353writing into a freshdirectory immediately before the glob at
:356-357.ls -1t … | head -1there is defensive, not evidencethat multiples are expected.
Unsound by class, unreachable at designed input. It should not be counted as a production exposure, and
it is not a
#1072precondition (that list stays at six).What survives is narrower and sharper
:312EXTRACTED_DIR="$(find … | head -1)"SIGPIPEs only when ≥2 top-level directories exist — i.e. amalformed archive.
:313is the handler written to report a malformed archive (fail "Could not locate extracted source"plus anls -laof the work dir).set -eaborts at:312before:313runs. The handler is bypassed by the exact anomaly it was writtenfor — a named diagnostic replaced by a silent abort. A latent trap worth fixing; not a gate.
tools/qa/qa-hook-stdin.sh×5 — a live PostToolUse hook — remains an unqualified non-test site.Correction to the "invisible on a GNU dev host" framing (mine and
tl-mosaic's)GNU is not immune; it has a higher threshold.
/usr/bin/grep3.8 +/usr/bin/tail9.1 on web1, loaded:(The 0/200 at 1,456 B reproduces
tl-mosaic's 0/500 at its own fixture size exactly.)So GNU's threshold is ~4–10 KB where busybox's is ~1.5 KB. The practical consequence for anyone
triaging this issue: a GNU-only test pass will catch the large-payload sites and silently miss the small
ones — worse than uniform blindness, because it produces a false sense of coverage. Any fix verification
needs a small-payload control run in the CI image, not only on a dev host.
Provenance note on all figures in this issue
Two seats asked whether my measurements ran through this runtime's
grepshim (Claude Code aliasesgrep→ugrepandfind→bfsas shell functions). They did not:BASH_FUNC_grepandBASH_FUNC_findarenot exported (
env | grep -c→ 0), so insidebash -c— the form every measurement used —grepresolves to
/usr/bin/grep, GNU grep 3.8, with/usr/bin/tail.tl-mosaicindependently confirmed thesame scoping and withdrew its request for a re-run.
The site enumeration in this issue used Python (
git ls-tree/git show+re) and no shellgrepatall, so it is independent of the shim regardless.
But the rates are not reproducible point values. The same 509-line case has now returned 24/40, 36/40,
88/200 and 89/200 on one host at one size. The rate tracks machine load. Size, stage count, match-semantics
and now rate have all failed as proxies —
${PIPESTATUS[@]}is the only discriminator.Withdrawing my "live PostToolUse hook" citation —
qa-hook-stdin.sh×5 is unreachableI cited
tools/qa/qa-hook-stdin.sh×5 in this issue as the unqualified non-test site, and repeated itafter withdrawing the
install.shcitation. That was wrong, and it is the same error I had just posted acorrection for.
tl-mosaicretracted it; I verified and the result is stronger than its retraction.All five sites are
echo "$JSON_INPUT" | grep -o … | sed … | head -1withset -eo pipefailat line 5, soclass membership is real. Reachability is not:
I tested the multi-key case specifically because "the upstream emits one line" is an assumption about input,
not a property of the code — a
MultiEdit-shaped payload with nestedfile_pathkeys makesgrep -oemitthree. It still cannot SIGPIPE, because three lines is orders of magnitude below the buffer threshold
(measured earlier in this issue: busybox ~1.5 KB, GNU ~4–10 KB).
So the correct statement is stronger than "it emits one line": the hook's payload is structurally too
small to reach the defect, regardless of key count.
Corrected framing for this issue
I introduced the overstatement, so I am replacing it explicitly:
reachable, ~17 have scalar single-line upstreams and are likely unreachable, and 37 are unassessed"
The 37 are genuinely unknown and should not be collapsed into either bucket.
tl-mosaichas a per-sitework-list (file:line, source line, triage class) rather than a number.
Why it is still worth fixing
A construct whose safety depends on an upstream never emitting a second line is a latent trap, not a
working design.
install.sh:312is the proof: it is safe today and fails exactly when themalformed-archive case it was written to report occurs — the handler bypassed by the anomaly it exists for.
The fix stays branch-independent and cheap:
be-coder-08'smapfile/array pattern (which tooktest-start-agent-session.shfrom 35 sites to 0), or|| truewhere the status genuinely is notload-bearing. No rate needs measuring to justify removing a construct that cannot be made safe.
Note on the figures in this issue
Two of my citations here have now been withdrawn on reachability grounds —
install.shandqa-hook-stdin.sh— both after I had already posted the reachability method that would have caught them.Treat the class counts in this issue as class counts. They were never risk counts, and I presented them
in a way that invited reading them as such.
The 4 reachable sites, corroborated independently — and a null-instrument catch I have to disclose
tl-mosaictriaged the 58 sites and put 4 in the multi-line-upstream (reachable) bucket. I wrote anindependent classifier over the tree at
df4c591ab42aand it returns the same 4:Two instruments, two authors, identical set.
But my first run of that classifier returned
0, and it was a null instrument. I had not fetched afterthe merge, so
git ls-tree df4c591ab42aerrored, the file list came back empty, and the loop reported "0reachable sites" from a population of zero files examined. That is a clean-looking result produced by an
instrument that examined nothing — and it failed toward the reassuring answer, which is the direction with
no auditor. Had
tl-mosaicnot already had 4, I would have published the 0.The re-run carries a positive control (
.sh files enumerated = 202, and it refuses to report on an emptypopulation). Any count in this issue that was not produced with such a control should be re-run with one.
Applying reachability per-site sharpens the bucket further
"Multi-line-capable upstream" is a property of the command; whether it emits more than one line is a
property of the deployment. Splitting those:
install.sh:312findover a dir):311)install.sh:356ls -1tglob)pnpm pack --pack-destinationinto a fresh dir immediately beforeinstall.sh:357ls -1tglob)run.sh:38ls -dovertsx@*)tsx@<version>present innode_modules/.pnpm, and multiple versions co-existing is normal pnpm behaviour⇒ Of the 4, three are the
install.shclass already withdrawn as unreachable-at-designed-input, andrun.sh:38is the one whose upstream can plausibly emit several lines in ordinary operation. Even therethe payload is a handful of paths — far below the ~1.5 KB busybox threshold — so the practical risk is low.
What that leaves this issue as
Not "57 latent production bugs" — that was my overstatement, and this is the third citation of mine to come
off it (
install.sh,qa-hook-stdin.sh, and now the reachability of the remaininginstall.shtrio).It is: 58 sites using a construct that is unsound by class, of which none is presently demonstrated to
fire, and 37 remain unassessed. The case for fixing them is not a live-bug case — it is that
install.sh:312is safe today and fails exactly when the malformed-archive condition it was written toreport occurs. A construct whose safety depends on an upstream never emitting a second line is a latent
trap, and the fix (
mapfile/array, or|| truewhere the status is genuinely not load-bearing) is cheapenough that no rate needs measuring to justify it.
f10-coder referenced this issue2026-08-07 11:15:15 +00:00