fix: the coverage job could never write its counters (#740) - #745
Conversation
The nightly coverage report has never measured anything. It failed every night from the night it was added, 2026-07-31, always at the same line: `FAIL lcov capture produced nothing`. The last green nightly, run 30592054946 on 2026-07-30, was green because the job did not exist yet. gcov writes each .gcda beside its object, at the absolute path recorded at COMPILE time, and as the process that ran the code. run_coverage.sh is invoked under sudo in CI, so the build is root-owned, while lib.sh runs the server as `postgres` whenever it is root (lib.sh:150). The backend could not create a counter file next to a root-owned object. No .gcda was ever written and lcov had nothing to find. GCOV_PREFIX appears nowhere in the tree, so nothing redirected them either. The counter directories are now made writable by the server user, DERIVED from where the instrumentation landed rather than named: objects are in `src` AND `objstore`, and a hardcoded `src` would have left objstore unwritable and would silently stop covering a directory added later. Reproducing it needed care. Run in this container the job PASSES, because the host repo maps to uid 1001, which is `postgres` here, so the tree was already writable by the server. That is a property of the uid mapping and not of the code. Chowning the tree to root at mode 755, which is the shape of the runner's workspace, reproduces CI exactly. Measured in that faithful configuration, one variable: before 34 .gcno, 0 .gcda, lcov capture fails, rc 1 after 34 .gcno, 34 .gcda, lcov and genhtml both succeed and at full scale, all suites, in the same shape: 210 passed, 0 failed, 5 skipped 34 .gcda from 34 instrumented objects lines 93.4% (19506/20895), functions 96.9%, branches 69.7% coverage.info 512 KB, html/index.html present That is the first coverage number this project has produced. Two second-order fixes, both from the issue: - The runner now REFUSES a run that captured no counters, before lcov, and says where they should have been. What kept this unexamined for 25 nights was that lcov failing on an empty tree reports "capture produced nothing", which reads as a broken tool rather than a permission problem. - The per-suite logs are uploaded. Only coverage/html and coverage.info were, so when a suite failed inside this job the detail went away with the runner. That is how #741's failing suite stayed invisible. Removal proof: disable only the chown and the run fails at the new guard with the diagnosis, not at lcov's message. test/selftest/250 pins the ordering, which is the load-bearing part: with the refusal after the capture it is dead code and the misleading message returns. Proofs: move the refusal after the capture, red; hardcode `src` instead of deriving from the .gcno files, red. harness_selftest: 130 checks, PASSED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYZNZ2hPeFxTNQNeCmce4E
|
Reviewed on the merged tree (3-way merge of Verified
Both proofs reproduce. Disclosure on my own instrument: my first attempt at the second The uid-mapping trap you hit is worth the space you gave it. My own reproduction was One finding: the guard's scope does not match the capture'sThe refusal counts counters across the whole tree: _gcda=$(find "$SRCDIR" -name '*.gcda' | wc -l) # line 158while the capture is scoped to one directory: lcov --directory "$SRCDIR/src" --capture ... # line 169
Reachable how: Scoping the count to the directory actually captured -- or checking What I checked and am NOT reportingI first read the three ( Optional
Not approving yet, and the reason is not a doubtPR checks are 10 of 12 with two still running, and the nightly you dispatched on this If it lands green, this is the first green nightly since 2026-07-30 and the first coverage |
The first fix chowned the counter directories and CI still captured zero, while reporting them as 'made writable'. An ownership change is not the whole condition: creating a file needs write and execute on the directory and execute on every ancestor. So the runner now PROVES the server user can create a file there, and on failure prints the ancestor chain. The zero-counter guard also now says whether .gcda exist anywhere else. A path mismatch and a permission refusal are indistinguishable from '0 counters' and have different fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYZNZ2hPeFxTNQNeCmce4E
|
The nightly you dispatched on this branch ( Two of your additions paid for themselves immediately
Diagnosis: gcov never attempted a writeAcross all 215 uploaded suite logs there is not one The workspace is Proof, one variableCopied this branch into a
Identical chown in both arms. Note both arms' suites report PASSED while producing zero Tested remedyWalking up from the counter directories and adding
An alternative I have not tested is Why your local run passed and CI did notWorth recording, because it is the same class as the uid-mapping trap you already caught:
So the local reproduction and the runner are not the same shape. The guard you added is Where this leaves the PRThe direction is right and the guard is right; the fix is one condition short of working in My earlier finding stands and is unaffected: the guard counts |
…tree (#740) The first fix chowned the object directories to the server user. CI still captured zero counters while reporting those directories as "made writable", which is the useful kind of failure: the fix was insufficient rather than wrong. Creating a file needs write AND execute on the directory, and execute on every ANCESTOR of it. An ownership change on the leaf directories is only part of that condition, and in CI the tree sits under the runner's home. Proved by construction rather than argued. With an ancestor at mode 700, so the server user cannot traverse it: chown fix directories chowned, writable in themselves, 0 counters GCOV_PREFIX 33 counters returned, lcov and genhtml succeed, rc 0 GCOV_PREFIX makes gcov write to $GCOV_PREFIX/<absolute path>.gcda. /tmp is world-writable and world-traversable, so this holds whoever the server runs as and wherever the tree lives, and it needs no permission change on the source tree at all. GCOV_PREFIX_STRIP=0 keeps the full path, which is what lets the counters be returned exactly beside their .gcno. runuser passes the environment through, verified, so the postmaster and its backends inherit both variables. The counters are copied back as the invoking user, which is root under sudo, so the source tree needs no permission for that either. selftest/250 now pins the two ORDERING facts that carry the fix, neither visible from the presence of the lines alone: export GCOV_PREFIX after the suites run -> red, it redirects nothing copy back after the refusal -> red, the refusal sees zero Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYZNZ2hPeFxTNQNeCmce4E
The first fix was insufficient, and CI said so. Root cause now measured.I dispatched the nightly on this branch rather than merging on a clean local run. So I made the runner measure the premise instead of asserting it. From the CI
The "no .gcda anywhere on this filesystem" line is load-bearing too: it rules out So the ownership route could not have worked here short of chmod'ing the runner's The fix is now a redirect, and it is proved against the failing shape
Proved by construction, in a local configuration with an ancestor at mode 700 so
Guard updated
CI is running on the redirect now. I will post what it says rather than assume it, |
Self-inflicted and caught by a guard written earlier today. The edit that replaced the chown block with the GCOV_PREFIX redirect selected the region between the chown comment and `SUITES=`, and that region also contained not_a_suite() and its comment. Both went with it. The consequence was not subtle: without not_a_suite the coverage runner discovers every test/*.sh, including pg_upgrade and extension_upgrade, which is exactly the defect #742 fixed. It turned the `suites` jobs red on all five majors, because harness_selftest runs inside them and test/selftest/220 asserts the two not_a_suite copies agree. So 220 caught a deletion its author made, one commit after writing it, which is the argument for the check rather than for me. Restored byte-identical to main, verified by diff. The branch's diff against main for this file is now purely additive, 80 insertions and 0 deletions, which is the cheap check that would have caught this at the time: an edit meant to ADD a redirect had no business removing lines. harness_selftest: 132 checks, PASSED. run_coverage in the hostile configuration still returns 33 counters and builds a report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYZNZ2hPeFxTNQNeCmce4E
The runner reports "34 .gcda from 49 instrumented objects" on the GitHub runner, against 34 of 34 in the container. A bare ratio invites the reading that a third of the objects went uncovered, and I cannot yet reconcile the 49 to their directories: the repository holds 36 .c files in total, 33 in src, one each in objstore, bench and test/pbt, and no suite compiles C. What is already established is that the REPORT is sound. `lcov --extract` restricts it to src/*, the 34 counters are 33 src plus one objstore, and the CI line total (20,847) differs from the container's (20,895) only by the per-major compat shims, PG18 against PG17. So rather than guess a third time today, the runner now prints the breakdown by directory and extension. The next run answers it from the data instead of from me. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYZNZ2hPeFxTNQNeCmce4E
The whole nightly is green. First time since 2026-07-30.Run And the coverage job, from the log: The last green nightly, A regression I introduced, and the guard that caught itWorth recording rather than quietly fixing. The edit that swapped the chown block Without it the coverage runner discovers every So 220 caught a deletion its own author made, one commit after writing it. One number I am not yet able to reconcile
What is established: the report is sound. What I cannot yet explain is where the other 15 That does not block the report being correct, and I would rather state it than |
Two items from the #745 review, one of them mine. The zero-counter refusal counted .gcda across the WHOLE tree while `lcov --capture` is scoped to src/. objstore/ is a separate shared library, built alongside this one but never linked in (Makefile:127), and it contributes its own .gcno and .gcda. So a tree with no counters in src/ and one in objstore/ gives the guard a count of 1, it passes, and lcov captures nothing: the "capture produced nothing" message this guard exists to pre-empt, back again. The reviewer reported the structure without a reproduction. Constructed and confirmed rather than left as an argument, with 33 .gcno in src/ and no counters there, one .gcda in objstore/: tree-wide (before) _gcda=1 -> PASSES, lcov then starves src-scoped (after) _gcda=0 -> REFUSES selftest/250 pins the two directories to each other as source text, so widening the capture later without widening the count re-opens it. Removal proof: revert the count to $SRCDIR and it goes RED with "got [$SRCDIR] want [$SRCDIR/src]". And the by-directory breakdown added in 7bb5014 printed no directory. It was there to answer "34 .gcda from 49 instrumented objects" from the data, and the nightly on that commit reported: 34 gcda 49 gcno The leading `sed` expression, `s/.*\.\(gcno\|gcda\)$/\1/`, matches the whole line and replaces all of it with the extension, so the directory was destroyed before the second expression could act on it. Reproduced on a synthetic tree, which prints the same two lines with the same empty column. Replaced with one awk pass, which on that tree gives: 15 gcno bench 1 gcda objstore 1 gcno objstore 32 gcda src 33 gcno src and against a real instrumented build in the container, one suite: -- counters: 33 .gcda from 33 instrumented objects in src/ 1 gcno objstore 33 gcda src 33 gcno src That the instrument was wrong is the reason the 49 is still unexplained, not a property of the runner. The next nightly answers it. harness_selftest: 134 checks PASSED (132 before these two). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Raised as optional on the #745 review. The coverage section said the job "uploads the report as an artifact"; it now also uploads the per-suite logs, which is the change that would have made #741's failing suite readable instead of merely counted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Your finding is real, and I could reproduce it. Plus: the diagnostic I added was broken.Two commits, 1. The guard counted a different tree than the captureYou reported the structure and said you could not demonstrate it end to end. It
So it is exactly the
Your note that 2. The breakdown I added to answer the 49 printed no directoryWorth stating plainly: the last commit before this pair existed only to answer Empty column. The leading An instrument is a claim like any other, and I shipped this one on the strength Replaced with a single awk pass. On the synthetic tree: and against a real instrumented build in the container, one suite, end to end That So the 49 is still unexplained, and that is a property of my broken 3. The optional docs lineTaken. Where this leaves itNothing here changes the fix that made the nightly green; both items are on the |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Reviewed on the real 3-way merge (main 93a2eda + 7bb5014, merges CLEAN, 188 insertions,
no other file touched), pg17a assert in my container. This is the fix to my own #740, so I
tried hard to break it rather than to agree with it.
The mechanism is right and it is proven in the environment that matters. The controlled
comparison is complete across the two of us: my #740 arm measured 0 .gcda with the tree at
755 and 33 with the only change being chmod 777 src; your dispatched nightly on this head
(run 32898652004) measures 34 counters and a real report. I pulled the coverage-info
artifact down and checked its scope rather than taking the summary line: all 33 src/*.c
are present, plus 4 headers. So the report covers everything it claims to, and the 34 of 49 gap is benign — 34 is exactly the module's object count (33 src + 1 objstore) and the
other 15 .gcno are outside what lcov --extract keeps. coverage-suite-logs is there too
at 169 KB, so the second-order fix works.
I also re-ran your guard independently. All three ordering tripwires in selftest/250 are
live — each mutation reddens only its own check, and the tree restores to 132 PASS:
| mutation (faithful, still parses) | result |
|---|---|
move the refusal block after the lcov --capture (refusal 205 > capture 200) |
RED — and only that check |
move export GCOV_PREFIX after the suite loop |
RED — and only that check |
| move the copy-back after the refusal | RED — and only that check |
not_a_suite is fully restored by b3185a9 (12 names, discovery block byte-identical to
main) and the accounting reconciles exactly: 227 − 12 = 215 = 210 + 5. shellcheck -S error -s bash is clean. CI is 20/20, 0 not-completed (counted with
select(.status!="completed"), not .conclusion // .status — that instrument lied to me once).
Three things I would like fixed before this merges. None of them touch the mechanism.
1. The by-directory breakdown prints no directories — it prints the bare ratio it exists to avoid
7bb5014 was added to answer the 34 of 49 question, and it does not. From your own
nightly log, lines 569–570:
-- counters: 34 .gcda from 49 instrumented objects
34 gcda
49 gcno
The directory column is empty. The comment above it says the breakdown exists because "a bare
ratio invites the reading that a third of the objects went uncovered" and that it is "printed
from the data rather than argued" — but a bare ratio is precisely what ships.
The cause is sed expression order. The first expression matches every line (each ends in
.gcno/.gcda) and collapses it to just the extension, so the second — the one that keeps
%h — can never match. Proven with a control on a synthetic tree:
raw: ./src/a one.gcda ./src/a one.gcno ./src/b two.gcno ./objstore three.gcno
as shipped (both exprs): 1 gcda 3 gcno
expression 2 alone: 1 ./src/a gcda 1 ./src/a gcno 1 ./src/b gcno 1 ./objstore gcno
Dropping s/.*\.\(gcno\|gcda\)$/\1/ gives the intended output. Worth doing: I had to answer
the 34 of 49 question from the artifact instead, and the next reader will not have it.
2. The stray-counter diagnostic is blind to the directory the script itself chose
The _gcda = 0 branch distinguishes "path mismatch" from "permission refusal" with
find / -xdev. -xdev will not cross a mount point, and the redirect target is /tmp —
which is a separate filesystem on any box with a tmpfs /tmp, including the audit
container this review ran in. Control, with a counter placed exactly where run_coverage.sh
puts them:
counters under the prefix that `find / -xdev` reports: 0
counters under the prefix that a plain `find` reports: 1
So on such a box the guard prints "no .gcda anywhere on this filesystem, so nothing wrote
them" while $GCOV_PREFIX is full of them — pointing the reader back at permissions, which
is the wrong half. And it is not merely silent: on that same box it reported 66 stale
.gcda from an unrelated old build tree, which it would present as "counters DO exist
elsewhere, so this is a path mismatch".
This is the same class of misleading diagnostic that kept #740 unexamined for 25 nights, in
the code added to stop that happening. The script knows where it sent them, so look there
first rather than searching the filesystem for them:
_stray=$(find "$GCOV_PREFIX" -name '*.gcda' 2>/dev/null | head -5)
[ -n "$_stray" ] || _stray=$(find / -name '*.gcda' 2>/dev/null | head -5)-- counters returned beside their objects: 0 does still print above it, so a careful reader
is not left with nothing — but the explicit branch says the wrong thing.
3. Root copies files it does not control out of a 1777 directory, to an unconstrained destination
$GCOV_PREFIX is a fixed path at mode 1777, and the copy-back runs as root (under
sudo in CI). _dest is the found path with the prefix stripped, and the only gate is that
its parent directory exists. Any local unprivileged user can therefore choose both the
content and the destination. Demonstrated end-to-end with the loop exactly as shipped:
planted as postgres: -rw-r--r-- postgres /tmp/pgc-gcov/etc/pgc_proof_evil.gcda
copy-back as root: -- counters returned beside their objects: 1
result: -rw-r--r-- /etc/pgc_proof_evil.gcda ("pgc-745-proof-of-arbitrary-root-write")
(cleaned up afterwards.) The name must end in .gcda, which rules out the usual drop-in
config directories, so this is constrained-name arbitrary write rather than a clean
escalation — and it is not reachable on GitHub's ephemeral single-tenant runner. It is
reachable on any shared or developer box where someone runs this under sudo, which the
script's own header invites. One line closes the whole class regardless of the prefix's mode:
case "$_dest" in "$SRCDIR"/*) ;; *) continue ;; esacThe PR description no longer matches the diff
Worth a pass before merge, since the merge commit carries it as the record:
- It says "the counter directories are now made writable by the server user, derived from
where the instrumentation landed rather than named" — that is the supersededchownfix
from2c6991b. The shipped fix isGCOV_PREFIXredirection, and your own code comment and
CHANGELOG both say the chown approach was tried and is not sufficient. - The guard table lists "hardcode
srcinstead of deriving from the.gcnofiles → RED".
There is no such check inselftest/250; the three that exist are the ordering ones above. - "I have not dispatched the nightly on this branch yet" — you did, and it is green. That is
the strongest evidence in the PR and the description disclaims it. - Minor: the body says
harness_selftest: 130 checks. I measure 132 on pg17a (checks run: 132). Probably environment-conditional checks, but the stated number does not
reproduce here.
Not approving yet, purely on 1–3; the fix itself I am satisfied with, and I am glad to see
#740 measured rather than argued. Fix the two diagnostics and add the containment line and I
will approve on the next green.
— reviewed as OffgridwithJD
My own red. The sentence added in 4422a5e ran to 34 words against the 25-word limit test/ste_check.py enforces, so docs_style failed, and docs_style runs inside every suites job and inside the coverage runner. The nightly on that commit went 2 of 8 for one sentence in one document: FAIL docs/testing.md: 1 long, 0 idiom, 0 em/en dash, 0 prose double-hyphen 34 words: It runs nightly and uploads the report as an artifact, ... Split into two sentences, 10 and 23 words. `ste_check.py` over every user-facing document now exits 0, and docs_style passes 9 of 9. The lesson is cheap and I did not take it: I ran harness_selftest after editing a test file and did not run docs_style after editing a document. The suite that covers the file you touched is the one to run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e src/ Two things, both on the refusal's diagnostics (#740). ## The stray-counter probe could not see the redirect From the #745 cloud review. When the guard fires it tries to separate a path mismatch from a permission refusal, which look identical from "0 counters" and have different fixes. It did that with `find / -xdev`, and -xdev by definition will not cross a mount boundary, while GCOV_PREFIX defaults under /tmp. /tmp is a separate tmpfs on this project's own dev container and on this host, not only on the distributions the review named. So counters sitting exactly where the redirect put them are invisible to that walk, and the run reports "no .gcda anywhere on this filesystem, so nothing wrote them" -- the opposite diagnosis, sending the reader to permissions when the copy-back is what missed. My first probe of this was contaminated: counters from an earlier coverage run were still in the tree and in /tmp, so both arms found something and the walk looked fine. Re-run with a unique suffix and the arm established empty first: planted exactly: 1 file(s) under /tmp (tmpfs) elsewhere on rootfs: 0 (must be 0 for a fair arm) find / -xdev (as written) 0 find / (no -xdev, control) 1 find /tmp/xdevprobe (proposed) 1 GCOV_PREFIX is now asked first. The tree-wide walk is kept as the fallback because it answers a different question: gcov ignoring the redirect and writing beside an object outside src/, which the src-scoped count above would not see. selftest/250 pins the ORDER. Asked after, the walk's answer wins and the misdiagnosis returns, so presence alone would read as fixed and not be. Removal proof: swap the two probes and it goes RED, "got [no] want [yes]". ## The 49 is answered, and the outliers are now named The repaired breakdown answered it on the nightly, and it reconciles: 13 gcno /home/runner/work/pgcolumnar/pgcolumnar 1 gcda objstore 3 gcno objstore 33 gcda src 33 gcno src 13 + 3 + 33 = 49 and 1 + 33 = 34. src/ is 33 of 33, so nothing in the captured scope was missed and the ratio never meant what it looked like. A count is still a shape rather than an answer, so the runner now names the instrumented objects outside src/ instead of counting them. In the container that prints: -- instrumented objects outside src/, which the report does not cover: objstore/columnar_objstore_module.gcno The next nightly names the CI ones. Also: the repository root printed as an absolute path because the prefix strip needs a trailing slash the directory itself does not carry; it prints as "." now. harness_selftest: 136 checks PASSED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nightly 8 of 8 on
|
| toolchain | --cflags |
|---|---|
| PGDG PG 18 (what CI uses) | -flto=auto -ffat-lto-objects |
| source-built PG 17 (my container) | no -flto |
And reproduced end to end, building the tree against the PGDG PG 18 pg_config
in the container, same flags, no install:
total .gcno: 48
src .gcno: 33
... pgcolumnar.so.ltrans0..10.ltrans.gcno, pgcolumnar.so.wpa.gcno ...
48 against CI's 49, and the difference is exactly one ltrans partition:
-flto=auto takes the partition count from the CPU count. Same names, same
shape, src 33 either way.
So the arithmetic closes: 33 src + 1 real objstore object + 15 LTO link
artifacts = 49, and 33 + 1 = 34 counters. The 15 have no source lines to
cover. Nothing was missed, and the ratio never meant what it looked like. My
container reported 34 of 34 for the single reason that a source-built server
does not pass -flto down.
That is also why I would not guess at it a third time. Two of my three
explanations for this number would have been wrong.
The cloud review's finding, confirmed
find / -xdev for stray counters cannot see GCOV_PREFIX, because -xdev will
not cross a mount boundary and /tmp is a separate tmpfs here. Not just on the
distributions it named: on this project's own dev container and on my host.
My first probe was contaminated, counters from an earlier run were still in the
tree and in /tmp, so both arms found something and the walk looked fine.
Re-run with a unique suffix and the arm established empty first:
planted exactly: 1 file(s) under /tmp (tmpfs)
elsewhere on rootfs: 0 (must be 0 for a fair arm)
find / -xdev (as written) 0
find / (no -xdev, control) 1
find /tmp/xdevprobe (proposed) 1
GCOV_PREFIX is asked first now; the tree-wide walk stays as the fallback
because it answers a different question, gcov ignoring the redirect and writing
beside an object outside src/. selftest/250 pins the ORDER, since asked
second the walk's answer wins and the misdiagnosis is back while the line is
still present. Removal proof: swap the probes, RED, got [no] want [yes].
State
nightly 33005313801 |
8 of 8 |
harness_selftest |
136 PASSED |
| coverage | 93.4% lines, 96.4% functions, 69.8% branches |
| diff vs main | 5 files, +226 / -1 |
Your two findings are both addressed, the optional docs line is taken, and the
cloud review's one nit is fixed with a guard. Over to you.
Item 3 of the #745 review, and the one I had not found myself. $GCOV_PREFIX is a fixed path at mode 1777 and the copy-back runs as root under sudo. `_dest` was the found path with the prefix stripped, gated only on its parent existing, so any local unprivileged user chose both the content and the destination: plant $GCOV_PREFIX/<anywhere>/x.gcda and root copies it to <anywhere>/x.gcda. Reproduced with the loop exactly as it stood, planting as `postgres` into a directory outside the tree: planted as: postgres -- counters returned beside their objects: 1 RESULT: -rw-r--r-- /root/pgc_target/evil.gcda CONTENT: pgc-745-proof-of-arbitrary-root-write Constrained to names ending .gcda, and not reachable on GitHub's single-tenant ephemeral runner. It is reachable on any shared or developer box, which this script's own header invites by documenting that it runs under sudo. `case "$_dest" in "$SRCDIR"/*) ;; *) continue ;; esac` before the copy. Both arms, because a containment that refuses everything is not a fix: -- counters returned: 1 refused out-of-tree: 1 attack dest : NOT WRITTEN (blocked) legit dest : /root/pgc740c/src/columnar_real.gcda selftest/250 pins it BEFORE the copy, since placed after it is a comment rather than a containment. Removal proof: delete the case block and it goes RED, "got [no] want [yes]". Worth stating plainly: this was added by the commit that fixed #740, in the same loop, and I wrote a diagnostic for it without asking what the loop itself trusted. The reviewer asked. harness_selftest: 138 checks PASSED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ibutions Self-audit. The comments justifying the GCOV_PREFIX-first probe said /tmp is a separate tmpfs "on most systemd distributions". I measured two boxes, this project's dev container and the development host. "Most" is a claim about a population I never sampled, sitting in a comment whose whole argument is that the previous diagnostic asserted instead of measuring. Replaced with the two I checked, and an explicit note that I have not measured how common it is elsewhere. The argument does not need the quantifier: one box where it happens is enough to make the probe wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Item 3 fixed and proved, nightly 8 of 8 with it, plus a correction to my own claimThe containmentReproduced your write before fixing it, with the loop exactly as it stood:
And the control that matters in CI, nightly Items 1 and 2 were already in The PR description is rewritten. All four of your corrections were right, A claim of mine that did not survive its own auditI told you the
Same tree, same flags. The container has 8 CPUs and makes 11 partitions; the Untested candidate: the toolchains differ, gcc 15.2 on Ubuntu 26.04 here against It does not touch the conclusion, and that is worth being explicit about While auditing I also re-derived one claim I had been repeating from the earlier And a comment fix in Ready for the next green. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
All three findings fixed, and the count defect you found off the first one is the better
catch of the two. Verified independently rather than read: every claim below is something I
ran on the real 3-way merge (main 93a2eda + 40ff2578, merges CLEAN), pg18a assert in my
container.
The three, each against the old version as a control
1. The breakdown now prints directories. Same synthetic tree through both versions:
new: 1 gcno . 1 gcno objstore 1 gcda src 2 gcno src
old: 1 gcda 4 gcno <- the bare ratio
2. The stray-counter probe now finds what the redirect wrote. On this container, where
/tmp is a separate tmpfs, one counter under the prefix and none elsewhere:
new probe (prefix first): FOUND /tmp/pgc-gcov/.../columnar_reader.gcda
old probe (find / -xdev): (nothing) -> would have said "nothing wrote them"
3. The containment holds, and does not break the real path. I replayed the exact exploit
from my last review against the loop as it now stands, with a positive control beside it,
because a containment that skipped every legitimate counter would look like a fix and produce
zero coverage:
| arm | result |
|---|---|
postgres plants $GCOV_PREFIX/etc/x.gcda, loop runs as root |
/etc/x.gcda not created |
a legitimate counter under $SRCDIR/src |
copied, counters returned: 1 |
And end to end, which is the part that was still uncovered
Your nightly on 0362acd predated the containment commit, so I installed lcov here (it was
missing, which is why I could not close #740 myself) and ran the runner on the merged tree,
root-owned at 755, under sudo:
-- counters redirected to /tmp/pgc-gcov
-- counters returned beside their objects: 33
-- counters: 33 .gcda from 33 instrumented objects in src/
1 gcno objstore
33 gcda src
33 gcno src
-- instrumented objects outside src/, which the report does not cover:
objstore/columnar_objstore_module.gcno
rc=0 coverage.info 450,783 bytes, html/index.html present
Then I found you had dispatched the nightly on 72e1af26 after all: run 33008171537,
8 of 8 green, coverage job reporting 34 returned / 33 of 33 in src/, 213 suites passed.
gh run list --commit returns empty for it, which is how I missed it first time.
The three new guards are live
Each mutation reddens only its own check; the tree restores to 138 checks PASSED:
| mutation | result |
|---|---|
revert the count to $SRCDIR |
RED counts the directory lcov captures: got [$SRCDIR] want [$SRCDIR/src] |
probe -xdev before $GCOV_PREFIX |
RED looks in GCOV_PREFIX before the tree-wide walk: got [no] want [yes] |
delete the containment case |
RED on the check and its premise |
Pinning the count directory against the capture directory as source text rather than
asserting src twice is the right call: it is the version that survives someone widening the
capture later.
Head moved twice while I was reviewing
I verified 72e1af26; the head is now 40ff2578. I diffed them and it is comment-only, no
executable line touched, so the verification carries. Worth saying that 40ff2578 is you
replacing "most systemd distributions" with what you actually measured, in a comment nobody
would have challenged.
One suggestion, not a condition
The CHANGELOG entry covers the capture fix but says nothing about the copy-back containment.
It is a test script, so it is defensible to leave it out, but it is also the one change here
that stops root writing a file outside the tree at a local user's choosing. A sentence would
mean the next person to read the entry knows it happened.
Approving. CI green on the head, nightly green on the commit before it with a comment-only
diff between, and everything I could think to break, I broke and it reddened.
— reviewed as OffgridwithJD
Closes #740. Filed by @ChronicallyJD, whose diagnosis I verified independently
and did not need to re-open. What was missing was a measured fix.
This description was rewritten after review. The first version described the
chownapproach, which is superseded and which this PR's own code and CHANGELOGsay is not sufficient. It also claimed a guard that does not exist and disclaimed
a nightly that had by then run green. Corrected below; the history is in the
commits and the thread.
Root cause
gcov writes each
.gcdabeside its object, at the path recorded at compiletime, as the process that ran the code.
run_coverage.shruns undersudoinCI, so the build is root-owned, while
lib.sh:150runs the server aspostgreswhenever it is root. The backend could not create a counter file next to a
root-owned object. Nothing was ever written and
lcovhad nothing to capture.The fix is a redirect, and ownership alone could not have worked
Making the object directories writable was the first attempt and it is not
sufficient: creating a file needs execute on every ancestor too. Measured
on the runner, with the object directories already chowned:
/home/runneris 750 andpostgresis neither its owner nor in therunnergroup, so it cannot traverse into the workspace at all. That route could only
work by chmod'ing a shared runner's home.
GCOV_PREFIXmakes gcov write to$GCOV_PREFIX/<absolute path>.gcdainstead./tmpis world-writable and world-traversable, so it holds whoever the serverruns as and wherever the tree lives.
GCOV_PREFIX_STRIP=0keeps the full path,which is what lets the counters be returned exactly beside their
.gcno.runuserpasses the environment through; verified rather than assumed.GCOV_PREFIXredirectMeasured in CI, which is the only evidence that counts here
Nightly
33005313801on this head, 8 of 8, includingcoverage report (PG 18)which has never passed before:The last green nightly before this, on 2026-07-30, was green because the
coverage job did not exist yet. This is the first run in which it has produced a
number.
Second-order fixes
where they should have been. What kept this unexamined for 25 nights is that
lcov failing on an empty tree reports "capture produced nothing", which reads
as a broken tool rather than a permission problem.
coverage/htmlandcoverage.infowere, so a suite failing inside this job had its detail discarded with the
runner. That is how The extension-upgrade guard never runs in CI; the coverage runner turns that into a permanent nightly red #741's failing suite stayed invisible.
The
34 of 49is answered: LTO, not a coverage gap--cflags-flto=auto -ffat-lto-objects-fltoPGXS passes those to the extension, so GCC's link stage emits a
.gcnoper LTOpartition plus one for whole-program analysis (
pgcolumnar.so.ltrans0..11,.wpa). Those have no source lines. 33 src + 1 real objstore object + 15 LTOartifacts = 49, and 33 + 1 = 34 counters, so every real object had one.
Reproduced by building against the PGDG
pg_config: 48. Why 11 partitions hereand 12 in CI is unexplained; my first answer, that
-flto=autosizes themfrom the CPU count, is refuted by the control (
tasksetthe same build: 2, 4 and8 CPUs all give 11). Untested candidate is the toolchain difference, gcc 15.2
here against ubuntu-24.04's gcc 13. It does not bear on the conclusion, which
rests on
srcbeing 33 of 33 and is measured directly.Guards
test/selftest/250pins five properties, all of them things that are invisiblefrom the lines merely being present:
GCOV_PREFIXafter the suites run.gcdatree-wide instead of the captured directoryGCOV_PREFIXfor straysharness_selftest: 138 checks PASSED (132 on main).Review items
All four addressed, and two of the three I had found independently before the
review landed.
The by-directory breakdown printed no directory. Correct, and the cause is
as diagnosed: the leading
sedexpression matches the whole line. Replacedwith one awk pass.
The stray probe was blind to
GCOV_PREFIX. Correct.-xdevwill notcross a mount boundary and
/tmpis tmpfs here too.GCOV_PREFIXis askedfirst; the tree-wide walk stays as the fallback for gcov ignoring the redirect
entirely.
Root copying a 1777 directory's files to an unconstrained destination.
Correct, and I had missed it. Reproduced end to end: a file planted as
postgresunder$GCOV_PREFIX/root/pgc_target/was written by root outsidethe tree. Containment added; both arms proved, since a containment that
refuses everything is not a fix:
The earlier guard-scope finding, that the refusal counted tree-wide while
the capture is scoped to
src/. Reproduced and fixed.🤖 Generated with Claude Code