diff --git a/MobileGL/MG_Test/Wire/RemoteClientE1Controls.inc b/MobileGL/MG_Test/Wire/RemoteClientE1Controls.inc new file mode 100644 index 00000000..e08718bc --- /dev/null +++ b/MobileGL/MG_Test/Wire/RemoteClientE1Controls.inc @@ -0,0 +1,109 @@ +// E1: real transport wait boundaries, with a peer that cannot complete until +// either the client parks or its emission has already returned. No GPU or +// scheduling-delay assumption decides whether the wait was observed. +#if MGTEST_HAVE_FORK +namespace { +struct E1BoundaryPeer : Codec::WireVerbSink { + BudgetBell& bell; + std::atomic clientReturned{false}; + std::atomic seen{0}; + MGPWireOp expected; + explicit E1BoundaryPeer(BudgetBell& value, MGPWireOp op) : bell(value), expected(op) {} + + Bool Apply(MGPWireOp op) { + if (op != expected) ::_exit(84); + ++seen; + while (!bell.entered.load() && !clientReturned.load()) std::this_thread::yield(); + return true; + } + Bool OnGenerateMipmap(const MGPMipPlan&) override { return Apply(MGPWireOp::GenerateMipmap); } + Bool OnClear(const MGPClear&) override { return Apply(MGPWireOp::Clear); } + void Install() { + if (Srv::ServerLoopInstance().RunProbeOnApplyThreadForTesting([](void* self) { + auto& decoder = Srv::ServerSessionInstance().Applier().*PeerMember(DecoderTag{}); + decoder.SetVerbSink(static_cast(self)); + return MOBILEGL_OK; + }, this) != MOBILEGL_OK) ::_exit(82); + } +}; + +void CheckE1Boundary(Bool serverCap, MGPWireOp op, Bool expectWait, const char* diagnostic) { + const auto child = RunInChild([=] { + ::alarm(15); + const char* barrier = std::getenv("MOBILEGL_IPC_VERB_BARRIER"); + if (barrier && std::strcmp(barrier, "0") && std::strcmp(barrier, "1")) ::_exit(80); + MG_Config::Ipc.VerbBarrier = barrier ? static_cast(barrier[0] - '0') : 1; + MG_Config::Ipc.BatchWaits = 1; + MG_Config::Ipc.RunAhead = 1; + MG_Config::Ipc.SpinUs = 0; + MG_Config::Transport = MG_Config::TransportMode::InProcess; + const Uint64 caps = serverCap ? static_cast(kCapRunAheadApply) : 0; + Srv::ServerSessionInstance().SetCapabilityBits(caps); + Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a); + AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemsMigratedAtP4a, caps)); + auto& session = ClientSessionInstance(); + if (session.Start(MG_Config::TransportMode::InProcess, {}) != MOBILEGL_OK) ::_exit(81); + auto& producer = session.Producer(); + BudgetBell bell(producer.SelfDoorbell()); + E1BoundaryPeer peer(bell, op); + peer.Install(); + const Uint64 before = session.Control()->submittedSeq.load(); + producer.*PeerMember(ProducerBellTag{}) = &bell; + const MGPMipPlan mip{}; + const MGPClear clear{}; + const Uint64 seq = op == MGPWireOp::GenerateMipmap + ? session.EmitAndWait(op, &mip, sizeof(mip), nullptr, 0, nullptr, 0, nullptr) + : session.EmitAndWait(op, &clear, sizeof(clear), nullptr, 0, nullptr, 0, nullptr); + // Observe BEFORE allowing the peer to complete a non-waiting emission. + const Bool parked = bell.entered.load(); + const Bool appliedBeforeReturn = session.Control()->appliedSeq.load() >= seq; + peer.clientReturned.store(true); + producer.*PeerMember(ProducerBellTag{}) = bell.original; + if (session.WaitForApplied(seq, 2000) != Transport::SessionWait::Reached) ::_exit(85); + if (seq <= before || peer.seen.load() != 1 || session.Control()->appliedSeq.load() < seq) ::_exit(86); + const Bool correct = expectWait ? parked && appliedBeforeReturn : !parked && !appliedBeforeReturn; + std::printf("E1 observation: op=%s cap=%u barrier=%u parked=%u applied_before_return=%u emitted=1 peer_seen=1\n", + op == MGPWireOp::GenerateMipmap ? "GenerateMipmap" : "Clear", + static_cast(serverCap), MG_Config::Ipc.VerbBarrier, + static_cast(parked), static_cast(appliedBeforeReturn)); + std::fflush(stdout); + session.Stop(); + ::_exit(correct ? 0 : 101); + }); + ASSERT_TRUE(WIFEXITED(child.Status)) << DescribeStatus(child) << '\n' << child.Log; + const int code = WEXITSTATUS(child.Status); + ASSERT_TRUE(code == 0 || code == 101) << "E1 probe/setup failed: " << DescribeStatus(child) << '\n' << child.Log; + EXPECT_EQ(code, 0) << diagnostic; +} +} // namespace +#endif + +TEST(RemoteWaitBoundaryControl, AppliedClassWaitsWithRunAheadCap) { +#if MGTEST_HAVE_FORK + ASSERT_EQ(MGPipeWaitClassFor(MGPWireOp::GenerateMipmap), kWaitApplied); + CheckE1Boundary(true, MGPWireOp::GenerateMipmap, true, + "E1 wait boundary: GenerateMipmap returned before apply"); +#else + GTEST_SKIP() << "E1 requires POSIX child isolation"; +#endif +} + +TEST(RemoteWaitBoundaryControl, MissingServerCapKeepsClearLockstep) { +#if MGTEST_HAVE_FORK + ASSERT_EQ(MGPipeWaitClassFor(MGPWireOp::Clear), kWaitNone); + CheckE1Boundary(false, MGPWireOp::Clear, true, + "E1 wait boundary: Clear without server cap returned before apply"); +#else + GTEST_SKIP() << "E1 requires POSIX child isolation"; +#endif +} + +TEST(RemoteWaitBoundaryControl, ServerCapAllowsClearToRunAhead) { +#if MGTEST_HAVE_FORK + ASSERT_EQ(MGPipeWaitClassFor(MGPWireOp::Clear), kWaitNone); + CheckE1Boundary(true, MGPWireOp::Clear, false, + "E1 positive boundary: Clear with server cap waited for apply"); +#else + GTEST_SKIP() << "E1 requires POSIX child isolation"; +#endif +} diff --git a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp index a938a23e..ad26faf5 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp +++ b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp @@ -2710,6 +2710,8 @@ TEST(RemoteRunAhead, OnlyTheTextureHalfOfResourceSubDataWantsItsReply) { } +#include "RemoteClientE1Controls.inc" + int main(int argc, char** argv) { namespace fs = std::filesystem; const fs::path path = diff --git a/scripts/ci/control_smoke_test.sh b/scripts/ci/control_smoke_test.sh index 324ae64d..8ef470c8 100644 --- a/scripts/ci/control_smoke_test.sh +++ b/scripts/ci/control_smoke_test.sh @@ -87,6 +87,12 @@ expect FAILED "the knob leaves the selection green" -- run_split green expect FAILED "the baseline is already red" -- run_split red-baseline # P5 is complete: losing the runtime implementation must no longer disarm the gate. expect FAILED "every split entry skipped (implementation lost)" -- run_split all-skipped +expect FAILED "E1 missing boundary probes" -- run_split e1-empty +expect FAILED "E1 own baseline is already red" -- run_split e1-red-baseline +expect FAILED "E1 restored boundary remains red" -- run_split e1-restore-red +expect FAILED "E1 record never reaches the peer" -- run_split e1-no-peer +expect FAILED "E1 observed no real emission" -- run_split e1-no-emit +expect FAILED "E1 incorrectly waits every record" -- run_split e1-waitall echo echo "=== the retrace lane's pull-library control (scripts/ci/retrace_pull_library_control.sh)" diff --git a/scripts/ci/redcheck_control_smoke_test.sh b/scripts/ci/redcheck_control_smoke_test.sh index 61e71489..8114e936 100755 --- a/scripts/ci/redcheck_control_smoke_test.sh +++ b/scripts/ci/redcheck_control_smoke_test.sh @@ -13,12 +13,14 @@ set -u HERE="$(cd "$(dirname "$0")" && pwd)" WORK="$(mktemp -d)" || exit 1 HELPER="${HERE}/../../MobileGL/MG_IntegrationTest/Harness/split_log_paths.py" -trap 'cp "${WORK}/split.orig" "${HERE}/split_negative_controls.sh"; cp "${WORK}/retrace.orig" "${HERE}/retrace_pull_library_control.sh"; cp "${WORK}/dropdraw.orig" "${HERE}/retrace_drop_draw_control.sh"; cp "${WORK}/helper.orig" "${HELPER}"; rm -rf "${WORK}"' EXIT +WAIT_HELPER="${HERE}/wait_boundary_negative_control.py" +trap 'cp "${WORK}/split.orig" "${HERE}/split_negative_controls.sh"; cp "${WORK}/retrace.orig" "${HERE}/retrace_pull_library_control.sh"; cp "${WORK}/dropdraw.orig" "${HERE}/retrace_drop_draw_control.sh"; cp "${WORK}/helper.orig" "${HELPER}"; cp "${WORK}/wait.orig" "${WAIT_HELPER}"; rm -rf "${WORK}"' EXIT cp "${HERE}/split_negative_controls.sh" "${WORK}/split.orig" cp "${HERE}/retrace_pull_library_control.sh" "${WORK}/retrace.orig" cp "${HERE}/retrace_drop_draw_control.sh" "${WORK}/dropdraw.orig" cp "${HELPER}" "${WORK}/helper.orig" +cp "${WAIT_HELPER}" "${WORK}/wait.orig" echo "=== baseline: the smoke test must be GREEN before anything is perturbed" if ! bash "${HERE}/control_smoke_test.sh" > "${WORK}/before.log" 2>&1; then @@ -31,9 +33,9 @@ tail -1 "${WORK}/before.log" echo echo "=== perturbation: remove the evidence check from both controls" python3 - "${HERE}/split_negative_controls.sh" "${HERE}/retrace_pull_library_control.sh" \ - "${HERE}/retrace_drop_draw_control.sh" <<'PY' || exit 1 + "${HERE}/retrace_drop_draw_control.sh" "${WAIT_HELPER}" <<'PY' || exit 1 import sys -split, retrace, dropdraw = sys.argv[1], sys.argv[2], sys.argv[3] +split, retrace, dropdraw, wait = sys.argv[1:] # (file, [(needle, the prefix the line must start with)]). The draw-drop control has THREE # evidence checks rather than one, because "the picture went red" has three different ways of # being somebody else's red: the SSIM never fell, the library never said the knob armed, and the @@ -49,16 +51,17 @@ split, retrace, dropdraw = sys.argv[1], sys.argv[2], sys.argv[3] # nothing was ever silently proved - but the one control it was most about was never perturbed. # Reproduced on p5/joint@e61d0012 before this line changed. # -# AND THE SPLIT CONTROL HAS THREE EVIDENCE CHECKS, NOT ONE, so all three are reverted. E1's is a -# read of the entry's own private library log, E3(a)'s ctest-output regex is the `elif` above, -# and E3(a)'s private-log half is ID-65's addition. Perturbing only one of the three leaves the +# AND THE SPLIT CONTROL HAS THREE EVIDENCE CHECKS, NOT ONE. E1 checks each boundary +# probe's own observation and assertion; E3(a) checks its assertion and private log. +# All three are reverted: perturbing only one leaves the # other two catching the smoke test's "unrelated failure" case, and the case never flips - which # is what this script measured the first time the perturbation actually applied. rules = [ (split, [('LINE', '"${log_helper}" assertion', ('if ! ', 'elif ! ')), - ('SUBST', '"[A-Za-z_][A-Za-z_0-9]*"\\}\' || exit 1', '"[A-Za-z_][A-Za-z_0-9]*"\\}\' || true'), ('SUBST', '"${private_evidence}" "${name}" || exit 1', '"${private_evidence}" "${name}" || true')]), + (wait, [('SUBST', 'if not own_negative(text, observation, diagnostic):', + 'if False: # E1 evidence check removed by red-check')]), (retrace, [('LINE', 'grep -qF "${EVIDENCE}"', ('if ! ',))]), (dropdraw, [('LINE', 'awk -v a="${ssim}"', ('if ! ',)), ('LINE', '[ -z "${armed_line}" ]', ('if ',)), @@ -134,8 +137,9 @@ echo cp "${WORK}/split.orig" "${HERE}/split_negative_controls.sh" cp "${WORK}/retrace.orig" "${HERE}/retrace_pull_library_control.sh" cp "${WORK}/dropdraw.orig" "${HERE}/retrace_drop_draw_control.sh" +cp "${WORK}/wait.orig" "${WAIT_HELPER}" echo "=== ID-62 perturbation: remove only the skip check" -python3 - "${HELPER}" <<'PY' || exit 1 +python3 - "${HELPER}" "${WAIT_HELPER}" <<'PY' || exit 1 from pathlib import Path import sys path = Path(sys.argv[1]) @@ -144,6 +148,12 @@ needle = ' if skipped:\n' if text.count(needle) != 1: raise SystemExit('expected exactly one skip check') path.write_text(text.replace(needle, ' if False: # skip check removed by red-check\n')) +path = Path(sys.argv[2]) +text = path.read_text() +needle = ' if case.find("skipped") is not None or case.get("status") in {"notrun", "disabled"}:\n' +if text.count(needle) != 1: + raise SystemExit('expected exactly one E1 skip check') +path.write_text(text.replace(needle, ' if False: # E1 skip check removed by red-check\n')) PY bash "${HERE}/control_smoke_test.sh" > "${WORK}/skips.log" 2>&1 rc=$? diff --git a/scripts/ci/split_negative_controls.sh b/scripts/ci/split_negative_controls.sh index dd89e0f3..0b1f70a4 100644 --- a/scripts/ci/split_negative_controls.sh +++ b/scripts/ci/split_negative_controls.sh @@ -33,8 +33,10 @@ # CONTROL", "is the E3(a) NEGATIVE CONTROL"), and it is tempting to grep for that. It is not # evidence: it is written at config load, by every process in the run, whatever happens next. A # setup abort would carry it too. It proves the knob was READ, never that the knob caused the red. -# Only the failing case's own diagnostic does that. ID-53 gives each Split entry a private -# library log: E1 reads its Fatal there; E3(a) reads the scenario assertion in ctest output. +# Only the failing case's own diagnostic does that. E1 now observes deterministic transport +# waits in dedicated CPU tests; E3(a) retains its scenario assertion and private library log. +# P5e ID-122: ordinary run-ahead draws need not fail with VERB_BARRIER=0, and the old +# overlap Fatal cannot fire with the default BATCH_WAITS=1. Pixel failures are not E1 evidence. # # Usage: split_negative_controls.sh [--self-test] # CTEST ctest binary (default: ctest) @@ -139,20 +141,14 @@ run_control() { cat "${out}" # Inspect JUnit before exit status or private Fatal: a skipped pre-flight can carry both. - label='E3(a)' - [ "${evidence}" != private-barrier-fatal ] || label=E1 - python3 "${log_helper}" results "${manifest}" "${filter}" "${result}" "${label}" || exit 1 + python3 "${log_helper}" results "${manifest}" "${filter}" "${result}" 'E3(a)' || exit 1 if [ "${control_rc}" -eq 0 ]; then echo "::error::${name} left ${matched} split entries GREEN, so the knob it turns is not load-bearing and the gate it controls proves nothing." exit 1 fi - # E1's MGLOG_F sink is the private file, never ctest's status or transcript. - if [ "${evidence}" = "private-barrier-fatal" ]; then - python3 "${log_helper}" evidence "${manifest}" "${filter}" \ - 'Fatal\{BarrierViolation, "[A-Za-z_][A-Za-z_0-9]*"\}' || exit 1 - elif ! python3 "${log_helper}" assertion "${manifest}" "${filter}" "${result}" "${evidence}"; then + if ! python3 "${log_helper}" assertion "${manifest}" "${filter}" "${result}" "${evidence}"; then echo "::error::${name} FAILED: red lacks its persistent-map push diagnostic. Required: ${evidence}" exit 1 fi @@ -168,26 +164,15 @@ run_control() { echo "${name} turned ${matched} selected entries red, and the red carries the scenario's own diagnostic, as it must" } -# E1: c1 ClientSession::EmitAndWait emits MGLOG_F Fatal{BarrierViolation, ""}. -# This asserts an observed overlap with the applier, which is timing-dependent. -# Without that Fatal in a fresh selected file E1 fails; pixel/status fallbacks do not count. -# Keep the ID-53-approved SmallRing selection as well as the default lane. -# ...MINUS TheServerStampedAVerbBoundaryOnThisDrawingFrame, and that exclusion is load-bearing -# rather than tidying (P5e, ID-122). That case is package gl's ID-115 POSITIVE CONTROL: it reads -# a PipeStats window out of the lane's own private log, so it runs ONLY in the strict-arming -# lane and skips everywhere else BY DESIGN, whatever this knob is set to. E1's selection began -# matching it the moment gl added it, and ID-62 makes a SKIPPED SELECTED entry a hard failure - -# correctly, because a control that silently loses its subjects proves nothing. E1 then reported -# "the knob killed the pre-flight, not the entry" while the knob was in fact working: four other -# selected entries went red with the abort in the same run. So the bug was in WHO E1 selects, -# not in what it asserts, and the fix excludes a case that can never run here rather than -# teaching E1 to tolerate skips - which would have thrown ID-62 away. -run_control "negative control E1 (MOBILEGL_IPC_VERB_BARRIER=0)" \ - 'DirectGLES\.Split\.(SmallRing\.)?(Triangle|ClearThenReadPixels)' \ - 'private-barrier-fatal' \ - '' \ - 'TheServerStampedAVerbBoundaryOnThisDrawingFrame' \ - MOBILEGL_IPC_VERB_BARRIER=0 +# E1: the default arm must actually park for a kWaitApplied record and for a +# kWaitNone record whose server has no run-ahead cap. Removing the verb barrier +# must fail those exact return-before-apply assertions. A third positive case +# proves that a cap-authorized kWaitNone record does not wait. The peer releases +# on an observed Park or on the client's return, not on a sleep or pixel race. +# The helper demands green baseline, two named reds, and green restoration; +# skipped/missing cases, timeouts and unrelated failures never count as evidence. +python3 "$(dirname "$0")/wait_boundary_negative_control.py" \ + --ctest "${CTEST}" --out "${CONTROL_TMPDIR}/e1" || exit 1 # E3(a): PersistentMapTracker::PushBlocksFor stops at blockBytes == 0 - deliberately, because 0 # is the negative control and not "unlimited". Two independent halves are now required: diff --git a/scripts/ci/testdata/split_private_log_smoke.sh b/scripts/ci/testdata/split_private_log_smoke.sh index 86fa8983..ad68ae5f 100644 --- a/scripts/ci/testdata/split_private_log_smoke.sh +++ b/scripts/ci/testdata/split_private_log_smoke.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Exercise the production control, including the private-file read (ID-53 / R-16). +# Exercise E1's per-test boundary evidence and E3(a)'s private-file read (R-16). set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" WORK=$(mktemp -d "${TMPDIR:-/tmp}/split-private-log.XXXXXX") @@ -14,25 +14,21 @@ for mode in missing-fatal stdout-fatal stale-fatal evidence e3-unrelated e3-no-p bash "${HERE}/split_negative_controls.sh" > "${WORK}/${mode}.out" 2>&1 || rc=$? if [ "${mode}" = evidence ]; then [ "${rc}" = 0 ] && grep -q "negative control E3(a).*scenario's own diagnostic" "${WORK}/${mode}.out" || { - cat "${WORK}/${mode}.out"; echo "NOT OK private-file Fatal must PASS"; exit 1; + cat "${WORK}/${mode}.out"; echo "NOT OK own boundary/private-file evidence must PASS"; exit 1; } - grep -qFx "private-log evidence: DirectGLES.Split.ClearThenReadPixelsScenario.ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels: ${WORK}/${mode}/entry.log" "${WORK}/${mode}.out" || { + grep -qFx "private-log evidence: DirectGLES.Split.PersistentCoherentMapScenario.TwoWritesThroughTheCoherentPointerEachReachTheirOwnDraw: ${WORK}/${mode}/pmap.log" "${WORK}/${mode}.out" || { cat "${WORK}/${mode}.out"; echo "NOT OK private-file evidence line must name the selected entry and path"; exit 1; } else - message='E1 FAILED: selected private logs lack expected Fatal' + message='red lacks its own observed wait-boundary failure' [ "${mode}" != e3-unrelated ] || message='FAILED: red lacks its persistent-map push diagnostic' case "${mode}" in e3-no-private) message='no selected private log carries /MGPipe: persistent-map push disabled' ;; - skipped-selection) message='SplitLogPaths FAILED: E1 control: the knob killed the pre-flight, not the entry - 1 selected entries skipped' ;; + skipped-selection|notrun-selection) message='selected testcase skipped or did not run' ;; e3-skipped-selection) message='SplitLogPaths FAILED: E3(a) control: the knob killed the pre-flight, not the entry - 1 selected entries skipped' ;; - notrun-selection|missing-selection) message='SplitLogPaths FAILED: E1 control: 1 selected entries did not run' ;; + missing-selection) message='selected testcase names are missing, duplicated or unexpected' ;; esac - if [[ "${mode}" = *-selection ]]; then - match=(-qFx) - else - match=(-qF) - fi + match=(-qF) [ "${rc}" != 0 ] && grep "${match[@]}" "${message}" "${WORK}/${mode}.out" || { cat "${WORK}/${mode}.out"; echo "NOT OK ${mode}: control must report FAILED for its own reason"; exit 1; } diff --git a/scripts/ci/testdata/stub_ctest.sh b/scripts/ci/testdata/stub_ctest.sh index d98efdc6..eb6055e7 100755 --- a/scripts/ci/testdata/stub_ctest.sh +++ b/scripts/ci/testdata/stub_ctest.sh @@ -52,10 +52,80 @@ for a in "$@"; do [ "$a" = "--show-only=json-v1" ] && json_requested=1 [ "$a" = "-N" ] && listing_requested=1 if [ "$prev" = "--output-junit" ]; then junit="$a"; fi + if [ "$prev" = "-R" ]; then selector="$a"; fi prev="$a" done listing_requested="${listing_requested:-0}" +# E1's CPU-only boundary probes use their own testcase results. Keep observations +# inside each testcase so stdout-only and stale evidence remain rejected. +if [[ "${selector:-}" == *RemoteWaitBoundaryControl* ]]; then + python3 - "${mode}" "${junit}" "${selector}" <<'PY' +import os, re, sys +import xml.etree.ElementTree as ET +mode, path, selector = sys.argv[1:] +barrier = int(os.environ['MOBILEGL_IPC_VERB_BARRIER']) +negative = barrier == 0 +prefix = 'RemoteWaitBoundaryControl.' +specs = [ + ('AppliedClassWaitsWithRunAheadCap', 'GenerateMipmap', 1, True, + 'E1 wait boundary: GenerateMipmap returned before apply'), + ('MissingServerCapKeepsClearLockstep', 'Clear', 0, True, + 'E1 wait boundary: Clear without server cap returned before apply'), + ('ServerCapAllowsClearToRunAhead', 'Clear', 1, False, ''), +] +root = ET.Element('testsuite') +failures = 0 +for index, (short, op, cap, waits, diagnostic) in enumerate(specs): + name = prefix + short + if not re.search(selector, name): + continue + if mode == 'e1-empty' or (negative and mode == 'missing-selection' and index == 1): + continue + case = ET.SubElement(root, 'testcase', name=name, status='run') + waited = int(waits and barrier == 1) + observation = (f'E1 observation: op={op} cap={cap} barrier={barrier} parked={waited} ' + f'applied_before_return={waited} emitted=1 peer_seen=1') + failed = negative and mode != 'green' + if mode == 'e1-red-baseline' and 'baseline' in path: + failed = True + if mode == 'e1-restore-red' and 'restored' in path: + failed = True + if mode == 'e1-waitall' and not waits: + failed = True + observation = observation.replace('parked=0', 'parked=1').replace('applied_before_return=0', 'applied_before_return=1') + if negative and mode == 'skipped-selection': + ET.SubElement(case, 'skipped') + failed = False + if negative and mode == 'notrun-selection': + case.set('status', 'notrun') + failed = False + if failed: + ET.SubElement(case, 'failure', message='control red') + failures += 1 + text = observation + '\n' + (diagnostic if negative else '') + if negative: + if mode == 'unrelated' or mode == 'wrong-fatal': + text = observation + '\nUNRELATED_CONTROL_FAILURE' + elif mode == 'missing-fatal' or (mode == 'partial-fatal' and index == 1): + text = diagnostic + elif mode == 'stdout-fatal': + print(text) # Must not qualify as this testcase's own evidence. + text = 'UNRELATED_CONTROL_FAILURE' + elif mode == 'stale-fatal': + text = text.replace('barrier=0', 'barrier=1') + elif mode == 'e1-no-peer': + text = text.replace('peer_seen=1', 'peer_seen=0') + elif mode == 'e1-no-emit': + text = text.replace('emitted=1', 'emitted=0') + ET.SubElement(case, 'system-out').text = text +ET.ElementTree(root).write(path, encoding='utf-8', xml_declaration=True) +print(f'E1 stub: {len(root)} selected, {failures} failed') +sys.exit(8 if failures else 0) +PY + exit $? +fi + emit_listing() { echo "Test project /stub" if [ "${mode}" = "retrace-noselect" ] || [ "${mode}" = "dropdraw-noselect" ]; then diff --git a/scripts/ci/wait_boundary_negative_control.py b/scripts/ci/wait_boundary_negative_control.py new file mode 100644 index 00000000..0b4e4fd4 --- /dev/null +++ b/scripts/ci/wait_boundary_negative_control.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""E1: green/red/restored-green proof of real ClientSession wait boundaries. + +The peer holds apply until the actual transport parks or the emit call returns. +VERB_BARRIER=0 must fail only the two owed waits; ordinary run-ahead draws need +not fail. Each arm is checked from its own CTest JUnit testcase, never a global +grep, stale library log, arbitrary nonzero exit, or timing-dependent pixel. +""" +import argparse +import os +from pathlib import Path +import re +import subprocess +import sys +import xml.etree.ElementTree as ET + +PREFIX = "RemoteWaitBoundaryControl." +CASES = { + "AppliedClassWaitsWithRunAheadCap": ("GenerateMipmap", 1, True, + "E1 wait boundary: GenerateMipmap returned before apply"), + "MissingServerCapKeepsClearLockstep": ("Clear", 0, True, + "E1 wait boundary: Clear without server cap returned before apply"), + "ServerCapAllowsClearToRunAhead": ("Clear", 1, False, ""), +} + + +def own_negative(text, expected_observation, diagnostic): + return expected_observation in text and diagnostic in text + + +def inspect(path, selected, barrier, negative, rc): + root = ET.parse(path).getroot() + cases = root.findall(".//testcase") + names = [case.get("name") for case in cases] + if len(names) != len(set(names)) or set(names) != set(selected): + raise ValueError("selected testcase names are missing, duplicated or unexpected") + for case in cases: + name = case.get("name") + if case.find("skipped") is not None or case.get("status") in {"notrun", "disabled"}: + raise ValueError(f"{name}: selected testcase skipped or did not run") + failed = case.find("failure") is not None or case.find("error") is not None + op, cap, waits, diagnostic = CASES[name.removeprefix(PREFIX)] + waited = int(waits and barrier == 1) + observation = (f"E1 observation: op={op} cap={cap} barrier={barrier} parked={waited} " + f"applied_before_return={waited} emitted=1 peer_seen=1") + text = "\n".join(case.itertext()) + if negative: + if not failed or rc == 0: + raise ValueError(f"{name}: disabled barrier did not fail the owed wait") + if not own_negative(text, observation, diagnostic): + raise ValueError(f"{name}: red lacks its own observed wait-boundary failure") + elif failed or rc != 0 or observation not in text: + raise ValueError(f"{name}: baseline/restored arm did not execute its green boundary proof") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ctest", default=os.environ.get("CTEST", "ctest")) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + all_cases = [PREFIX + name for name in CASES] + negative_cases = [PREFIX + name for name, (_, _, waits, _) in CASES.items() if waits] + ok = True + for arm, barrier, negative, selected in ( + ("baseline", 1, False, all_cases), + ("control", 0, True, negative_cases), + ("restored", 1, False, all_cases), + ): + # Never run a negative arm against a failed baseline. A failed negative + # still proceeds through restoration and must not consume stale XML. + if arm == "control" and not ok: + continue + result = args.out / f"e1-{arm}.xml" + result.unlink(missing_ok=True) + env = os.environ.copy() + env["MOBILEGL_IPC_VERB_BARRIER"] = str(barrier) + selector = "^(" + "|".join(re.escape(name) for name in selected) + ")$" + command = [args.ctest, "-L", "unit", "-R", selector, "--no-tests=error", + "--output-on-failure", "--output-junit", str(result)] + try: + run = subprocess.run(command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, timeout=90) + (args.out / f"e1-{arm}.out").write_text(run.stdout, encoding="utf-8") + print(run.stdout, end="", flush=True) + inspect(result, selected, barrier, negative, run.returncode) + print(f"E1 {arm}: {'EXPECTED_RED' if negative else 'GREEN'} ({len(selected)} cases)", flush=True) + except (OSError, ValueError, ET.ParseError, subprocess.TimeoutExpired) as exc: + print(f"E1 FAILED: {arm}: {exc}", file=sys.stderr, flush=True) + ok = False + if ok: + print("negative control E1: owed waits failed by their own observed boundary assertions; baseline and restoration green") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main())