diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index a78c4887..02d825ef 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -985,7 +985,11 @@ namespace MobileGL::MG_Pipe { const Uint64 before = MG_Remote::Client::ClientWireRecordsEmitted(); MGPipeEmitResourceSubData(buffer, 0, static_cast(buffer.GetSize())); if (MG_Remote::Client::ClientWireRecordsEmitted() == before) { - MGLOG_F("MGPipe: Fatal{InitialBytesNotCarried, \"resource_respecify\"} - the " + // UncarriedInitialBytes, not a second word for the same family: WireTables.cpp:418 + // already dies of exactly this - a respecify that crossed with no initial bytes - + // under that name, and two words for one family is the vocabulary drift a6 + // censused and 5.2's .def exists to bound (P7 wave 0). + MGLOG_F("MGPipe: Fatal{UncarriedInitialBytes, \"resource_respecify\"} - the " "respecify crossed with initialBytes = nullptr (R-13.3) and the " "resource_subdata records that were supposed to follow it emitted " "NOTHING, so %llu bytes of initial content exist on no side of the wire", diff --git a/MobileGL/MG_Remote/FatalFamilies.def b/MobileGL/MG_Remote/FatalFamilies.def index 1646aa1a..7ef2b19f 100644 --- a/MobileGL/MG_Remote/FatalFamilies.def +++ b/MobileGL/MG_Remote/FatalFamilies.def @@ -33,12 +33,29 @@ // not merely full this instant. // AbiMismatch - the two builds do not agree on layout or protocol version. // ServerCrashed - a liveness end the WAITING side attributes to the other: a drain that -// never completed, an apply thread that is gone or never started. The -// device-lost latch is armed from the doorbell, never from these, but the -// peer that was waiting is told the server is gone. +// never completed, an apply thread that is gone or never started, a native +// resource the server had to have and could not obtain. The device-lost +// latch is armed from the doorbell, never from these, but the peer that +// was waiting is told the server is gone. // DeviceLost - reserved: set by the latch (5.3), never raised as a Fatal family, so no // row projects onto it. // +// P7 WAVE 0 ADDED ELEVEN ROWS, for the eleven words the widened census gate found in the server +// image outside MG_Remote/ (scripts/ci/fatal_census.py rule 3). Ten are ProtocolCorruption for +// the reason that bucket already states - a record, a mask or a reply that cannot be honoured +// correctly. The eleventh, ResourceUnavailable (Managers.cpp's three image-promotion sites and +// VkBufferManager.cpp's eight), is ServerCrashed rather than SegmentMismatch: SegmentMismatch +// means a SHARED SEGMENT was the wrong size, and telling a peer that when a VkBuffer allocation +// or a native readback failed would be a lie about whose geometry was wrong. The honest thing to +// tell a waiting peer is the one ServerCrashed already says. The wire enum has no "the server +// could not obtain a resource" value and adding one would move `wireFingerprint`, which is a +// protocol change no wave-0 instrument is entitled to make (recorded for the integrator). +// +// Two words were NOT given rows, they were collapsed instead: `InitialBytesNotCarried` +// (MG_Impl/Pipe/PipeFill.cpp) said the same thing as `UncarriedInitialBytes` (Client/ +// WireTables.cpp) about the same record - a respecify that crossed with no initial bytes - and +// two words for one family is exactly the drift a6 censused. PipeFill now uses the older word. +// // clang-format off // X(Family, WireFatalCode, Why) @@ -47,16 +64,26 @@ X(UnmigratedVerb, ProtocolCorruption, "a wire verb slot P5/P6 does not implement was reached") \ X(UnmigratedSurface, ProtocolCorruption, "a surface op named a client-process handle that means nothing here (P12)") \ X(UnmigratedPipeInput, ProtocolCorruption, "a PipeInputs field with no field-ownership row was read across the boundary") \ + X(UnmigratedEmulation, ProtocolCorruption, "an emulation path the applier has no migrated arm for was reached") \ X(RoleViolation, ProtocolCorruption, "a call ran on the wrong role/thread - an invariant the two roles must hold") \ X(UnsetCallMask, ProtocolCorruption, "the CallMask was consulted before either half was published") \ + X(ConsumerMaskLie, ProtocolCorruption, "the server published a consumer bit for a family whose op table is absent") \ X(ClientTablesUninstalled, ProtocolCorruption, "the applier ran before the client installed its tables") \ X(NoClientSession, ProtocolCorruption, "a path that needs the client session ran without one") \ + X(UnnamedIdentity, ProtocolCorruption, "a per-context memo was consulted with a null context identity") \ + X(PipeLegacyMemosDisabled, ProtocolCorruption, "a subsystem bit is clear and this build compiles no legacy arm to fall back to") \ + X(PipeVerifyBadKnob, ProtocolCorruption, "a MOBILEGL_PIPE_VERIFY/POISON knob named a verb or field that does not exist") \ + X(PipeVerifyDiffer, ProtocolCorruption, "the verify comparator found a pushed field differing from the live one") \ X(UnexpectedMapAccept, ProtocolCorruption, "a persistent-map accept arrived where the protocol permits none") \ X(UncarriedInitialBytes, ProtocolCorruption, "a producer's initial bytes were required on the wire and absent") \ X(ReplyStatusInvalid, ProtocolCorruption, "a reply carried a status outside the defined set") \ X(ReplyError, ProtocolCorruption, "a reply-owning verb saw an error status it cannot represent") \ X(ReplyMissing, ProtocolCorruption, "a reply slot the protocol requires was empty") \ + X(ReplyMismatched, ProtocolCorruption, "the reply mailbox held an answer for a different slot than the one waited on") \ + X(ReplyOverrun, ProtocolCorruption, "an answer was posted while a previous one was still unread") \ + X(ReplyToNoSlot, ProtocolCorruption, "an answer was posted against reply slot 0, which means \"no record\"") \ X(ReplyTooLarge, ProtocolCorruption, "an answer exceeded the size its verb's contract allows") \ + X(ResidualBlockSize, ProtocolCorruption, "a fixed-size residual block arrived at a size that is not its own") \ X(ReadbackDeclined, ProtocolCorruption, "a readback that cannot return without complete pixels was declined") \ X(ReadbackReplyShort, ProtocolCorruption, "a readback reply carried fewer bytes than its shape requires") \ X(UnsizedReadback, ProtocolCorruption, "a readback arrived with no declared size") \ @@ -77,6 +104,7 @@ X(PresentCreditTimeout, ServerCrashed, "a present credit did not return within the bounded wait") \ X(RetirementWaitFailed, ServerCrashed, "a wait for the server to retire staging did not complete") \ X(ApplyThreadJoinTimeout, ServerCrashed, "the apply thread did not join within its bound at teardown") \ - X(ApplyThreadNotRunning, ServerCrashed, "an operation needed the apply thread and it was not running") + X(ApplyThreadNotRunning, ServerCrashed, "an operation needed the apply thread and it was not running") \ + X(ResourceUnavailable, ServerCrashed, "the server could not obtain a native GL/Vulkan resource it had to have") // clang-format on diff --git a/MobileGL/MG_Remote/Transport/SocketTransport.cpp b/MobileGL/MG_Remote/Transport/SocketTransport.cpp index f1fbabe8..526fadd6 100644 --- a/MobileGL/MG_Remote/Transport/SocketTransport.cpp +++ b/MobileGL/MG_Remote/Transport/SocketTransport.cpp @@ -158,7 +158,11 @@ namespace MobileGL::MG_Remote::Transport { MobileGLResult result = MOBILEGL_ERR_UNSUPPORTED; for (auto* address = addresses; address; address = address->ai_next) { if (!authenticated && !Loopback(address->ai_addr)) { - WireLogError("MG_Remote: Refuse{AuthenticationRequired} non-loopback TCP listen needs MOBILEGL_IPC_TOKEN"); + // The word is RefuseCode's own enumerator. It read `AuthenticationRequired` + // for a whole phase, naming a value protocol.fbs has never had, so a reader + // grepping the refusal vocabulary found a word the wire cannot carry (PH-7 + // (2)); fatal_census.py's rule 4 now refuses that. + WireLogError("MG_Remote: Refuse{Authentication} non-loopback TCP listen needs MOBILEGL_IPC_TOKEN"); result = MOBILEGL_ERR_PROTOCOL_MISMATCH; continue; } diff --git a/MobileGL/MG_Test/Wire/SocketTransportTest.cpp b/MobileGL/MG_Test/Wire/SocketTransportTest.cpp index a626d5c5..95dda313 100644 --- a/MobileGL/MG_Test/Wire/SocketTransportTest.cpp +++ b/MobileGL/MG_Test/Wire/SocketTransportTest.cpp @@ -35,6 +35,7 @@ // `BufferTooSmallKeepsTheMessage` matters more here: the message it must keep // is one this side already reassembled out of bytes nobody else can re-deliver. +#include // RefuseCode, for the word below #include // kWaitForever #include #include @@ -119,6 +120,14 @@ TEST(SocketTransportTest, TcpRejectsMalformedNamesAndUnauthenticatedWildcard) { ::unsetenv("MOBILEGL_IPC_TOKEN"); EXPECT_EQ(SocketTransport::Listen("tcp://0.0.0.0:40613", &listener), MOBILEGL_ERR_PROTOCOL_MISMATCH); EXPECT_EQ(listener, -1); + // PH-7 (2). The refusal that path logs is `Refuse{Authentication}`, and `Authentication` has + // to be RefuseCode's own spelling: for a whole phase it read `Refuse{AuthenticationRequired}`, + // a word the wire enum has never had, so anyone grepping the refusal vocabulary found a name + // no peer could ever receive. fatal_census.py's rule 4 refuses an invented word statically; + // this pins the word that gate checks against to the enumerator the peer actually reads, so + // renaming the enumerator without the log line fails here rather than silently. + EXPECT_STREQ(MobileGL::Wire::EnumNameRefuseCode(MobileGL::Wire::RefuseCode::Authentication), + "Authentication"); if (wasSet) ::setenv("MOBILEGL_IPC_TOKEN", saved.c_str(), 1); } diff --git a/scripts/ci/fatal_census.py b/scripts/ci/fatal_census.py index 8a68bbc9..e0f0e00e 100644 --- a/scripts/ci/fatal_census.py +++ b/scripts/ci/fatal_census.py @@ -1,34 +1,74 @@ #!/usr/bin/env python3 -"""The Fatal census gate (CONTRACT-P6 5.2, exit gate S7). +"""The Fatal census gate (CONTRACT-P6 5.2, exit gate S7; perimeter widened by P7 wave 0). -WHAT IT REFUSES: a `std::abort()` under MG_Remote/ that carries no family word, and a -SessionFail / WireLogFatal call whose string forgot its family. +WHAT IT REFUSES: a `std::abort()` inside the SPLIT SERVER IMAGE that carries no family word, a +SessionFail / WireLogFatal call whose string forgot its family, a family word with no row in +FatalFamilies.def, a `Refuse{Word}` that is not a value of the wire RefuseCode, and one more +abort site than the baseline records. -S7: "a bare abort() added outside Session::Fail -> the census gate goes red". Session::Fail now -EXISTS - it is SessionFail() in FatalFunnel.cpp, and the ~90 scattered aborts route through it, -so a bare std::abort() under MG_Remote/ is now the exception this gate refuses rather than the -rule. Four aborts remain, all inside the two funnels (WireLog.cpp, FatalFunnel.cpp), and both -files are exempt because their callers carry the family word. The property this enforces is the -one every downstream consumer depends on: every death names a FAMILY, in a `Fatal{Word...}` -marker, on a line a grep can find. +S7: "a bare abort() added outside Session::Fail -> the census gate goes red". Session::Fail +EXISTS - it is SessionFail() in FatalFunnel.cpp - so a bare std::abort() is the exception this +gate refuses rather than the rule. The property it enforces is the one every downstream consumer +depends on: every death names a FAMILY, in a `Fatal{Word...}` marker, on a line a grep can find. -That is not as weak as it sounds. a6's census found the vocabulary had drifted to 30 family -words against a 7-value wire FatalCode, and found two aborts carrying no marker at all - so "the -log stays verbatim" was untrue of them and no census could see them. Everything downstream reads -these markers: run_trace_case.cmake reds a split retrace on any `Fatal{` line, the retrace lane's -refusal census counts them by name, and MEASUREMENTS quotes the distinct words. A death with no -word is invisible to all of it. +That is not as weak as it sounds. a6's census found the vocabulary had drifted to 30 family words +against a 7-value wire FatalCode, and found two aborts carrying no marker at all - so "the log +stays verbatim" was untrue of them and no census could see them. Everything downstream reads these +markers: run_trace_case.cmake reds a split retrace on any `Fatal{` line, the retrace lane's refusal +census counts them by name, and MEASUREMENTS quotes the distinct words. A death with no word is +invisible to all of it. -TWO SANCTIONED SHAPES, both spelled out rather than inferred: +THE PERIMETER IS THE SERVER IMAGE, NOT `MG_Remote/` ALONE (P7 wave 0, plan section 1.1 row +"gate: Session::Fail site census"). CONTRACT-P6 §12.1 settles what the server image is: "P6's +server image is the whole libMobileGL.so", one flat SOURCE_FILES list feeding both roles. Until +this widening the gate scanned `MG_Remote/` and saw FOUR abort sites; the server process was +already executing seventy-nine more - 15 in DirectGLES.cpp, 12 in Managers.cpp, 11 in +VkBufferManager.cpp, 7 in PipeRoute.cpp - and every one of them was outside the gate. SCAN_ROOTS +below is the set of roots the split server actually runs code from; `.inc` joins `.cpp`/`.h` +because the Magma wire arms live in Renderer/Wire*.inc and nothing else compiles them. - 1. the marker is within kMarkerWindow lines above the abort; - 2. the abort is inside the WireLogFatal funnel (Transport/WireLog.*), whose callers each pass - a `Fatal{` format string - which this file also checks, because a funnel that accepted an - unmarked string would launder exactly what rule 1 refuses. +TEST TREES ARE OUT. A death test's deliberate abort is not a production death, and the benchmark +and integration harnesses drive the library rather than being it. -THE BASELINE IS A CEILING ON SILENCE, NOT ON DEATHS. The count of abort sites may grow; the set -of family words may not grow unrecorded. Freezing the total would make every new guard a gate -change, which is how a gate stops being run. +THE 83 SITES OF THE WIDENED PERIMETER, TRIAGED. Every one was read; the triage is the argument +for why the gate can be green with eighty-three deaths in it, and the two tables below are the +only places an unnamed one is tolerated. + + (i) INSIDE A SANCTIONED FUNNEL - 4 sites. The abort is reached only through a function whose + CALLERS carry the family word, so the death is named by the caller. FUNNEL_FILES exempts + the two whole files that are nothing but funnel; FUNNEL_SITES exempts the individual + funnels that live inside a file with ordinary named deaths around them. + (ii) NAMED, AND OWNED BY A Ph / P7 ROW - 20 sites. The Magma wire arms (WireFramebuffer.inc, + WireDraw.inc, UniformManager.cpp, VulkanRenderer.cpp) hold the 15 `@P7` refusals due for + retirement in P7 wave 2; StagedShadow.h / StagedTextureStore.h and the D11 rows are Ph's. + They carry their family word today, which is the whole point of widening the perimeter: + the census can now SEE them, and the ratchet below means retiring one is visible. + (iii) NAMED, BUT NOT SESSION-SCOPED - the remainder. Startup and role self-checks that predate + the split (Init.cpp's ConsumerMaskLie pair, MagmaPipeArms.h's PipeLegacyMemosDisabled, + DirectGLES.cpp's UnnamedIdentity, the PipeVerify knob parser) plus the backend-internal + resource failures in Managers.cpp / VkBufferManager.cpp. They die before or beside a + session rather than inside one, so routing them through SessionFail would publish a + SessionFault to a peer that does not exist yet. They are named, which is what this gate + asks of them; converting them to latches is Ph's fuzz arm 2, not wave 0's. + +THE FOUR RULES, spelled out rather than inferred: + + 1. an abort's `Fatal{Word` marker is within kMarkerWindow lines above it, OR the abort is + exempted by FUNNEL_FILES / FUNNEL_SITES; + 2. every WireLogFatal / SessionFail call carries a `Fatal{` in its format string - a funnel that + accepted an unmarked string would launder exactly what rule 1 refuses; + 3. every family word found in the perimeter has a row in FatalFamilies.def. a6's finding was a + 30-word vocabulary against a 7-value wire enum; the .def is the projection that bounds the + divergence, and a word with no row projects onto nothing at all; + 4. every `Refuse{Word}` names a value of protocol.fbs's RefuseCode enum. This is rule 3 for the + handshake's vocabulary, and it exists because `Refuse{AuthenticationRequired}` was in the + tree for a whole phase naming an enumerator that has never existed (PH-7 (2)). + +THE BASELINE IS A DOWNWARD RATCHET ON ABORT SITES AND AN UPWARD ONE ON WORDS. More abort sites +than the baseline records is a failure that names the file(s) that grew; fewer is the direction +the P7 `@P7` retirements move and only asks to be re-baselined in the same commit. The family set +may grow, but never unrecorded: the wire's FatalCode has seven values while the internal +vocabulary is six times that, and the divergence is the thing 5.2's funnel exists to bound. """ import argparse import json @@ -37,8 +77,28 @@ import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -SCAN_ROOT = ROOT / "MobileGL" / "MG_Remote" + +# CONTRACT-P6 §12.1's server image, root by root. Not "everything under MobileGL/": MG_Impl's GL +# entry points and MG_State's object tables are the CLIENT's halves except for the two subtrees +# named here, which the server's applier and staging paths run directly. +SCAN_ROOTS = ( + ROOT / "MobileGL" / "MG_Remote", + ROOT / "MobileGL" / "MG_Pipe", + ROOT / "MobileGL" / "MG_Backend", + ROOT / "MobileGL" / "MG_Impl" / "Pipe", + ROOT / "MobileGL" / "MG_State", +) +# A death test's deliberate abort is not a production death; the benchmark and integration +# harnesses drive the library rather than being it. +EXCLUDED_PARTS = ("MG_Test", "MG_Benchmark", "MG_IntegrationTest") +# `.inc` is not decoration: the Magma wire arms and the generated wire records are .inc files +# included into a .cpp, and 20 of the 83 sites - every one of the `@P7` refusals among them - +# are reachable only through one. +SOURCE_SUFFIXES = (".cpp", ".h", ".inc") + BASELINE = Path(__file__).with_name("fatal_census_baseline.json") +FAMILIES_DEF = ROOT / "MobileGL" / "MG_Remote" / "FatalFamilies.def" +PROTOCOL_FBS = ROOT / "MobileGL" / "MG_Remote" / "Protocol" / "protocol.fbs" # The funnel's own files. WireLog.cpp's abort is reached only through WireLogFatal, and rule 2 # checks that every CALLER passes a family word - so the death is named by the caller. Both files @@ -54,18 +114,54 @@ FUNNEL_FILES = { "MobileGL/MG_Remote/FatalFunnel.h", } +# THE ALLOW-LIST FOR FUNNELS THAT DO NOT GET A WHOLE FILE TO THEMSELVES (P7 wave 0). An unmarked +# abort here is sanctioned only if one of its file's anchors appears in the same kMarkerWindow the +# marker search uses - so the exemption follows the funnel if it moves and expires if it is +# deleted, and an unrelated unmarked abort added elsewhere in the same file is still refused. One +# line of reason per entry, argued here rather than in a pull request nobody rereads. +FUNNEL_SITES = { + "MobileGL/MG_Pipe/PipeApply.cpp": ( + ("#define MGP_TRIP_WIRE_REPORT", + "the poison/verify trip-wire funnel: its 30-odd callers each pass MGP_TRIP_WIRE_TAG(name), " + "which IS the `Fatal{name}` marker, so the death is named by the caller"), + ), + "MobileGL/MG_Pipe/generated/PipeWire.inc": ( + ("MGPipeWireProtocolFatal", + "the generated bounds gate's one death, reached only through MGP_WIRE_CHECK_BOUNDS - but " + "its line carries NO `Fatal{` marker at all (scripts/gen_pipe.py:982), so the retrace " + "refusal census cannot name it; a generator fix owed to Ph, recorded here meanwhile"), + ), +} + # Twelve lines: long enough for a wrapped MGLOG_F argument list (the longest in the tree runs to # eight continuation lines), short enough that an unrelated marker further up cannot cover an # abort it has nothing to do with. kMarkerWindow = 12 MARKER = re.compile(r"Fatal\{([A-Za-z][A-Za-z0-9]*)") +REFUSAL = re.compile(r"Refuse\{([A-Za-z][A-Za-z0-9]*)") ABORT = re.compile(r"\bstd::abort\(\)") WIRE_LOG_FATAL_CALL = re.compile(r"WireLogFatal\s*\(") # P6 dl: a SessionFail call must carry a Fatal{ word in its string, the same rule # WireLogFatal has - the enum and the string both name the family, and a call whose string # forgot it would let the two disagree. Checked everywhere SessionFail is called. SESSION_FAIL_CALL = re.compile(r"SessionFail\s*\(") +FAMILY_ROW = re.compile(r"^\s*X\(([A-Za-z][A-Za-z0-9]*)\s*,", re.MULTILINE) +REFUSE_ENUM = re.compile(r"enum\s+RefuseCode\s*:[^{]*\{(.*?)\}", re.DOTALL) +ENUM_VALUE = re.compile(r"^\s*([A-Za-z][A-Za-z0-9]*)\s*=", re.MULTILINE) + +# Two client-side declines that borrow the Refuse vocabulary for a refusal that never becomes a +# wire Refuse frame, so no peer ever reads the word and no RefuseCode can carry it. Listed rather +# than renamed: inventing enumerators for them would move `wireFingerprint`, which is a protocol +# change and not a wave-0 instrument's to make. Recorded for the integrator. +LOCAL_REFUSAL_WORDS = { + "ProtocolMismatch": + "ClientSession.cpp:700/:718 - the CLIENT refusing a control/data transport pair locally, " + "before any Hello is sent; there is no peer to send a Refuse frame to", + "InitialCapsSnapshot": + "ClientSession.cpp:1292/:1302 - the CLIENT refusing a Welcome that carried no initial " + "capabilities; the handshake has already been answered, so the decline is local", +} def strip_comments(text): @@ -119,23 +215,48 @@ def strip_comments(text): def source_files(): - for path in sorted(SCAN_ROOT.rglob("*")): - if path.suffix not in (".cpp", ".h"): - continue - # A death test's deliberate abort is not a production death. - if "MG_Test" in path.parts: - continue - yield path + seen = set() + for root in SCAN_ROOTS: + for path in sorted(root.rglob("*")): + if path.suffix not in SOURCE_SUFFIXES: + continue + if any(part in EXCLUDED_PARTS for part in path.parts): + continue + if path in seen: + continue + seen.add(path) + yield path def relative(path): return path.relative_to(ROOT).as_posix() +def declared_families(): + """The family words FatalFamilies.def has a row for - rule 3's right-hand side.""" + return set(FAMILY_ROW.findall(FAMILIES_DEF.read_text(encoding="utf-8"))) + + +def declared_refusals(): + """The RefuseCode enumerators protocol.fbs defines - rule 4's right-hand side.""" + block = REFUSE_ENUM.search(PROTOCOL_FBS.read_text(encoding="utf-8")) + if block is None: + return set() + return set(ENUM_VALUE.findall(block.group(1))) + + +def sanctioned(rel, window): + """Is this unmarked abort inside a funnel the tables above sanction?""" + if rel in FUNNEL_FILES: + return True + return any(anchor in window for anchor, _reason in FUNNEL_SITES.get(rel, ())) + + def census(): unmarked = [] families = set() - aborts = 0 + refusals = {} + sites = [] unnamed_funnel_calls = [] for path in source_files(): @@ -146,6 +267,9 @@ def census(): for word in MARKER.findall(text): families.add(word) + for index, line in enumerate(lines): + for word in REFUSAL.findall(line): + refusals.setdefault(word, []).append(f"{rel}:{index + 1}") if rel not in FUNNEL_FILES: # Rule 2: every WireLogFatal / SessionFail call carries a family word in its string. @@ -161,21 +285,68 @@ def census(): for index, line in enumerate(lines): if not ABORT.search(line): continue - aborts += 1 - if rel in FUNNEL_FILES: - continue # rule 2 covers it + sites.append({"file": rel, "line": index + 1}) window = "\n".join(lines[max(0, index - kMarkerWindow):index + 1]) + if sanctioned(rel, window): + continue # rule 2, or an argued funnel entry, covers it if not MARKER.search(window): unmarked.append({"file": rel, "line": index + 1, "text": line.strip()}) + by_file = {} + for site in sites: + by_file[site["file"]] = by_file.get(site["file"], 0) + 1 + return { - "abort_sites": aborts, + "abort_sites": len(sites), + "abort_sites_by_file": dict(sorted(by_file.items())), + "abort_site_lines": sites, "unmarked_aborts": unmarked, "unnamed_funnel_calls": unnamed_funnel_calls, "families": sorted(families), + "refusal_words": dict(sorted(refusals.items())), } +def ratchet_aborts(result, baseline, failures, notes): + """The DOWNWARD ratchet on abort sites (P7 wave 0). + + More sites than the baseline records is a failure that NAMES the file that grew and prints its + sites, because "83 became 84" is not actionable and "VkBufferManager.cpp went from 11 to 12, + here they are" is. Fewer is the direction `@P7` retirement moves the number, so it asks for a + re-baseline in the same commit rather than blocking one. + """ + recorded = baseline.get("abort_sites") + if recorded is None: + return + current = result["abort_sites"] + if current == recorded: + return + if current < recorded: + notes.append( + f"abort sites fell from {recorded} to {current} - re-baseline in this commit with " + f"`python3 scripts/ci/fatal_census.py --write-baseline`, so the ratchet holds at the " + f"new floor instead of leaving {recorded - current} sites of headroom behind.") + return + + before = baseline.get("abort_sites_by_file", {}) + grew = [(name, count) for name, count in result["abort_sites_by_file"].items() + if count > before.get(name, 0)] + detail = [] + for name, count in grew: + where = ", ".join(str(site["line"]) for site in result["abort_site_lines"] + if site["file"] == name) + detail.append(f"{name} {before.get(name, 0)} -> {count} (lines {where})") + if not detail: + # Only reachable from a baseline written before the per-file map existed. + detail.append("(no per-file baseline recorded; run --write-baseline once to gain naming)") + failures.append( + f"abort sites grew from {recorded} to {current}: " + "; ".join(detail) + + ". A new death in the server image goes through SessionFail (FatalFunnel.h), not through " + "its own std::abort(): the funnel is the one place a SessionFault reaches the peer, the " + "one telemetry point, and the only reason this census can be sure a death was named. If " + "the site genuinely cannot reach the funnel, argue it into FUNNEL_SITES with a reason.") + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--write-baseline", action="store_true", @@ -190,19 +361,21 @@ def main(): if args.write_baseline: BASELINE.write_text(json.dumps({"abort_sites": result["abort_sites"], + "abort_sites_by_file": result["abort_sites_by_file"], "families": result["families"]}, indent=2) + "\n", encoding="utf-8") - print(f"fatal census baseline written: {result['abort_sites']} abort sites, " - f"{len(result['families'])} families") + print(f"fatal census baseline written: {result['abort_sites']} abort sites over " + f"{len(result['abort_sites_by_file'])} files, {len(result['families'])} families") return 0 failures = [] + notes = [] for entry in result["unmarked_aborts"]: failures.append( f"{entry['file']}:{entry['line']} aborts with no Fatal{{Family}} marker within " - f"{kMarkerWindow} lines. Every death under MG_Remote/ must name a family: the split " - f"retrace reds on `Fatal{{`, the refusal census counts by name, and a death with no " - f"word is invisible to both.") + f"{kMarkerWindow} lines. Every death in the split server image must name a family: " + f"the split retrace reds on `Fatal{{`, the refusal census counts by name, and a death " + f"with no word is invisible to both.") for entry in result["unnamed_funnel_calls"]: failures.append( f"{entry['file']}:{entry['line']} calls WireLogFatal with no Fatal{{Family}} in its " @@ -210,6 +383,27 @@ def main(): f"its callers carry the word; a call that does not would launder exactly what the " f"rule refuses.") + # Rule 3: the vocabulary is the .def's, and only the .def's. + rowless = sorted(set(result["families"]) - declared_families()) + if rowless: + failures.append( + "family word(s) with no row in MG_Remote/FatalFamilies.def: " + ", ".join(rowless) + + ". A row is the family's projection onto the seven-value wire FatalCode (5.2's D2 " + "ruling); a word with no row tells the peer nothing, and FatalCodeForFamily cannot " + "compile a call that names it.") + + # Rule 4: the handshake's vocabulary is protocol.fbs's RefuseCode, and only that. + enumerators = declared_refusals() + for word, where in result["refusal_words"].items(): + if word in enumerators or word in LOCAL_REFUSAL_WORDS: + continue + failures.append( + f"Refuse{{{word}}} at {', '.join(where)} names no RefuseCode enumerator " + f"(protocol.fbs has {', '.join(sorted(enumerators))}). A refusal the peer reads must " + f"spell the enum's own word, or the log and the frame disagree about why the session " + f"was declined; a refusal the peer never reads goes in LOCAL_REFUSAL_WORDS with its " + f"reason.") + if BASELINE.is_file(): baseline = json.loads(BASELINE.read_text(encoding="utf-8")) # A NEW FAMILY WORD IS A DELIBERATE ACT and has to be recorded, because the wire's @@ -222,10 +416,15 @@ def main(): ". Add them with `python3 scripts/ci/fatal_census.py --write-baseline` in the " "same commit that introduces them - the point is that the vocabulary grows on " "purpose rather than by accident (CONTRACT-P6 5.2's D2 ruling).") + ratchet_aborts(result, baseline, failures, notes) - print(f"fatal census: {result['abort_sites']} abort sites under MG_Remote/, " + print(f"fatal census: {result['abort_sites']} abort sites over " + f"{len(result['abort_sites_by_file'])} files of the split server image, " f"{len(result['families'])} distinct family words, " + f"{len(result['refusal_words'])} refusal words, " f"{len(result['unmarked_aborts'])} unmarked") + for message in notes: + print(f"::notice::{message}") if failures: for message in failures: print(f"::error::{message}", file=sys.stderr) diff --git a/scripts/ci/fatal_census_baseline.json b/scripts/ci/fatal_census_baseline.json index b8eee28a..2430a897 100644 --- a/scripts/ci/fatal_census_baseline.json +++ b/scripts/ci/fatal_census_baseline.json @@ -1,7 +1,33 @@ { - "abort_sites": 4, + "abort_sites": 83, + "abort_sites_by_file": { + "MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp": 15, + "MobileGL/MG_Backend/DirectGLES/Managers.cpp": 12, + "MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp": 2, + "MobileGL/MG_Backend/DirectGLES/Utils.cpp": 1, + "MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp": 2, + "MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h": 1, + "MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp": 1, + "MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp": 11, + "MobileGL/MG_Backend/DirectVulkan/Renderer/WireDraw.inc": 1, + "MobileGL/MG_Backend/DirectVulkan/Renderer/WireFramebuffer.inc": 1, + "MobileGL/MG_Backend/Init.cpp": 2, + "MobileGL/MG_Backend/MGPipe/PipeInputs.cpp": 3, + "MobileGL/MG_Impl/Pipe/PipeFill.cpp": 5, + "MobileGL/MG_Impl/Pipe/ResourceTracker.h": 5, + "MobileGL/MG_Impl/Pipe/SlotAllocator.cpp": 2, + "MobileGL/MG_Pipe/PipeApply.cpp": 3, + "MobileGL/MG_Pipe/PipeRoute.cpp": 7, + "MobileGL/MG_Pipe/generated/PipeFilled.inc": 1, + "MobileGL/MG_Pipe/generated/PipeWire.inc": 1, + "MobileGL/MG_Remote/FatalFunnel.cpp": 1, + "MobileGL/MG_Remote/Server/StagedShadow.h": 1, + "MobileGL/MG_Remote/Server/StagedTextureStore.h": 1, + "MobileGL/MG_Remote/Transport/WireLog.cpp": 1, + "MobileGL/MG_State/GLState/BufferState/BufferObject.cpp": 2, + "MobileGL/MG_State/GLState/TextureState/MipmapStorage.cpp": 1 + }, "families": [ - "AbiMismatch", "ApplyThreadJoinTimeout", "ApplyThreadNotRunning", "ArchiveEmpty", @@ -10,16 +36,25 @@ "BarrierViolation", "BlobMissing", "ClientTablesUninstalled", + "ConsumerMaskLie", "EventRingOverflow", "NoClientSession", + "PipeLegacyMemosDisabled", + "PipeVerifyBadKnob", + "PipeVerifyDiffer", "PresentCreditTimeout", "ProtocolCorruption", "ReadbackDeclined", "ReadbackReplyShort", "ReplyError", + "ReplyMismatched", "ReplyMissing", + "ReplyOverrun", "ReplyStatusInvalid", + "ReplyToNoSlot", "ReplyTooLarge", + "ResidualBlockSize", + "ResourceUnavailable", "RetirementWaitFailed", "RingOverrun", "RoleViolation", @@ -29,8 +64,11 @@ "UnimplementedAdoptTier", "UnimplementedClientSession", "UnimplementedWritebackWait", + "UnmigratedEmulation", + "UnmigratedPipeInput", "UnmigratedSurface", "UnmigratedVerb", + "UnnamedIdentity", "UnsetCallMask", "UnsizedReadback" ]