diff --git a/.github/ci/reap-census-selftest.sh b/.github/ci/reap-census-selftest.sh index e43542c3..ab1d4763 100644 --- a/.github/ci/reap-census-selftest.sh +++ b/.github/ci/reap-census-selftest.sh @@ -112,6 +112,19 @@ assert_false in_scope "" # A prefix that merely SHARES A NAME PREFIX with a root is not under it. assert_false in_scope "/ci/workspace-other/target/debug/spt" +echo "== orphanable descendants retain per-test attribution ==" +env NEXTEST_TEST_NAME='spt::activity_frames' sleep 5 & +attributed_pid=$! +# The async child must exec `env`/`sleep` before /proc exposes the new environment. +for attempt in $(seq 1 100); do + [ "$(nextest_test_of "$attributed_pid")" = 'spt::activity_frames' ] && break + sleep 0.01 +done +assert_eq "spt::activity_frames" "$(nextest_test_of "$attributed_pid")" \ + "nextest identity is read from the descendant rather than inferred from timing" +kill "$attributed_pid" 2>/dev/null || true +wait "$attributed_pid" 2>/dev/null || true + echo "== unreadable family paths refuse a clean survivor verdict ==" assert_eq "0" "$(survivor_verdict 0 0)" \ "a fully readable empty census may report zero survivors" diff --git a/.github/ci/reap-census.sh b/.github/ci/reap-census.sh index eeafe870..d530e5eb 100644 --- a/.github/ci/reap-census.sh +++ b/.github/ci/reap-census.sh @@ -48,7 +48,7 @@ set -uo pipefail # in gate round 1 were ALL in that logic and ALL invisible without a Linux box to run on. # A rig that only exists on the machine that already has the problem is not a rig. if [ "${SPT_CI_REAP_LIB:-0}" != 1 ]; then - PHASE="${1:?usage: reap-census.sh }" + PHASE="${1:?usage: reap-census.sh }" fi SETTLE_SECS="${SPT_CI_REAP_SETTLE_SECS:-5}" @@ -110,6 +110,18 @@ exe_of() { # which is exactly why it is not trusted for membership — see the header. comm_of() { cat "/proc/$1/comm" 2>/dev/null | tr -d '\n'; } +# The nextest process-per-test runner exports this identity into each test +# process; daemon and brain descendants inherit it, and retain it after PPID +# becomes 1. Reading the survivor's own environment therefore attributes a leak +# without guessing from timing or from whichever test happened to fail nearby. +nextest_test_of() { + local value + value=$(tr '\0' '\n' <"/proc/$1/environ" 2>/dev/null | + sed -n 's/^NEXTEST_TEST_NAME=//p' | sed -n '1p') + [ -n "$value" ] || value='' + printf '%s' "$value" | tr '[:space:]' '_' +} + comm_is_family() { local c="$1" name [ -n "$c" ] || return 1 @@ -192,7 +204,7 @@ flood_count() { # Publishes the scoped count in CENSUS_SCOPED; human-readable rows go to stdout under one # greppable summary line, so a run-to-run diff is a grep rather than a read. census() { - local label="$1" scoped=0 total=0 unreadable=0 pid exe tag rows="" d c + local label="$1" scoped=0 total=0 unreadable=0 pid exe tag test_name rows="" d c local box_procs=0 # file-nr's first field is system-wide allocated file handles — the cheap analog of the # Windows census's box-wide handle count, with no lsof sweep. @@ -216,7 +228,8 @@ census() { elif [ -z "$exe" ]; then tag='UNREAD '; unreadable=$((unreadable + 1)) else tag='FOREIGN ' fi - rows+=" $tag pid=$pid ${exe:- comm=${c:-?}}"$'\n' + test_name=$(nextest_test_of "$pid") + rows+=" $tag pid=$pid test=$test_name ${exe:- comm=${c:-?}}"$'\n' done local floods; floods=$(flood_count) @@ -233,6 +246,59 @@ survivor_verdict() { if [ "$unreadable" -gt 0 ]; then printf 'UNPROVEN'; else printf '%s' "$observed"; fi } +# Positive control for per-test attribution. It drives the same `spt daemon run` +# spawn/re-exec path as the leaking E2Es, then reads the inherited nextest name +# from BOTH live process classes before any missing attribution is interpreted. +attribution_control() { + local target="${CARGO_TARGET_DIR:-${GITHUB_WORKSPACE:-}/target}" + local spt_bin="${target%/}/debug/spt" + if [ ! -x "$spt_bin" ]; then + echo "::error::CI-CENSUS attribution control: built spt not found at $spt_bin" + return 1 + fi + local control_home + control_home=$(mktemp -d "${RUNNER_TEMP:-/tmp}/spt-census-control.XXXXXX") || return 1 + mkdir -p "$control_home/identity" + printf 'not-a-valid-seed' >"$control_home/identity/node.key" + env -u OWL_SESSION_ID -u SPT_AGENT_ID -u SPT_ENDPOINT_ID \ + SPT_HOME="$control_home" NEXTEST_TEST_NAME='CI-CENSUS::positive-control' \ + "$spt_bin" daemon run >"$control_home/stdout.log" 2>"$control_home/stderr.log" & + local broker_pid=$! brain_pid='' broker_name='' brain_name='' i + for i in $(seq 1 600); do + brain_pid=$(sed -n 's/.*"pid"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' \ + "$control_home/brain.ready" 2>/dev/null | sed -n '1p') + [ -n "$brain_pid" ] && break + sleep 0.1 + done + broker_name=$(nextest_test_of "$broker_pid") + [ -n "$brain_pid" ] && brain_name=$(nextest_test_of "$brain_pid") + + timeout 15s env -u OWL_SESSION_ID -u SPT_AGENT_ID -u SPT_ENDPOINT_ID \ + SPT_HOME="$control_home" "$spt_bin" daemon stop --force \ + >"$control_home/stop.log" 2>&1 || true + for i in $(seq 1 50); do + if ! kill -0 "$broker_pid" 2>/dev/null && + { [ -z "$brain_pid" ] || ! kill -0 "$brain_pid" 2>/dev/null; }; then + break + fi + sleep 0.1 + done + if kill -0 "$broker_pid" 2>/dev/null || + { [ -n "$brain_pid" ] && kill -0 "$brain_pid" 2>/dev/null; }; then + echo "::error::CI-CENSUS attribution control cleanup unproven; preserving $control_home (broker=$broker_pid brain=${brain_pid:-unknown})" + return 1 + fi + wait "$broker_pid" 2>/dev/null || true + rm -rf "$control_home" + + if [ "$broker_name" != 'CI-CENSUS::positive-control' ] || + [ "$brain_name" != 'CI-CENSUS::positive-control' ]; then + echo "::error::CI-CENSUS attribution control FAILED: broker=$broker_name brain=${brain_name:-}. Missing survivor attribution is UNKNOWN; name no test." + return 1 + fi + echo "CI-CENSUS attribution control OK: broker and brain retained NEXTEST_TEST_NAME across the production daemon spawn path" +} + # ---- Predicates end here. Sourced as a library, we stop before touching the box. ---- if [ "${SPT_CI_REAP_LIB:-0}" = 1 ]; then return 0 2>/dev/null || exit 0 @@ -249,6 +315,11 @@ for r in "${roots[@]}"; do echo " root: $r"; done # scoped_survivors=0, which is exactly what today's continue-and-exit-0 path would do. self_check || true +if [ "$PHASE" = attribution-control ]; then + attribution_control + exit $? +fi + if [ "$PHASE" = start ]; then census start if [ "${CENSUS_SCOPED:-0}" -gt 0 ]; then diff --git a/.github/workflows/golden.yml b/.github/workflows/golden.yml index 1c606f8e..d0bb6e5d 100644 --- a/.github/workflows/golden.yml +++ b/.github/workflows/golden.yml @@ -367,6 +367,14 @@ jobs: cargo build -p spt-daemon --bin service_fixture cargo build --workspace + # A survivor with no test identity names nobody. Before the battery creates + # evidence, prove that nextest's identity survives the exact daemon→brain + # spawn/re-exec path the post-job census will inspect. + - name: Census per-test attribution positive control + if: runner.os == 'Linux' + shell: bash + run: bash .github/ci/reap-census.sh attribution-control + # `cargo nextest` runs every test across ALL test binaries in ONE parallel # pool — `cargo test` runs the binaries SERIALLY (only threads WITHIN a # binary). nextest also builds the test targets, so the old standalone `cargo