fix(lsphp): correct the SIGPIPE explanation, drop the temp-dir boot precondition, close two scanner blind spots
Three non-blocking findings from the review of 8790b02. The fix itself is
unchanged in intent; this makes the reasoning around it true, removes a
regression the fix introduced on the boot path, and stops the new test from
under-reporting.
F1 — the shipped comments explained the bug wrongly, and a wrong rule is what
the next maintainer reasons from. entrypoint-lsphp.sh and
render-shared-ols-config.sh both said the race is decided by PIPE CAPACITY:
"while the output fits the pipe the writer always wins; once it doesn't, SIGPIPE
is guaranteed." Both halves are refuted by measurement against a default
65536-byte pipe (F_GETPIPE_SZ):
41144 bytes -> 141 in 32/300 runs (11%) — well UNDER capacity
65012 bytes -> 141 in 25/30 runs — not certain even AT capacity
500 KB into a 1 MiB pipe -> 200/200 with 4096-byte writes, 0/200 with one
500 KB write
and strace caught printf dying having written 12086 of 40406 bytes into a
65536-byte pipe. The mechanism is a race on whether the reader closes before the
writer's final write() returns; capacity only modulates how many syscalls the
writer needs. What actually separated whp02 (5/5 failures) from a dev container
(10/10 clean) is the WRITER's syscall size: bash <= 5.2.15 writes ~37 KB at a
time, bash >= 5.2.21 writes 80-160 bytes. The fs.pipe-user-pages-soft aside was
also wrong: it clamps to two pages not one, needs one uid holding >1024 pipes,
and is skipped for CAP_SYS_RESOURCE.
Both blocks now state the rule that is actually true — any
`writer | early-exiting-reader` under pipefail is a latent 141; payload size is
not a safety argument; the only sound reasons a call site is safe are structural
(no pipefail, reader provably reads to EOF, or the status is discarded) — and
the same correction is applied to the three other comments that leaned on size
(`ols_running` x2, fpm-parity-check.sh's pre-flight). Nor is the reader's
implementation a defence: at 248 KB, mawk, gawk, `grep -q` and `head -1` all
gave 141 on 10/10, and these images already differ (mawk 1.3.4 vs gawk 5.2.1).
Comment-only; the test file's own section-4 output no longer contradicts the
prose next to it.
F2 — `<<<` added a writable-temp-dir precondition to the boot path. Above a
build-dependent size bash materialises a here-string as /tmp/sh-thd.XXXXXX
(measured switch: 65536 in this image's bash 5.2.21, and Debian's 5.2.15
switches between 4096 and 16384, where a ~40 KB `lsphp -i` WOULD spill). On a
bare assignment a temp file it cannot create is `set -e` killing PID 1 — the
exact failure this branch exists to remove, re-acquired from a different
direction and gated on which bash the base image ships. In cac-lsphp:f1f2f3
under `docker run --read-only`, same payload, same statement shape:
OLD (here-string) : bash: cannot create temp file for here-document
-> exit 1, script dead
NEW (pure bash) : REACHED NEXT STATEMENT, SCAN=[…/mods-available/], exit 0
So the boot-critical sites — the three probe helpers in entrypoint-lsphp.sh and
the SCAN_DIR extraction in entrypoint-litespeed.sh — now match with `[[ ]]` and
parameter expansion, which allocate nothing. The non-boot sites keep their
here-strings and say why at the call site: `ols_running` in both OLS entrypoints
(`lswsctrl status` is under 100 bytes, orders below any spill threshold) and
fpm-parity-check.sh's `php-fpm -m` pre-flight (~1 KB, in a harness that has
already written a docroot and a pool config).
Matching semantics are preserved, not approximated: the anchored whole-line
grep becomes a glob over a subject wrapped in newlines at BOTH ends (so first
and unterminated-last lines still match), and awk's `-F'=> ' {print $2; exit}`
becomes first-matching-line then the text between the FIRST and SECOND
separator. Section 6 of the test asserts that against the original grep/awk
patterns reading a FILE — 24 cases incl. trailing-space, prefix decoys, CRLF,
a second separator, an empty value, two candidate lines, glob metacharacters in
the body, and the full 40 KB fixture. Mutations verify the assertions bite:
dropping the trailing-newline wrap fails 3 cases, taking the whole rest of the
line fails "second separator", `##` instead of `#` fails "first of two wins",
dropping the `^` anchor on the banner fails "banner not at line start".
F3 — the structural scan missed shapes it implied it caught, and the extractor
was unbounded.
* `grep -l`/`-L`/`--quiet`/`--files-with-matches`, `-im1`-style clusters, a
bare `head` before `;`, and `sed q` / `sed 'q'` / `sed 2q` / `sed '$q'` were
all invisible. grep is now walked option by option the way grep reads them
(so `grep -eq foo` stays the pattern "q", not --quiet), and the sed test
reads the script with quote characters stripped but their contents kept.
Replaying the old regexes against the new fixtures: 10 shapes missed and 2
false positives (`sed s/a/q/`, `grep -eq foo`) — both now correct.
* new section 7 pins that coverage from both sides: 19 early-exit shapes must
be reported, 18 read-to-EOF / quoted / non-pipeline forms must not. Without
it the scan's regexes are unfalsified and can quietly stop matching, which
is precisely how `grep -l` and `sed q` stayed missing.
* the helper extraction is bounded. It buffers and emits nothing until it has
seen the END marker (exit 4 = BEGIN without END, exit 3 = no markers), so a
half-deleted pair is a marker error instead of a slurp. Measured on this
entrypoint with the END marker removed: the old extractor produced 301 lines
including `mkdir -p "$SCAN_DIR"` and three `rm -f "$SCAN_DIR/…"` — which the
harness then sourced and ran. It failed loudly last time only because `set
-u` happened to trip two statements in. The new one emits 0 bytes and says
what is wrong.
* the stated scope limits now include what remains: the reader list is an
enumeration, not a proof (nothing knows about `perl -ne … last`, `jq`,
`head -c`), and only the first word after a pipe is inspected.
Verified: PHP 8.3 `--no-cache` build exit 0, 10/10 .phpt; cac-lsphp boots and
logs `path parity = extension` with `Rewriting => active` and .from/.to
populated from the rendered ini; cac-litespeed boots, resolves SCAN_DIR and
writes 99-user-error-log.ini, OLS reports "running with PID", /healthz 200. The
FPM parity harness — never executed by the previous review because no cac-fpm
image existed locally — was built (Dockerfile.fpm, PHPVER=83), the extension
compiled inside it, and it reports 9/9 ALL PASS, exit 0. The new test exits 0
here and exits 1 against a `git archive 9343a56` export naming all 9 offending
lines. `bash -n` clean repo-wide; `shellcheck -S warning` clean on the CI set;
`-S style` is byte-identical to before this commit (5 pre-existing info-level
findings, 0 added — the earlier report's claim of `-S style` clean was wrong).
No `.c`/`.h` file touched and the C fail-open invariant grep is still empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+88
-29
@@ -178,44 +178,103 @@ validate_ini_num() {
|
||||
## Everything between these two markers is extracted verbatim and executed by
|
||||
## scripts/tests/lsphp-info-probe.test.sh — the markers are inert comments with
|
||||
## no runtime effect, and they exist so the test exercises THE SHIPPED CODE
|
||||
## rather than a copy of it that can drift away from it.
|
||||
## rather than a copy of it that can drift away from it. Keep BOTH markers: the
|
||||
## extractor refuses to emit anything unless it sees the END one, so a half-
|
||||
## deleted pair is reported there as a marker error instead of silently
|
||||
## sourcing the rest of this file.
|
||||
##
|
||||
## WHY THESE READ `$1` FROM A HERE-STRING AND NOT A PIPELINE. Both probes used
|
||||
## to be `printf '%s\n' "$LSPHP_INFO" | <reader>`. `lsphp -i` is ~40 KB and both
|
||||
## readers stop early — `grep -q` on first match, `awk` at `exit` — so the
|
||||
## reader can close the pipe while printf is still writing to it. printf then
|
||||
## takes SIGPIPE and dies 141, `set -o pipefail` (line 34) adopts 141 as the
|
||||
## PIPELINE's status, and the test reads FALSE **because the thing it was
|
||||
## WHY THESE MATCH `$1` IN THE SHELL AND NEVER PIPE IT INTO A READER.
|
||||
##
|
||||
## What broke. Both probes used to be `printf '%s\n' "$LSPHP_INFO" | <reader>`,
|
||||
## and both readers stop early — `grep -q` at its first match, `awk` at `exit`.
|
||||
## When the reader closes the pipe with the writer still writing, the writer
|
||||
## takes SIGPIPE and dies 141; `set -o pipefail` (line 34) adopts 141 as the
|
||||
## PIPELINE's status; and the branch reads FALSE **because the thing it was
|
||||
## looking for was present early enough to stop the reader**. Measured on whp02
|
||||
## against the published cac-lsphp:php83: 5/5 runs status=141 with pipefail,
|
||||
## 0 without.
|
||||
##
|
||||
## It reproduces on some hosts and not others, and the reason is the PIPE
|
||||
## CAPACITY, not the payload alone. While the writer's whole output fits in the
|
||||
## pipe it never blocks and always finishes before the reader can act; once it
|
||||
## does not fit, the early exit is a guaranteed SIGPIPE. Linux gives a pipe
|
||||
## 64 KiB by default — 40 KB fits, which is why this same image measured 0/10
|
||||
## on the build host here — but drops NEW pipes to a single page once a user
|
||||
## passes fs.pipe-user-pages-soft, which is the state a busy production host
|
||||
## lives in. Forcing the payload over the limit makes it deterministic
|
||||
## everywhere: 3x this output = 122100 bytes gave 141 141 141 in this very
|
||||
## image. "It worked when I ran it" was never evidence about this bug.
|
||||
## THE RULE, and it is not about size. ANY `writer | early-exiting-reader`
|
||||
## under pipefail is a latent 141. PAYLOAD SIZE IS NOT A SAFETY ARGUMENT. Each
|
||||
## run is decided by a race — whether the reader's close lands before the
|
||||
## writer's final write() returns — and the payload only sets how many write()
|
||||
## syscalls the writer has to lose. Measured here against a default
|
||||
## 65536-byte pipe (confirmed with F_GETPIPE_SZ):
|
||||
## 41144 bytes -> 141 in 32/300 runs (11%) — well UNDER capacity
|
||||
## 65012 bytes -> 141 in 25/30 runs — not 100% even AT capacity
|
||||
## 500 KB into a 1 MiB pipe -> 200/200 SIGPIPE written 4096 bytes at a
|
||||
## time, 0/200 written as one 500 KB write
|
||||
## and strace caught printf dying having written 12086 of 40406 bytes into a
|
||||
## 65536-byte pipe, i.e. losing with 53 KB of room to spare. The reason the same
|
||||
## image failed 5/5 on whp02 and 10/10 clean in a dev container is the WRITER's
|
||||
## syscall size: bash <= 5.2.15 pushes ~37 KB per write, bash >= 5.2.21 pushes
|
||||
## 80-160 bytes, so the newer shell needs hundreds of chances to lose the race
|
||||
## and the older one needs a couple. (An earlier draft of this comment blamed
|
||||
## pipe capacity and fs.pipe-user-pages-soft. Both were wrong: that soft limit
|
||||
## clamps new pipes to two pages rather than one, applies only once a single uid
|
||||
## holds more than 1024 pipes, and is skipped entirely for CAP_SYS_RESOURCE.)
|
||||
##
|
||||
## A here-string is not a pipeline at all: the shell materialises the whole
|
||||
## string first (temp file, or a pipe only when it provably fits the pipe
|
||||
## buffer) and the command's status is the reader's own status, so there is no
|
||||
## second status for pipefail to prefer and no writer left alive to signal.
|
||||
## `case`/`[[ ]]` would also avoid the pipeline, but would mean re-expressing an
|
||||
## anchored line match as a glob over embedded newlines; keeping grep/awk with
|
||||
## the SAME patterns makes this a plumbing change and nothing else.
|
||||
## The only SOUND reasons a call site is safe are structural:
|
||||
## * the file does not set pipefail; or
|
||||
## * the reader provably consumes to EOF (no `q`, `-q`, `-l`, `-m`, `exit`,
|
||||
## `break`); or
|
||||
## * the pipeline's status is discarded.
|
||||
## The reader's implementation is not a defence either: at 248 KB, mawk, gawk,
|
||||
## `grep -q` and `head -1` each returned 141 on 10/10, and these images have
|
||||
## already drifted between mawk 1.3.4 (cac-lsphp) and gawk 5.2.1
|
||||
## (cac-litespeed:php83) — not a property this repo controls.
|
||||
##
|
||||
## Both return the reader's status, so a genuinely-absent extension is still a
|
||||
## clean 1 and a genuinely-missing "Scan this dir" line is still empty output.
|
||||
## WHY PURE-BASH MATCHING RATHER THAN A HERE-STRING. `<<<` does remove the
|
||||
## pipeline, but it is not free: above a build-dependent size bash materialises
|
||||
## the string as /tmp/sh-thd.XXXXXX, so it makes a writable temp dir a
|
||||
## PRECONDITION OF BOOTING. Measured in this image (bash 5.2.21) the switch is
|
||||
## at exactly 65536 bytes and `lsphp -i` is 39934, so the here-string form was
|
||||
## not hitting disk here — but Debian's bash 5.2.15 switches somewhere between
|
||||
## 4096 and 16384, where the same payload would. What that costs is not
|
||||
## theoretical:
|
||||
## docker run --read-only ... 'SCAN_DIR=$(awk ... <<<"$BIG")'
|
||||
## -> bash: cannot create temp file for here-document: Read-only file
|
||||
## system ... and the script is dead: exit 1, PID 1 gone.
|
||||
## which is the exact boot failure this branch exists to remove, re-acquired
|
||||
## from a different direction and gated on which bash the base image ships.
|
||||
## `[[ ]]` and `${...}` allocate nothing and cannot fail that way. Where the
|
||||
## subject is a couple of hundred bytes and provably cannot approach the
|
||||
## threshold, a here-string is still fine — see `ols_running` in
|
||||
## entrypoint-litespeed.sh, which says so at the call site.
|
||||
##
|
||||
## THE PATTERNS ARE THE OLD ONES RE-EXPRESSED, NOT APPROXIMATED.
|
||||
## grep -q '^cac_path_parity support => enabled$' — an anchored whole-line
|
||||
## match, so the subject is wrapped in a newline at BOTH ends and the glob
|
||||
## matches \n<line>\n; the wrapping is what keeps the first line and an
|
||||
## unterminated last line matching exactly as grep matched them.
|
||||
## grep -q '^PHP Version => ' — anchored at the start
|
||||
## only, so only a leading newline is added.
|
||||
## awk -F'=> ' '/^Scan this dir/ {print $2; exit}' — first matching line,
|
||||
## then the text between the FIRST and SECOND '=> ' on it ($2), or empty if
|
||||
## there is no separator. `${x#*'=> '}` then `${y%%'=> '*}` is that, exactly.
|
||||
## scripts/tests/lsphp-info-probe.test.sh asserts this equivalence against the
|
||||
## grep/awk originals over the edge cases (match on the first line, on the last
|
||||
## line with no trailing newline, decoy substrings, a second separator, an empty
|
||||
## value, a missing key), so "same answer as before" is checked, not asserted.
|
||||
##
|
||||
## Statuses are unchanged: a genuinely-absent extension is still a clean 1, and
|
||||
## a genuinely-missing "Scan this dir" line is still empty output with status 0.
|
||||
lsphp_info_has_parity_ext() {
|
||||
grep -q '^cac_path_parity support => enabled$' <<<"$1"
|
||||
[[ $'\n'"$1"$'\n' == *$'\ncac_path_parity support => enabled\n'* ]]
|
||||
}
|
||||
lsphp_info_scan_dir() {
|
||||
awk -F'=> ' '/^Scan this dir/ {print $2; exit}' <<<"$1"
|
||||
local rest line val
|
||||
rest=$'\n'"$1"
|
||||
[[ $rest == *$'\nScan this dir'* ]] || return 0
|
||||
## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`.
|
||||
rest=${rest#*$'\nScan this dir'}
|
||||
line="Scan this dir${rest%%$'\n'*}"
|
||||
val=""
|
||||
if [[ $line == *'=> '* ]]; then
|
||||
val=${line#*'=> '}
|
||||
val=${val%%'=> '*}
|
||||
fi
|
||||
printf '%s\n' "$val"
|
||||
}
|
||||
## Did `lsphp -i` answer at all? Separates "the extension is not there" from
|
||||
## "our probe produced nothing to look in", so neither gets reported as the
|
||||
@@ -223,7 +282,7 @@ lsphp_info_scan_dir() {
|
||||
## (verified against lsphp83 8.3.32) and is not something LSPHP_INFO could
|
||||
## contain from any other source.
|
||||
lsphp_info_is_usable() {
|
||||
grep -q '^PHP Version => ' <<<"$1"
|
||||
[[ $'\n'"$1" == *$'\nPHP Version => '* ]]
|
||||
}
|
||||
## ---- CAC-TEST: probe helpers END ----
|
||||
|
||||
|
||||
Reference in New Issue
Block a user