fix(lsphp): stop SIGPIPE+pipefail reporting the parity extension as missing
`entrypoint-lsphp.sh` decided whether cac_path_parity was loaded with
printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$'
under `set -euo pipefail`. `grep -q` exits on its first match; printf is still
writing the remaining ~40 KB of `lsphp -i`, takes SIGPIPE, exits 141, and
pipefail prefers 141 over grep's 0. The branch therefore evaluated FALSE
*because the extension was present* — present early enough to stop the reader —
and every affected container fell back to the auto_prepend normaliser that a
customer's own .user.ini silently displaces, i.e. the exact failure the
extension exists to remove. Measured on whp02 against the published
cac-lsphp:php83: 5/5 runs status=141 with pipefail, 0 without.
The race is decided by pipe capacity, which is why it reproduced on whp02 and
not on other daemons: while the payload fits the pipe the writer never blocks
and always finishes first. Forced over the limit it is deterministic — 3x the
same `lsphp -i` (122100 bytes) gives 141 every time in the built image.
Fixed by reading with here-strings, which are not pipelines at all, so there is
no second exit status for pipefail to adopt. Same grep/awk patterns; plumbing
only. Same class fixed everywhere it existed under pipefail:
* entrypoint-lsphp.sh parity probe, and the SCAN_DIR awk probe
* entrypoint-litespeed.sh SCAN_DIR probe (a bare assignment: 141 there does
not degrade, `set -e` kills PID 1), and ols_running
* entrypoint-shared-ols.sh ols_running
* render-shared-ols-config.sh site.meta parsing (`sed | head -1`): measured
141 at 6000 duplicate keys, which under `set -e`
aborts the whole render
* fpm-parity-check.sh the `php-fpm -m` pre-flight, whose whole job is to
stop a harness fault being blamed on the extension
Also: the fallback used to announce "cac_path_parity extension not loadable in
this image" for every reason the branch was reached, including its own plumbing
breaking — a false diagnosis that sends operators to rebuild a good image whose
build gate passed. Verdicts now carry the evidence they rest on, and a probe
that produced nothing is reported as a probe failure that establishes nothing
about the image. Fail-open posture is unchanged: no probe failure is fatal.
Adds scripts/tests/lsphp-info-probe.test.sh, which runs the shipped probes
(extracted verbatim, so they cannot drift from what runs in production) under
`set -euo pipefail` against a realistic ~40 KB phpinfo body, and statically
outlaws the shape repo-wide. Against trunk it fails, naming all 9 offending
lines. Wired into CI as a new Shell-Checks job, because no existing gate ever
executed the entrypoint's branch logic — the .phpt suite and the Dockerfile's
own `lsphp -i | grep -q` probe (which has no pipefail) were both green for the
release whose entrypoint declared that same extension missing.
Verified: PHP 8.3 --no-cache build green, 10/10 .phpt, 9/9 FPM harness; the
built image logs `path parity = extension` and reports `Rewriting => active`
with .from/.to populated; ext-removed and probe-broken variants each produce
their own honest message and still start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+308
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env bash
|
||||
## lsphp-info-probe.test.sh — regression test for the SIGPIPE-under-pipefail
|
||||
## class of bug that shipped in cac-lsphp (trunk 9343a56) and silently turned
|
||||
## $_SERVER path parity off on every shared-ols site whose host lost the race.
|
||||
##
|
||||
## THE BUG. entrypoint-lsphp.sh asked "is cac_path_parity loaded?" with
|
||||
##
|
||||
## printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$'
|
||||
##
|
||||
## under `set -euo pipefail`. `grep -q` exits the instant it matches; printf is
|
||||
## still pushing the remaining ~40 KB of `lsphp -i` into the pipe, takes
|
||||
## SIGPIPE, and exits 141; pipefail prefers that over grep's 0. So the test read
|
||||
## FALSE **because the extension was present** — present early enough in the
|
||||
## output to stop the reader — and the container fell back to the degraded
|
||||
## auto_prepend normaliser the extension exists to replace, while telling the
|
||||
## operator the extension was "not loadable in this image".
|
||||
##
|
||||
## WHY THE EXISTING SUITE DID NOT CATCH IT. The .phpt suite and
|
||||
## fpm-parity-check.sh both test the EXTENSION; nothing executed the
|
||||
## ENTRYPOINT's branch logic, and the Dockerfile's own `lsphp -i | grep -q`
|
||||
## build gate runs under `bash -c 'set -e'` with NO pipefail, so it reported the
|
||||
## extension present in the very image whose entrypoint declared it missing.
|
||||
##
|
||||
## WHAT THIS ASSERTS.
|
||||
## 1. behaviour — the SHIPPED probe helpers (extracted verbatim from
|
||||
## entrypoint-lsphp.sh, never copied, so they cannot drift) return the
|
||||
## right answer AND a clean exit status under `set -euo pipefail` with a
|
||||
## realistic ~40 KB phpinfo body.
|
||||
## 2. structure — no script in this repo that enables pipefail pipes into a
|
||||
## reader that can exit before its writer finishes. This is the check that
|
||||
## fails deterministically against trunk; assertion 1's *old* form is a
|
||||
## RACE (measured 141 on 3 of 5 runs here, 5 of 5 on whp02, and 0 of 5
|
||||
## under a different Docker daemon), so no behavioural assertion about the
|
||||
## broken code could be trusted to fail on every machine. Section 4 runs
|
||||
## the old form anyway and prints what it did, for the record.
|
||||
##
|
||||
## SCOPE LIMITS, stated rather than hidden. The structural scan is a text scan,
|
||||
## so it under-reports in two known ways:
|
||||
## - it reads one line at a time. The repo's only multi-line pipeline
|
||||
## (ols-htaccess-watcher.sh's `inotifywait … |` / `while read`) is invisible
|
||||
## to it and was reviewed by hand: that reader loops until EOF, i.e. until
|
||||
## the writer has already gone, so it cannot produce this failure.
|
||||
## - its quote stripping is flat, so a pipe nested inside a command
|
||||
## substitution inside a quoted string (`echo "x ($(a | head -1))"`) is read
|
||||
## as quoted text and skipped.
|
||||
## It is a guard against reintroducing the shape, not a proof of its absence.
|
||||
##
|
||||
## Usage: scripts/tests/lsphp-info-probe.test.sh [REPO_ROOT]
|
||||
## Exit: 0 all assertions passed, 1 an assertion FAILED, 2 the test could not run.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="${1:-$(cd "$HERE/../.." && pwd)}"
|
||||
ENTRYPOINT="$ROOT/scripts/entrypoint-lsphp.sh"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
pass=0; fail=0
|
||||
ok() { pass=$((pass+1)); echo " ok — $1"; }
|
||||
bad() { fail=$((fail+1)); echo " FAIL — $1" >&2; }
|
||||
die() { echo "HARNESS FAILURE: $*" >&2; exit 2; }
|
||||
|
||||
[ -f "$ENTRYPOINT" ] || die "no entrypoint at $ENTRYPOINT (pass REPO_ROOT as \$1)"
|
||||
|
||||
## ---------------------------------------------------------------------------
|
||||
## 1. Extract the real probe helpers. Not a copy: whatever the shipped
|
||||
## entrypoint says between the markers is what runs below, so a future edit
|
||||
## that reintroduces a pipeline is tested, not narrated.
|
||||
##
|
||||
## A missing block is an assertion FAILURE, not a harness error, and it does
|
||||
## not stop the run: against a pre-fix checkout the probes are still inline
|
||||
## pipelines, and section 5 below is what names them. Bailing out here would
|
||||
## have replaced that report with "could not run".
|
||||
## ---------------------------------------------------------------------------
|
||||
HAVE_HELPERS=yes
|
||||
awk '/^## ---- CAC-TEST: probe helpers BEGIN ----$/{f=1} f{print} /^## ---- CAC-TEST: probe helpers END ----$/{exit}' \
|
||||
"$ENTRYPOINT" > "$TMP/helpers.sh"
|
||||
if [ -s "$TMP/helpers.sh" ]; then
|
||||
for fn in lsphp_info_has_parity_ext lsphp_info_scan_dir lsphp_info_is_usable; do
|
||||
if ! grep -q "^${fn}()" "$TMP/helpers.sh"; then
|
||||
HAVE_HELPERS=no
|
||||
bad "the probe-helper block in ${ENTRYPOINT#"$ROOT"/} defines no ${fn}()"
|
||||
fi
|
||||
done
|
||||
else
|
||||
HAVE_HELPERS=no
|
||||
bad "no probe-helper markers in ${ENTRYPOINT#"$ROOT"/} — either they were removed, or this is a pre-fix checkout where the probes are still inline pipelines (trunk 9343a56). Section 5 says which lines."
|
||||
fi
|
||||
|
||||
## ---------------------------------------------------------------------------
|
||||
## 2. Fixtures. Shaped like real `lsphp -i`: the two lines the probes look for
|
||||
## sit near the TOP (that is what lets a reader stop early), with tens of KB
|
||||
## of body after them. A fixture whose marker is near the end could not
|
||||
## SIGPIPE at all and would make this whole test vacuous, so both properties
|
||||
## are asserted before anything else runs.
|
||||
## ---------------------------------------------------------------------------
|
||||
SCAN_PATH='/usr/local/lsws/lsphp83/etc/php/8.3/mods-available'
|
||||
make_fixture() { # $1=outfile $2=yes|no (include the cac_path_parity line)
|
||||
{
|
||||
printf 'phpinfo()\nPHP Version => 8.3.27\n\n'
|
||||
printf 'System => Linux 6ecb4e0a1c1f 5.15.0 #1 SMP x86_64\n'
|
||||
printf 'Server API => LiteSpeed V8.3\n'
|
||||
printf 'Configuration File (php.ini) Path => /usr/local/lsws/lsphp83/etc/php/8.3/litespeed\n'
|
||||
printf 'Scan this dir for additional .ini files => %s\n' "$SCAN_PATH"
|
||||
printf 'PHP API => 20230831\nDebug Build => no\nThread Safety => disabled\n\n'
|
||||
printf 'bcmath\n\nBCMath support => enabled\n\n'
|
||||
printf 'calendar\n\nCalendar support => enabled\n\n'
|
||||
[ "$2" = yes ] && printf 'cac_path_parity\n\ncac_path_parity support => enabled\nRewriting => active\n\n'
|
||||
printf 'Core\n\nPHP Version => 8.3.27\n\n'
|
||||
## ~40 KB of directive rows, exactly the shape phpinfo() prints them in.
|
||||
local i=0
|
||||
while [ "$i" -lt 700 ]; do
|
||||
printf 'some.directive_%03d => local_value_%03d => master_value_%03d\n' "$i" "$i" "$i"
|
||||
i=$((i+1))
|
||||
done
|
||||
} > "$1"
|
||||
}
|
||||
make_fixture "$TMP/info-present.txt" yes
|
||||
make_fixture "$TMP/info-absent.txt" no
|
||||
: > "$TMP/info-empty.txt"
|
||||
|
||||
echo "== fixture sanity =="
|
||||
FIX_BYTES=$(wc -c < "$TMP/info-present.txt")
|
||||
MATCH_OFF=$(grep -b -m1 '^cac_path_parity support => enabled$' "$TMP/info-present.txt" | cut -d: -f1)
|
||||
if [ "$FIX_BYTES" -ge 32768 ]; then
|
||||
ok "fixture is ${FIX_BYTES} bytes (realistic 'lsphp -i' is ~40 KB)"
|
||||
else
|
||||
bad "fixture is only ${FIX_BYTES} bytes — too small to reproduce the failure"
|
||||
fi
|
||||
if [ "$MATCH_OFF" -lt $((FIX_BYTES / 4)) ]; then
|
||||
ok "match sits at byte ${MATCH_OFF} of ${FIX_BYTES} — most of the body is still unwritten when a reader could stop"
|
||||
else
|
||||
bad "match at byte ${MATCH_OFF} of ${FIX_BYTES} leaves too small a tail; the test would be vacuous"
|
||||
fi
|
||||
|
||||
## ---------------------------------------------------------------------------
|
||||
## 3. Behaviour, under the REAL option set (`set -euo pipefail`, as line 34 of
|
||||
## the entrypoint sets it). Each case runs in its own bash process so the
|
||||
## options and the exit status are the genuine article, not something this
|
||||
## harness simulated.
|
||||
## ---------------------------------------------------------------------------
|
||||
cat > "$TMP/runner.sh" <<'RUNNER'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# shellcheck disable=SC1091
|
||||
source "$1" # the extracted probe helpers
|
||||
LSPHP_INFO=$(cat "$2")
|
||||
case "$3" in
|
||||
has_ext) lsphp_info_has_parity_ext "$LSPHP_INFO" ;;
|
||||
scan_dir) lsphp_info_scan_dir "$LSPHP_INFO" ;;
|
||||
usable) lsphp_info_is_usable "$LSPHP_INFO" ;;
|
||||
legacy) printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$' ;;
|
||||
*) echo "unknown case $3" >&2; exit 99 ;;
|
||||
esac
|
||||
RUNNER
|
||||
|
||||
## NOT `out=$(run …)`: command substitution runs the function in a subshell,
|
||||
## where an exit status assigned to RC would be thrown away with it. Run in this
|
||||
## shell, capture stdout through a file, and RC is the real thing.
|
||||
RC=0; out=""
|
||||
run() { # $1=fixture $2=case -> sets RC and out
|
||||
RC=0
|
||||
bash "$TMP/runner.sh" "$TMP/helpers.sh" "$1" "$2" >"$TMP/out" 2>&1 || RC=$?
|
||||
out=$(cat "$TMP/out")
|
||||
}
|
||||
|
||||
echo "== the shipped probes under set -euo pipefail =="
|
||||
if [ "$HAVE_HELPERS" = no ]; then
|
||||
echo " (skipped — no probe helpers to run; see the failure above)"
|
||||
fi
|
||||
if [ "$HAVE_HELPERS" = yes ]; then
|
||||
|
||||
run "$TMP/info-present.txt" has_ext
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
ok "extension present => branch TAKEN (exit 0)"
|
||||
elif [ "$RC" -eq 141 ]; then
|
||||
bad "exit 141 (SIGPIPE): the probe is a pipeline again — this is the original bug"
|
||||
else
|
||||
bad "extension present => exit $RC (expected 0). Output: $out"
|
||||
fi
|
||||
|
||||
run "$TMP/info-absent.txt" has_ext
|
||||
if [ "$RC" -eq 1 ]; then
|
||||
ok "extension genuinely absent => branch NOT taken (exit 1, a clean 'no')"
|
||||
else
|
||||
bad "extension absent => exit $RC (expected 1). Output: $out"
|
||||
fi
|
||||
|
||||
run "$TMP/info-present.txt" scan_dir
|
||||
if [ "$RC" -eq 0 ] && [ "$out" = "$SCAN_PATH" ]; then
|
||||
ok "scan-dir probe returns '$out' (exit 0)"
|
||||
elif [ "$RC" -eq 141 ]; then
|
||||
bad "scan-dir probe exit 141 (SIGPIPE) — as a bare assignment under set -e that KILLS PID 1"
|
||||
else
|
||||
bad "scan-dir probe => exit $RC, got '$out', expected '$SCAN_PATH'"
|
||||
fi
|
||||
|
||||
run "$TMP/info-present.txt" usable
|
||||
if [ "$RC" -eq 0 ]; then ok "usability probe: real phpinfo body => usable (exit 0)"
|
||||
else bad "usability probe on real body => exit $RC (expected 0)"; fi
|
||||
|
||||
run "$TMP/info-empty.txt" usable
|
||||
if [ "$RC" -eq 1 ]; then ok "usability probe: empty body => NOT usable (exit 1)"
|
||||
else bad "usability probe on empty body => exit $RC (expected 1)"; fi
|
||||
|
||||
## The two failure causes must be reported as the different things they are.
|
||||
## A probe that cannot answer has established nothing about the image, and the
|
||||
## old message asserted the opposite of that for every reason it fired.
|
||||
## SC2016: these patterns are the entrypoint's literal text, `$` and all.
|
||||
# shellcheck disable=SC2016
|
||||
if grep -q 'if lsphp_info_is_usable "$LSPHP_INFO"; then' "$ENTRYPOINT" &&
|
||||
grep -q 'This is a PROBE failure and establishes nothing' "$ENTRYPOINT"; then
|
||||
ok "a probe that produced nothing is reported as OUR failure, not as a verdict on the image"
|
||||
else
|
||||
bad "entrypoint no longer reports an unusable 'lsphp -i' as a probe failure"
|
||||
fi
|
||||
# shellcheck disable=SC2016
|
||||
if grep -q 'not loadable in this image' "$ENTRYPOINT" &&
|
||||
grep -q 'answered (${#LSPHP_INFO} bytes, scan dir ${SCAN_DIR}) and does not list it' "$ENTRYPOINT"; then
|
||||
ok "the 'extension not loadable' verdict now ships the evidence it rests on"
|
||||
else
|
||||
bad "the 'extension not loadable' message no longer states what it observed"
|
||||
fi
|
||||
|
||||
fi # HAVE_HELPERS
|
||||
|
||||
## ---------------------------------------------------------------------------
|
||||
## 4. The old form, for the record. NOT asserted: it is a race, and asserting a
|
||||
## race would make this suite flap on whichever machine happens to win it.
|
||||
## ---------------------------------------------------------------------------
|
||||
echo "== the pre-fix pipeline form on the same input (informational) =="
|
||||
if [ "$HAVE_HELPERS" = no ]; then
|
||||
echo " (skipped — the runner needs the helper block to source)"
|
||||
fi
|
||||
legacy_rcs=""
|
||||
if [ "$HAVE_HELPERS" = yes ]; then
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
run "$TMP/info-present.txt" legacy
|
||||
legacy_rcs="$legacy_rcs $RC"
|
||||
done
|
||||
echo " 10 runs of 'printf | grep -q' under pipefail:${legacy_rcs}"
|
||||
echo " (0 = happened to finish writing, 141 = writer SIGPIPEd and pipefail reported the"
|
||||
echo " extension MISSING because it was present. Either value is expected here.)"
|
||||
fi
|
||||
|
||||
## ---------------------------------------------------------------------------
|
||||
## 5. Structural scan — the deterministic guard, and the part of this file that
|
||||
## fails against trunk on EVERY machine. Any script that turns on pipefail
|
||||
## and pipes into a reader that can stop early has this bug latent in it
|
||||
## whether or not today's buffer sizes expose it, so the shape is what gets
|
||||
## outlawed, not the symptom.
|
||||
##
|
||||
## It is a text scan, so it is honest about its limits: it strips quoted
|
||||
## spans and comments (that is what keeps `case a|b)`, `sed "s|x|y|"` and
|
||||
## `echo "a | b"` from being reported), it requires the reader to be the
|
||||
## FIRST word after a pipe, and it reads one line at a time. This file is
|
||||
## skipped because section 4 runs the broken form on purpose.
|
||||
## ---------------------------------------------------------------------------
|
||||
echo "== structural scan: early-exit readers in pipefail scripts =="
|
||||
mapfile -t PIPEFAIL_FILES < <(grep -rl --include='*.sh' -E '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*[[:space:]]*o?[[:space:]]*pipefail|^[[:space:]]*set[[:space:]]+-o[[:space:]]+pipefail' "$ROOT/scripts" "$ROOT/ext" 2>/dev/null | sort)
|
||||
[ "${#PIPEFAIL_FILES[@]}" -gt 0 ] || die "found no pipefail-enabled scripts under $ROOT — the scan would be vacuous"
|
||||
|
||||
cat > "$TMP/scan.awk" <<'AWKPROG'
|
||||
{
|
||||
raw = $0
|
||||
l = raw
|
||||
gsub(/\\"/, "X", l); gsub(/\\'/, "X", l) # escaped quotes are not delimiters
|
||||
while (match(l, /'[^']*'/)) l = substr(l, 1, RSTART-1) "SQ" substr(l, RSTART+RLENGTH)
|
||||
while (match(l, /"[^"]*"/)) l = substr(l, 1, RSTART-1) "DQ" substr(l, RSTART+RLENGTH)
|
||||
if (l ~ /^[[:space:]]*#/) next # whole-line comment
|
||||
sub(/[[:space:]]#.*/, "", l) # trailing comment
|
||||
gsub(/\|\|/, " ", l) # || is not a pipeline
|
||||
if (l !~ /\|/) next
|
||||
n = split(l, seg, "|")
|
||||
for (i = 2; i <= n; i++) {
|
||||
r = seg[i]
|
||||
if (r ~ /^[[:space:]]*grep[[:space:]]+-([[:alnum:]]*q|m)/) { print NR ": " raw; next }
|
||||
if (r ~ /^[[:space:]]*head([[:space:]]|$)/) { print NR ": " raw; next }
|
||||
if (r ~ /^[[:space:]]*(while[[:space:]]+)?read([[:space:]]|$)/) { print NR ": " raw; next }
|
||||
if (r ~ /^[[:space:]]*awk([[:space:]]|$)/ && raw ~ /exit/) { print NR ": " raw; next }
|
||||
if (r ~ /^[[:space:]]*sed([[:space:]]|$)/ && raw ~ /[;{\/][[:space:]]*[qQ][^[:alnum:]]/) { print NR ": " raw; next }
|
||||
}
|
||||
}
|
||||
AWKPROG
|
||||
|
||||
offenders=0
|
||||
for f in "${PIPEFAIL_FILES[@]}"; do
|
||||
[ "$(basename "$f")" = "$(basename "${BASH_SOURCE[0]}")" ] && continue
|
||||
while IFS= read -r hit; do
|
||||
offenders=$((offenders+1))
|
||||
echo " FAIL — ${f#"$ROOT"/}:${hit}" >&2
|
||||
done < <(awk -f "$TMP/scan.awk" "$f")
|
||||
done
|
||||
if [ "$offenders" -eq 0 ]; then
|
||||
ok "${#PIPEFAIL_FILES[@]} pipefail-enabled scripts, no pipeline whose reader can outrun its writer"
|
||||
else
|
||||
bad "$offenders pipeline(s) above pipe into an early-exit reader under pipefail."
|
||||
echo " Read the value into a variable and match it with a here-string (see" >&2
|
||||
echo " lsphp_info_has_parity_ext in scripts/entrypoint-lsphp.sh), or give the" >&2
|
||||
echo " reader the file directly. A here-string is not a pipeline, so there is" >&2
|
||||
echo " no second exit status for pipefail to prefer." >&2
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "passed: $pass failed: $fail"
|
||||
[ "$fail" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
Reference in New Issue
Block a user