diff --git a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh index d0b91baa..1a8ea800 100644 --- a/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh +++ b/packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh @@ -39,11 +39,12 @@ ORIG_PATH="$PATH" # loop — which would make the control a false negative. A root dotfile is # operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit. STRIPPED="$FW/.install-rollback-control.tmp.sh" +SIGNALED="$FW/.install-signal-control.tmp.sh" NOEXIT="$FW/.install-noexit-control.tmp.sh" D1CTRL="$FW/.install-d1guard-control.tmp.sh" D2CTRL="$FW/.install-d2guard-control.tmp.sh" -rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL" -trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT +rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL" +trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT pass=0; fail=0 chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; } @@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \ # ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ────── # A bash signal trap that merely returns lets the script continue past the # interrupt — restoring the snapshot, then resuming the sync and reporting -# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never -# fires and ONLY the signal path governs), and assert the shipped installer -# restores AND exits without reporting success. The control strips `exit 1` from -# the trap and shows the buggy resume-to-success. -make_term_shim() { - local dir="$1" - cat > "$dir/cp" </dev/null # signal install.sh; the copy still succeeds - exec env PATH="$ORIG_PATH" cp "\$@" ;; -esac -exec env PATH="$ORIG_PATH" cp "\$@" -SHIM - chmod +x "$dir/cp" +# success. The earlier test used a child cp shim to signal its parent, making +# child completion race Bash's interrupted wait. Concurrency is not part of the +# guarded property: sync_framework_keep() runs in the installer's own Bash +# process, and `kill` is a builtin. Generate two installer fixtures that signal +# themselves at the same known mid-sync point. Their TERM handlers emit the same +# observable before diverging, so missing signal delivery fails BOTH arms rather +# than manufacturing a pass. The only semantic difference between fixtures is +# the explicit `exit 1` whose load-bearing behavior this control proves. +TERM_MARKER='[test-control] TERM handler entered' +HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER" +HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER" + +make_signal_installer() { + local output="$1" handler="$2" + local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM" + local target_cp=' cp "$abs" "$dst/$rel"' + local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then" + local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION' + local inject_close=' fi' + + if ! awk \ + -v target_trap="$target_trap" -v target_cp="$target_cp" \ + -v handler="$handler" -v inject_open="$inject_open" \ + -v inject_kill="$inject_kill" -v inject_close="$inject_close" ' + $0 == target_cp { + print inject_open + print inject_kill + print inject_close + injection_sites++ + } + { print } + $0 == target_trap { + print handler + handler_sites++ + } + END { + if (handler_sites != 1 || injection_sites != 1) exit 42 + } + ' "$INSTALL" > "$output"; then + rm -f "$output" + fail "Could not construct the self-TERM control installer at the exact trap/copy sites" + exit 1 + fi + chmod +x "$output" } -# Run one keep-mode upgrade with the SIGTERM shim. Echoes "\t\t". +make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT" +make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT" +signal_fixture_ready() { + local fixture="$1" expected_handler="$2" + [[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \ + && [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \ + && grep -Fqx "$expected_handler" "$fixture" +} +signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; } +noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; } +chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \ + "signaled_fixture_ready" +chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \ + "noexit_fixture_ready" +chk "[control] removing the explicit TERM exit changes the fixture" \ + "! cmp -s '$SIGNALED' '$NOEXIT'" + +# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at +# the selected copy. Echoes "\t\t". run_signal_upgrade() { - local installer="$1" H OUT SHIM rc - H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d) + local installer="$1" H OUT rc + H=$(mktemp -d); OUT=$(mktemp) seed_home "$H" - make_term_shim "$SHIM" set +e - PATH="$SHIM:$ORIG_PATH" \ + PATH="$ORIG_PATH" \ MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1 rc=$? set -e 2>/dev/null || true - rm -rf "$SHIM" printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H" } -IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL") +IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED") +chk "[signal] TERM handler observable fires exactly once" \ + "[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]" chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \ "[ '$rcC' -ne 0 ]" chk "[signal] restore_snapshot fires on the interrupt" \ @@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \ chk "[signal] does NOT resume to report sync success after the interrupt" \ "! grep -q 'file phase complete' '$OUTC'" -# Control: strip `exit 1` from the signal trap → the handler returns, the script -# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves. -sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \ - "$INSTALL" > "$NOEXIT" -chk "[control] the exit-strip actually changed the installer" \ - "! cmp -s '$INSTALL' '$NOEXIT'" -IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT") +IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT") +chk "[control] TERM handler observable fires exactly once" \ + "[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]" +chk "[control] without 'exit 1' the handler restores before returning" \ + "grep -q 'restoring previous state from snapshot' '$OUTD'" +chk "[control] without 'exit 1' the installer exits zero after resuming" \ + "[ '$rcD' -eq 0 ]" chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \ "grep -q 'file phase complete' '$OUTD'" @@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe # Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned). grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done -# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap). +# Cleanup (generated installer controls are also removed by the EXIT trap). for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \ - "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL" + "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL" echo echo "RESULT: $pass passed, $fail failed"