[Fix] (Test): stop G7's descriptor control carrying on past a Ctrl-C - latch the interrupt from the trap and from a signalled child, and report it as an interruption rather than as the contract tree's verdict

This commit is contained in:
2026-09-08 19:08:12 -04:00
committed by rereview
parent 993ce0fb76
commit 9a0a7f7608
+64 -12
View File
@@ -88,7 +88,11 @@
# very build directory, and every reading they take there would come from a deliberately corrupted # very build directory, and every reading they take there would come from a deliberately corrupted
# library. So repair() runs from the EXIT trap as well - a mid-way failure (a patched header that # library. So repair() runs from the EXIT trap as well - a mid-way failure (a patched header that
# would not compile, an interrupt) repairs too - and a repair that itself fails downgrades the # would not compile, an interrupt) repairs too - and a repair that itself fails downgrades the
# verdict to 2. On the exits that happen BEFORE any patch (bad arguments, a missing header, a # verdict to 2. The state repair() reads lives in THIS shell and not in a command substitution,
# which is what makes the trap version of it a real repair rather than a no-op, and every child's
# exit status is additionally checked for "killed by a signal" so that a Ctrl-C stops the run
# instead of carrying on into the next control and signing off with a verdict about a control
# nobody ran. On the exits that happen BEFORE any patch (bad arguments, a missing header, a
# missing suite) the tree was never touched and the build directory still holds what the caller # missing suite) the tree was never touched and the build directory still holds what the caller
# built: there is nothing to restore and nothing to rebuild, and the script says which of the two # built: there is nothing to restore and nothing to rebuild, and the script says which of the two
# situations it is leaving behind. # situations it is leaving behind.
@@ -102,10 +106,11 @@
# red without ever naming the field, so the red cannot be attributed to the drop. # red without ever naming the field, so the red cannot be attributed to the drop.
# Both are findings about the TEST, not errors in this script - and both leave the # Both are findings about the TEST, not errors in this script - and both leave the
# tree restored AND rebuilt AND re-run; # tree restored AND rebuilt AND re-run;
# 2 a control could not be run at all (bad arguments; a header or a field absent on # 2 a control could not be run at all (bad arguments; a build directory that is not a
# this tree; no matching test; a build that was already broken; a patched header that # push one; a header or a field absent on this tree; no matching test; a build that
# did not compile; a failed restore or a failed rebuild after one). Exit 2 wins over # was already broken; a patched header that did not compile; a failed restore or a
# exit 1: "could not run" is never reported as "did not answer". # failed rebuild after one; the run was INTERRUPTED). Exit 2 wins over exit 1:
# "could not run" is never reported as "did not answer".
set -u -o pipefail set -u -o pipefail
BUILD_DIR="" BUILD_DIR=""
@@ -203,9 +208,23 @@ repair() {
# that reader does is build, from a hard-wired field, with nothing saying so. The two extra traps # that reader does is build, from a hard-wired field, with nothing saying so. The two extra traps
# repair and then re-raise with the default disposition, so the exit status still reports the # repair and then re-raise with the default disposition, so the exit status still reports the
# signal. Armed for the whole run: before the first patch repair() is a no-op. # signal. Armed for the whole run: before the first patch repair() is a no-op.
#
# AND THE TRAPS ARE NOT THE ONLY DETECTION, because bash's SIGINT semantics do not guarantee that
# they run. A Ctrl-C in a terminal goes to the whole PROCESS GROUP, so the `cmake` this script is
# waiting on dies first and returns 130 - and bash, having a handler installed, may go on to the
# next command rather than to the trap. Measured exactly that way: the tree WAS repaired (through
# the explicit failure path below, which is why the state has to live in this shell), but the run
# then carried on into the second control and signed off with the summary it prints on an
# untouched contract tree. Repaired and wrong is still wrong. So every child's status is checked
# for "killed by a signal" as well, INTERRUPTED latches either way, and the run stops and says so.
INTERRUPTED=""
note_interrupt() { [ -n "$INTERRUPTED" ] || INTERRUPTED=$1; }
# 128 + signal number is how a shell reports a child that died on a signal; nothing this script
# runs exits above 128 for any other reason (ctest uses 8 for failing tests).
child_was_signalled() { [ "${1:-0}" -ge 128 ]; }
trap 'note_interrupt INT; repair; trap - INT; kill -INT $$' INT
trap 'note_interrupt TERM; repair; trap - TERM; kill -TERM $$' TERM
trap 'repair' EXIT trap 'repair' EXIT
trap 'repair; trap - INT; kill -INT $$' INT
trap 'repair; trap - TERM; kill -TERM $$' TERM
# --- one control ----------------------------------------------------------------------------- # --- one control -----------------------------------------------------------------------------
# Sets the GLOBAL CONTROL_VERDICT to "tripped" / "did-not-trip" / "wrong-reason" / # Sets the GLOBAL CONTROL_VERDICT to "tripped" / "did-not-trip" / "wrong-reason" /
@@ -329,7 +348,17 @@ PY
# 3. it must still COMPILE. A build break here would prove the static_asserts work, not that the # 3. it must still COMPILE. A build break here would prove the static_asserts work, not that the
# suite still checks. # suite still checks.
say "[$tag] rebuilding with the dropped field" say "[$tag] rebuilding with the dropped field"
if ! cmake --build "$BUILD_DIR" -j "$(nproc)" </dev/null > "$LOG_DIR/build-after-$tag.log" 2>&1; then cmake --build "$BUILD_DIR" -j "$(nproc)" </dev/null > "$LOG_DIR/build-after-$tag.log" 2>&1
buildRc=$?
if child_was_signalled "$buildRc" || [ -n "$INTERRUPTED" ]; then
note_interrupt "exit $buildRc"
say "[$tag] INTERRUPTED during the rebuild ($INTERRUPTED). Repairing the tree and stopping:"
say "a run that carried on here would report a verdict about a control that never ran."
repair || true
CONTROL_VERDICT=could-not-run
return
fi
if [ "$buildRc" -ne 0 ]; then
say "[$tag] the patched header did not compile, so the control cannot tell 'the test failed'" say "[$tag] the patched header did not compile, so the control cannot tell 'the test failed'"
say "from 'nothing was built'. The break is supposed to be invisible to the compiler - if the" say "from 'nothing was built'. The break is supposed to be invisible to the compiler - if the"
say "field is read somewhere that needs its value, say so in the control rather than working" say "field is read somewhere that needs its value, say so in the control rather than working"
@@ -345,8 +374,18 @@ PY
# reported and nothing returns until step 5 has put the tree back, because all three outcomes # reported and nothing returns until step 5 has put the tree back, because all three outcomes
# leave the same corrupted build directory behind. # leave the same corrupted build directory behind.
say "[$tag] running $test against the dropped field" say "[$tag] running $test against the dropped field"
if ctest --test-dir "$BUILD_DIR" -R "$test" --no-tests=error --output-on-failure </dev/null \ ctest --test-dir "$BUILD_DIR" -R "$test" --no-tests=error --output-on-failure </dev/null \
> "$LOG_DIR/ctest-after-$tag.log" 2>&1; then > "$LOG_DIR/ctest-after-$tag.log" 2>&1
ctestRc=$?
if child_was_signalled "$ctestRc" || [ -n "$INTERRUPTED" ]; then
note_interrupt "exit $ctestRc"
say "[$tag] INTERRUPTED while running $test ($INTERRUPTED). A suite that was killed is not a"
say "suite that went red: repairing the tree and stopping rather than scoring the control."
repair || true
CONTROL_VERDICT=could-not-run
return
fi
if [ "$ctestRc" -eq 0 ]; then
CONTROL_VERDICT=did-not-trip CONTROL_VERDICT=did-not-trip
elif awk '/: Failure$/ || /: error:/ { block = 1 } block { print } /^[[:space:]]*$/ { block = 0 }' \ elif awk '/: Failure$/ || /: error:/ { block = 1 } block { print } /^[[:space:]]*$/ { block = 0 }' \
"$LOG_DIR/ctest-after-$tag.log" | grep -q "$field"; then "$LOG_DIR/ctest-after-$tag.log" | grep -q "$field"; then
@@ -422,6 +461,12 @@ for mglRow in "${CONTROL_ROWS[@]}"; do
could-not-run) WORST=2 ;; could-not-run) WORST=2 ;;
*) [ "$WORST" -eq 2 ] || WORST=1 ;; *) [ "$WORST" -eq 2 ] || WORST=1 ;;
esac esac
if [ -n "$INTERRUPTED" ]; then
SUMMARY="$SUMMARY
(INTERRUPTED - the remaining control(s) did not run)"
WORST=2
break
fi
done done
trap - EXIT trap - EXIT
@@ -429,7 +474,14 @@ say "---- G7 (P4a descriptor emission) ----$SUMMARY"
case "$WORST" in case "$WORST" in
0) say "both controls tripped and named their field; exit 0" ;; 0) say "both controls tripped and named their field; exit 0" ;;
1) say "a control did not answer; exit 1" ;; 1) say "a control did not answer; exit 1" ;;
2) say "a control could not be run; exit 2 (this is the expected answer on the P4a contract tree," 2) if [ -n "$INTERRUPTED" ]; then
say "where both emit headers are the contract's stubs and copy nothing)" ;; say "the run was INTERRUPTED ($INTERRUPTED); exit 2. The tree was repaired before this line -"
say "\`git status\` is the check - and NOTHING here is a verdict about the controls."
else
say "a control could not be run; exit 2 (this is the expected answer on the P4a contract tree,"
say "where both emit headers are the contract's stubs and copy nothing)"
fi ;;
esac esac
# A run this shell interrupted still exits 2 rather than by signal, because the INT trap's
# re-raise only happens when bash actually reaches the trap. The summary above says which it was.
exit "$WORST" exit "$WORST"