Skip to content

Commit 8320c0f

Browse files
test: prove subprocess and production coverage (Fixes #534) (#562)
Make coverage evidence demonstrate real server execution and expose production gaps without changing existing whole-workspace regression gates. - Compare exact idle/info child profiles and require positive execution in the real handler, transport dispatch, and response writer after graceful shutdown. - Add production/test and changed-line diagnostics with conservative accounting for LLVM summary entries without unique source lines. - Require profile proof and reporting in Linux/Windows PR and baseline jobs; add native macOS coverage measured against the exact base on the same runner. - Document classification, branch-instrumentation limits, and artifact semantics. Validation: 76 Python tests, 19 Windows native cases, workspace formatting/Clippy, and independent review pass. Actual Windows LLVM profiles prove all three execution witnesses increase from 0 to 1. Hosted macOS validation and quality inspection remain pending; no signing/release services run. Fixes #534 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 597b599 commit 8320c0f

9 files changed

Lines changed: 993 additions & 1 deletion

File tree

‎.github/skills/rust-coding-skill/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,5 @@ Do not execute freshly written scripts as concurrent Unix subprocess fixtures: s
102102
For real-pipe EOF/EPIPE tests, create the pipe inside an isolated test subprocess when other test threads spawn children. Unix `CLOEXEC` closes descriptors at exec, not fork: a concurrent child can temporarily retain a reader, allowing the only write to succeed before the final reader disappears. A readiness handshake alone does not prevent this race. Keep the operation's measured deadline separate from setup, and make an outer fixture deadline cover readiness, waits both before and after forced termination, reader joins, and fallback `Drop` cleanup.
103103

104104
Use a per-worktree Cargo target directory when validating stacked changes so native fixtures cannot execute another worktree's stale binary. On WSL, run timing-sensitive Linux binaries from the native Linux filesystem rather than a Windows mount, where page faults can stall in filesystem RPC. When launching instrumented PET with `env_clear()`, retain `LLVM_PROFILE_FILE` exactly so child coverage reaches the collector instead of an uncollected default profile.
105+
106+
LLVM LCOV summaries are not necessarily counts of unique `DA` source entries: a native export can have `LF=191`, `LH=173`, 181 mapped lines, and 177 positive mapped lines. Do not reject valid exports using summary/source equality or silently drop unmapped entries; preserve the raw exact-base gate and follow the conservative supplemental accounting documented in [Quality snapshots](../../../docs/QUALITY_SNAPSHOTS.md#production-focused-and-subprocess-evidence). Prove subprocess collection with exact-PID profiles and isolated idle/request counter differences, not a whole-workspace percentage increase.

‎.github/workflows/coverage-baseline.yml‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ jobs:
2727
steps:
2828
- name: Checkout
2929
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
30+
with:
31+
fetch-depth: 0
32+
persist-credentials: false
3033

3134
- name: Set Python to PATH
3235
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -181,6 +184,21 @@ jobs:
181184
env:
182185
RUST_BACKTRACE: 1
183186
RUST_LOG: trace
187+
PET_SUBPROCESS_COVERAGE_PROOF: ${{ github.workspace }}/subprocess-coverage.json
188+
shell: bash
189+
190+
- name: Verify Isolated Server Coverage
191+
if: always()
192+
run: python scripts/coverage_detail.py proof --manifest subprocess-coverage.json --output subprocess-coverage
193+
shell: bash
194+
195+
- name: Report Production and Changed Coverage
196+
if: always()
197+
run: >-
198+
python scripts/coverage_detail.py report
199+
--lcov lcov.info
200+
--base "HEAD"
201+
--output production-coverage
184202
shell: bash
185203

186204
- name: Validate Coverage Baseline
@@ -194,8 +212,14 @@ jobs:
194212
shell: bash
195213

196214
- name: Upload Coverage Artifact
215+
if: always()
197216
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
198217
with:
199218
name: coverage-baseline-${{ matrix.os }}
200-
path: lcov.info
219+
path: |
220+
lcov.info
221+
coverage-baseline-report.md
222+
production-coverage/
223+
subprocess-coverage/
224+
subprocess-coverage.json
201225
retention-days: 90
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: Native macOS Coverage
2+
3+
on:
4+
pull_request:
5+
branches: [main, 'release*', 'release/*', 'release-*']
6+
push:
7+
branches: [main, 'release*', 'release/*', 'release-*']
8+
workflow_dispatch:
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
coverage:
15+
runs-on: macos-14
16+
timeout-minutes: 35
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20+
with:
21+
fetch-depth: 0
22+
persist-credentials: false
23+
24+
- name: Set Python to PATH
25+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
26+
with:
27+
python-version: '3.12'
28+
29+
- name: Rust Tool Chain setup
30+
uses: dtolnay/rust-toolchain@stable
31+
with:
32+
toolchain: stable
33+
components: llvm-tools-preview
34+
35+
- name: Install cargo-llvm-cov
36+
uses: taiki-e/install-action@cargo-llvm-cov
37+
38+
- name: Validate Coverage Reporting
39+
run: python -m unittest discover -s scripts/tests -p 'test_*.py' -v
40+
41+
- name: Collect Native macOS Coverage
42+
run: cargo llvm-cov --workspace --lcov --output-path lcov.info -- --test-threads=1
43+
env:
44+
PET_SUBPROCESS_COVERAGE_PROOF: ${{ github.workspace }}/subprocess-coverage.json
45+
46+
- name: Verify Isolated Server Coverage
47+
if: always()
48+
run: python scripts/coverage_detail.py proof --manifest subprocess-coverage.json --output subprocess-coverage
49+
50+
- name: Report Production and Changed Coverage
51+
if: always()
52+
env:
53+
BASE: ${{ github.event.pull_request.base.sha || github.sha }}
54+
run: python scripts/coverage_detail.py report --lcov lcov.info --base "$BASE" --output production-coverage
55+
56+
- name: Measure Exact PR Base on the Same Runner
57+
if: github.event_name == 'pull_request'
58+
env:
59+
BASE: ${{ github.event.pull_request.base.sha }}
60+
CARGO_TARGET_DIR: ${{ runner.temp }}/coverage-base-target
61+
PET_SUBPROCESS_COVERAGE_PROOF: ${{ runner.temp }}/base-subprocess-coverage.json
62+
run: |
63+
git worktree add --detach "$RUNNER_TEMP/coverage-base" "$BASE"
64+
cd "$RUNNER_TEMP/coverage-base"
65+
cargo llvm-cov --workspace --lcov --output-path "$GITHUB_WORKSPACE/baseline-lcov.info" -- --test-threads=1
66+
67+
- name: Compare Exact Base Coverage
68+
if: always() && github.event_name == 'pull_request'
69+
run: >-
70+
python scripts/quality_snapshot.py coverage
71+
--current lcov.info --baseline baseline-lcov.info --platform macOS
72+
--report coverage-report.md --summary "$GITHUB_STEP_SUMMARY"
73+
74+
- name: Validate Main Coverage
75+
if: github.event_name != 'pull_request'
76+
run: >-
77+
python scripts/quality_snapshot.py coverage
78+
--current lcov.info --baseline lcov.info --platform macOS
79+
--report coverage-report.md --summary "$GITHUB_STEP_SUMMARY"
80+
81+
- name: Upload Native macOS Coverage
82+
if: always()
83+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
84+
with:
85+
name: coverage-macos-native
86+
path: |
87+
lcov.info
88+
baseline-lcov.info
89+
coverage-report.md
90+
production-coverage/
91+
subprocess-coverage/
92+
subprocess-coverage.json
93+
retention-days: 30

‎.github/workflows/coverage.yml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
steps:
3333
- name: Checkout
3434
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
35+
with:
36+
fetch-depth: 0
37+
persist-credentials: false
3538

3639
- name: Post Coverage Started Comment
3740
if: github.event.pull_request.head.repo.full_name == github.repository
@@ -200,6 +203,21 @@ jobs:
200203
env:
201204
RUST_BACKTRACE: 1
202205
RUST_LOG: trace
206+
PET_SUBPROCESS_COVERAGE_PROOF: ${{ github.workspace }}/subprocess-coverage.json
207+
shell: bash
208+
209+
- name: Verify Isolated Server Coverage
210+
if: always()
211+
run: python scripts/coverage_detail.py proof --manifest subprocess-coverage.json --output subprocess-coverage
212+
shell: bash
213+
214+
- name: Report Production and Changed Coverage
215+
if: always()
216+
run: >-
217+
python scripts/coverage_detail.py report
218+
--lcov lcov.info
219+
--base "${{ github.event.pull_request.base.sha }}"
220+
--output production-coverage
203221
shell: bash
204222

205223
- name: Wait for Exact PR Base Coverage
@@ -248,6 +266,9 @@ jobs:
248266
path: |
249267
lcov.info
250268
coverage-report.md
269+
production-coverage/
270+
subprocess-coverage/
271+
subprocess-coverage.json
251272
if-no-files-found: ignore
252273

253274
- name: Post Coverage Comment

‎crates/pet/tests/jsonrpc_server_test.rs‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ struct RawRpcClient {
3838

3939
impl RawRpcClient {
4040
fn spawn() -> Self {
41+
Self::spawn_with_profile(None)
42+
}
43+
44+
fn spawn_with_profile(profile: Option<&Path>) -> Self {
4145
let mut command = Command::new(env!("CARGO_BIN_EXE_pet"));
4246
command
4347
.arg("server")
4448
.stdin(Stdio::piped())
4549
.stdout(Stdio::piped())
4650
.stderr(Stdio::inherit());
4751
jsonrpc_client::configure_isolated_pet_environment(&mut command);
52+
if let Some(profile) = profile {
53+
command.env("LLVM_PROFILE_FILE", profile);
54+
}
4855
let mut child = command.spawn().expect("raw fixture must spawn PET");
4956
let stdout = child.stdout.take().expect("PET stdout must be piped");
5057
let (sender, responses) = mpsc::channel();
@@ -819,6 +826,89 @@ fn invalid_and_oversize_framing_terminates_with_bounded_diagnostics() {
819826
}
820827
}
821828

829+
#[test]
830+
fn normal_shutdown_records_pid_unique_server_profiles() {
831+
let Some(proof_path) = std::env::var_os("PET_SUBPROCESS_COVERAGE_PROOF") else {
832+
return;
833+
};
834+
let inherited = PathBuf::from(
835+
std::env::var_os("LLVM_PROFILE_FILE")
836+
.expect("coverage proof requires cargo-llvm-cov instrumentation"),
837+
);
838+
let directory = inherited.parent().expect("profile must have a directory");
839+
assert!(
840+
directory.is_absolute(),
841+
"profile directory must be absolute"
842+
);
843+
assert!(
844+
inherited
845+
.file_name()
846+
.unwrap()
847+
.to_string_lossy()
848+
.contains("%p"),
849+
"cargo-llvm-cov must use PID-unique profiles"
850+
);
851+
let mut profiles = serde_json::Map::new();
852+
for (name, request) in [("idle", false), ("info", true)] {
853+
let prefix = format!("pet-proof-{}-{name}-", std::process::id());
854+
let pattern = directory.join(format!("{prefix}%p-%m.profraw"));
855+
let mut client = RawRpcClient::spawn_with_profile(Some(&pattern));
856+
let prefix = format!("{prefix}{}-", client.child.id());
857+
assert!(
858+
fs::read_dir(directory).unwrap().all(|entry| !entry
859+
.unwrap()
860+
.file_name()
861+
.to_string_lossy()
862+
.starts_with(&prefix)),
863+
"child profile must not predate this process exit"
864+
);
865+
if request {
866+
client.send(json!({"jsonrpc": "2.0", "id": "profile-proof", "method": "info"}));
867+
let reply = client.receive();
868+
assert_eq!(reply["id"], "profile-proof");
869+
assert_eq!(reply["result"]["petVersion"], env!("CARGO_PKG_VERSION"));
870+
}
871+
client.child.stdin.take();
872+
let status = jsonrpc_client::wait_for_exit(&mut client.child, Duration::from_secs(4))
873+
.expect("profile proof requires graceful exit, not kill fallback");
874+
assert!(
875+
status.success(),
876+
"profile probe exited unsuccessfully: {status}"
877+
);
878+
jsonrpc_client::join_reader(client.reader.take().unwrap(), Duration::from_secs(4)).unwrap();
879+
let raw: Vec<PathBuf> = fs::read_dir(directory)
880+
.unwrap()
881+
.map(|entry| entry.unwrap().path())
882+
.filter(|path| {
883+
path.file_name()
884+
.unwrap()
885+
.to_string_lossy()
886+
.starts_with(&prefix)
887+
})
888+
.collect();
889+
assert!(
890+
!raw.is_empty(),
891+
"normal server exit did not flush its own profile"
892+
);
893+
for path in &raw {
894+
assert!(
895+
fs::metadata(path).unwrap().len() > 0,
896+
"empty server profile"
897+
);
898+
}
899+
profiles.insert(name.into(), json!(raw));
900+
}
901+
fs::write(
902+
proof_path,
903+
serde_json::to_vec_pretty(&json!({
904+
"binary": env!("CARGO_BIN_EXE_pet"),
905+
"profiles": profiles,
906+
}))
907+
.unwrap(),
908+
)
909+
.expect("write subprocess coverage evidence");
910+
}
911+
822912
#[test]
823913
fn stdin_eof_after_exchange_exits_cleanly_within_one_second() {
824914
let client = PetJsonRpcClient::spawn().unwrap();

‎docs/QUALITY_SNAPSHOTS.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,61 @@ for the new client clocks.
113113

114114
Linux and Windows line and function coverage are compared with the exact base commit. A decrease greater than 0.01 percentage points blocks the pull request. Coverage artifacts and comments remain available for inspection even when the comparison fails.
115115

116+
### Production-focused and subprocess evidence
117+
118+
The raw workspace percentages still include inline tests and retain the same exact-base
119+
0.01 percentage-point line/function gate. Supplemental `production-coverage/report.md` and
120+
schema-1 `details.json` separate executable production/test lines, list uncovered production
121+
lines, and intersect added/modified Rust lines with executable production lines. Changed files
122+
without instrumentation are listed explicitly, never assumed covered. These diagnostics do not
123+
introduce a fabricated baseline, change the raw denominator, or replace regression protection.
124+
125+
Classification excludes integration-test/benchmark directories and Rust items explicitly marked
126+
`#[cfg(test)]` or `#[test]`, including inline modules and test-only helper functions. Nested
127+
`all`/`any` predicates are also excluded when they require `test`: `all(test, unix)` is test-only,
128+
but `any(test, unix)` is not. It masks
129+
strings, raw/byte strings, characters, and nested comments before matching item boundaries.
130+
Helpers outside those boundaries and unsupported conditional predicates remain conservatively in the
131+
production category; this is a source-focused diagnostic, not full Rust conditional-compilation
132+
analysis. Invalid/missing LCOV, missing source, inconsistent hit summaries, and source-line
133+
mismatches fail the reporting step. LLVM summaries can include more entries in `LF`/`LH`
134+
than the unique `DA` source lines (observed in real Windows exports). That deficit is reported
135+
per file (including unmatched summary hits) and conservatively retained as uncovered production
136+
in mixed source files, or uncovered tests in integration-test/benchmark files. It is never dropped
137+
from the denominator or silently assigned coverage.
138+
Changed lines without `DA` records are listed separately in JSON, including non-executable syntax;
139+
they are not silently considered covered.
140+
Native macOS also demonstrates `LH` below the number of positive unique `DA` entries. Reports
141+
retain this deficit and deduct `max(positive DA + unmapped LF - LH, 0)` from each covered
142+
production/test/changed subtotal (clamped at zero). This accounts for hits that could belong to
143+
unmapped entries instead of a mapped subset. These subtotals are lower bounds; the report does
144+
not pretend to locate the discrepancy on a particular source line. Raw LCOV and the exact-base gate remain intact.
145+
146+
Every coverage job opts into `normal_shutdown_records_pid_unique_server_profiles` through
147+
`PET_SUBPROCESS_COVERAGE_PROOF`. The test requires cargo-llvm-cov's absolute, PID-unique output
148+
pattern, launches idle and known-`info` PET subprocesses, closes stdin, and requires successful
149+
bounded exit and nonempty profiles for those exact child PIDs. The raw profiles stay in the normal
150+
cargo-llvm-cov collection directory and are included in the workspace report. The verifier also
151+
merges each child's profiles separately with the matching Rust LLVM tools and proves zero idle
152+
versus positive `info` execution at the real handler, transport dispatch, and response writer.
153+
The uploaded `subprocess-coverage/proof.json` and isolated LCOV exports retain that evidence;
154+
a killed child, missing profile, or absent execution witness fails rather than appearing covered.
155+
156+
Native ARM64 macOS coverage runs workspace default-feature and native process/transport tests,
157+
including Darwin-specific process ownership paths. It intentionally does not compare this workload
158+
with Linux/Windows's installed-manager `ci` workload. For each macOS PR, the exact base revision is
159+
built and measured separately on the same runner with the same compiler and feature selection;
160+
the unchanged line/function comparator gates those comparable artifacts. This works on the first
161+
PR without silently accepting an absent macOS baseline. Main/manual runs publish the native
162+
measurement and proof for inspection. Existing functional macOS installed-manager jobs remain.
163+
164+
Stable Rust line instrumentation does not provide condition/branch outcomes. Reports show LCOV
165+
`BRDA` totals when supplied, otherwise explicitly report branch data as unavailable (not 100%).
166+
Native malformed-frame/envelope, EOF, broken-output, saturation, and descendant tests provide
167+
behavioral failure-path evidence, but line coverage cannot prove both sides of every condition.
168+
Nightly `cargo llvm-cov --branch` can be used as a separate experiment; its unstable toolchain
169+
and differing denominator are not substituted into the stable cross-platform gate.
170+
116171
## Running locally
117172

118173
The comparator requires Python 3.10 or newer.

0 commit comments

Comments
 (0)