diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c02edc1..198eb889 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1392,6 +1392,10 @@ jobs: echo "dual-block census run: rc=${rc}" rows=$(awk '!/^[[:space:]]*(#|$)/ { n++ } END { print n+0 }' "${expected}") + if [ "${rows}" -eq 0 ] && [ "${rc}" -ne 0 ]; then + echo "::error::empty dual-block census requires ctest success, including infrastructure/execution status" + exit "${rc}" + fi if [ "${rc}" -eq 0 ] && [ "${rows}" -gt 0 ]; then echo "::error::the dual-block census lane went GREEN with ${rows} expected pair(s) outstanding - the knob stopped arming, or the fields retired and dualblock-expected-fatals.txt was not emptied in the same commit" exit 1 @@ -1399,6 +1403,21 @@ jobs: python3 "${helper}" expect-fatal "${RUNNER_TEMP}/dualblock-lane.json" \ "${RUNNER_TEMP}/dualblock.xml" "${expected}" + - name: P5f per-frame zero residual reads must execute on both backends + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_IPC_ROLE_SPLIT_STATE: "1" + MOBILEGL_IPC_STRICT_ERRORS: "1" + run: | + ctest -L integration-p5f-rsp --show-only=json-v1 > "${RUNNER_TEMP}/rsp-lane.json" + ctest -L integration-p5f-rsp --no-tests=error --output-on-failure \ + --output-junit "${RUNNER_TEMP}/rsp.xml" + python3 "${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py" \ + require-green "${RUNNER_TEMP}/rsp-lane.json" "${RUNNER_TEMP}/rsp.xml" \ + DirectGLES.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls \ + DirectVulkan.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls + # The mechanism's own negative control (P5F §6): the same named case must be green with # the knob on and red - on its own assertion text - with it off. - name: Dual-block negative control (P5f f1, P5F section 6) diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 97873c0b..16d2c017 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -2601,3 +2601,9 @@ if (MOBILEGL_BUILD_DISAGGREGATED) ENVIRONMENT "${p5f_rsp_environment}") endforeach() endif() + +if (MOBILEGL_BUILD_DISAGGREGATED) + add_test(NAME SplitLogPaths.ResultAccountingControls + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/Harness/test_split_log_paths.py) + set_tests_properties(SplitLogPaths.ResultAccountingControls PROPERTIES LABELS "unit") +endif() diff --git a/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py index 988bfbea..8969140c 100644 --- a/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py +++ b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py @@ -16,7 +16,7 @@ def paths(document): props = {p["name"]: p["value"] for p in test.get("properties", [])} values = [v.split("=", 1)[1] for v in props.get("ENVIRONMENT", []) if v.startswith("MOBILEGL_LOG_FILE_PATH=")] - is_split = test["name"].startswith("DirectGLES.Split.") + is_split = test["name"].startswith(("DirectGLES.Split.", "DirectVulkan.Split.")) if is_split and (len(values) != 1 or not values[0]): raise ValueError(f"{test['name']}: requires exactly one nonempty MOBILEGL_LOG_FILE_PATH") for value in values: @@ -113,6 +113,55 @@ def print_marker_table(title, table): print(f" {len(table[pair]):4d} {pair}") +# These six skips predate P5f closure. Match both the exact entry and its reason; +# losing a GPU/preflight, a required lane marker, or either RSP probe is never allowed. +DUALBLOCK_ALLOWED_SKIPS = { + "DirectGLES.Split.TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame": "not the strict-arming lane:", + "DirectGLES.Split.SmallRing.TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame": "not the strict-arming lane:", + "DirectGLES.Split.PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares": "not the counting lane: MGITEST_PMAP_LANE", + "DirectGLES.Split.SmallRing.PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares": "not the counting lane: MGITEST_PMAP_LANE", + "DirectVulkan.Split.Fm.ClipDistanceScenario.ADisabledClipDistanceRemovesNothing": "clips by a DISABLED gl_ClipDistance", + "DirectVulkan.Split.Fm.ClipDistanceScenario.TheEnablesAreIndependentPerDistance": "clips by every declared gl_ClipDistance regardless of the enables", +} + + +def complete_results(document, junit_path, allowed_skips=None): + expected = [test["name"] for test in document.get("tests", [])] + if not expected or len(expected) != len(set(expected)): + raise ValueError("result accounting: discovery is empty or has duplicate names") + cases = ET.parse(junit_path).getroot().findall(".//testcase") + by_name = {} + for case in cases: + name = case.get("name") + if name in by_name: + raise ValueError(f"result accounting: duplicate JUnit entry {name}") + by_name[name] = case + missing, extra = set(expected) - set(by_name), set(by_name) - set(expected) + if missing or extra: + raise ValueError(f"result accounting: missing {sorted(missing)}, unexpected {sorted(extra)}") + allowed_skips = allowed_skips or {} + for name, case in by_name.items(): + if case.find("skipped") is not None: + reason = allowed_skips.get(name) + text = " ".join(" ".join(case.itertext()).split()) + if reason is None or reason not in text: + raise ValueError(f"result accounting: unexpected skip/reason for {name}") + elif case.get("status") in ("notrun", "disabled"): + raise ValueError(f"result accounting: {name} did not execute ({case.get('status')})") + return by_name + + +def require_green(document, junit_path, required): + names = {test["name"] for test in document.get("tests", [])} + if not required or names != set(required): + raise ValueError(f"required green entries differ: expected {sorted(required)}, discovered {sorted(names)}") + by_name = complete_results(document, junit_path) + for name, case in by_name.items(): + if case.find("failure") is not None or case.find("error") is not None or case.get("status") == "fail": + raise ValueError(f"required green entry failed: {name}") + print(f"SplitLogPaths require-green: {len(by_name)} PASS, 0 skip, 0 failed, complete discovery") + + def expect_fatal(document, junit_path, expected_path): """THE DUAL-BLOCK LANE'S CENSUS AND TWO-SIDED RATCHET (P5f f1, P5F §4/§6). @@ -137,41 +186,30 @@ def expect_fatal(document, junit_path, expected_path): empty expected file IS the P5f exit state, which this mode then verifies rather than obstructs.""" logs = marker_log_paths(document) - cases = ET.parse(junit_path).getroot().findall(".//testcase") - by_name = {} - for case in cases: - by_name.setdefault(case.get("name"), []).append(case) - + by_name = complete_results(document, junit_path, DUALBLOCK_ALLOWED_SKIPS) problems = [] fatal, admitted, escalated = {}, {}, {} - scanned = 0 - skipped = 0 - green = 0 - red = 0 - for name, entries in sorted(by_name.items()): - if any(c.find("skipped") is not None for c in entries): + scanned = skipped = green = red = 0 + for name, case in sorted(by_name.items()): + is_skipped = case.find("skipped") is not None + failed = case.find("failure") is not None or case.find("error") is not None or case.get("status") == "fail" + if is_skipped: skipped += 1 - continue - failed = any(c.find("failure") is not None or c.get("status") == "fail" - for c in entries) - if not failed: + elif failed: + red += 1 + else: green += 1 - continue - red += 1 path = logs.get(name) if path is None: - problems.append(f"{name} is RED and declares no MOBILEGL_LOG_FILE_PATH, so the " - "red cannot be shown to be a named Fatal - an unnamed crash is " - "exactly what this lane exists to forbid") - continue + if failed: + problems.append(f"{name} is RED and declares no private log") + continue # metadata/monolith-arm entries have no marker channel if not Path(path).is_file(): - problems.append(f"{name} is RED and its private log {path} does not exist - the " - "process died before it could write one, which is an unnamed crash") + problems.append(f"{name}: declared private log {path} does not exist") continue scanned += 1 - text = Path(path).read_text(errors="replace") entry_fatal = set() - for tag, field, verb, why in MARKER_RE.findall(text): + for tag, field, verb, why in MARKER_RE.findall(Path(path).read_text(errors="replace")): pair = f"{field}@{verb}" if tag == "Fatal": entry_fatal.add(pair) @@ -180,13 +218,13 @@ def expect_fatal(document, junit_path, expected_path): escalated.setdefault(pair, set()).add(name) else: admitted.setdefault(pair, set()).add(name) - if not entry_fatal: - problems.append(f"{name} is RED but its private log carries no " - "Fatal{UnmigratedPipeInput} marker - an unnamed failure, not a " - "census entry") + if failed and not entry_fatal: + problems.append(f"{name} is RED but its private log carries no Fatal{{UnmigratedPipeInput}} marker") + if entry_fatal and not failed: + problems.append(f"{name} did not fail but its private log carries Fatal markers") print(f"SplitLogPaths expect-fatal: {len(by_name)} entrie(s) in the run - " - f"{green} green, {skipped} skipped, {red} red ({scanned} with a private log scanned)") + f"{green} green, {skipped} skipped, {red} red ({scanned} private logs scanned)") print_marker_table("Fatal{UnmigratedPipeInput", fatal) if admitted or escalated: problems.append("%d Admitted marker(s) under the dual-block knob - the unconditional " @@ -294,6 +332,9 @@ def main(): if mode == "expect-fatal": expect_fatal(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4]) return + if mode == "require-green": + require_green(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4:]) + return selected = paths(json.loads(Path(sys.argv[2]).read_text())) selected = {name: path for name, path in selected.items() if re.search(sys.argv[3], name)} # The SAME exclusion run_control hands ctest -E. Without it this helper and ctest disagree diff --git a/MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py b/MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py new file mode 100644 index 00000000..46a086f6 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/test_split_log_paths.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression controls for complete, non-vacuous P5f result accounting.""" +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +import xml.etree.ElementTree as ET + +module_path = Path(os.environ.get("P5F_TEST_HELPER", Path(__file__).with_name("split_log_paths.py"))) +spec = importlib.util.spec_from_file_location("tested_split_logs", module_path) +helper = importlib.util.module_from_spec(spec) +spec.loader.exec_module(helper) +A = "DirectGLES.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls" +B = "DirectVulkan.Split.P5fRsp.F1WireScenario.EachWireFrameHasZeroResidualPulls" + + +class ResultAccountingTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.expected = self.root / "expected.txt" + self.expected.write_text("# empty P5f census\n") + self.xml = self.root / "run.xml" + self.document = self.discovery([A, B]) + self.results([(A, "run", None), (B, "run", None)]) + + def tearDown(self): + self.temp.cleanup() + + def discovery(self, names): + tests = [] + for i, name in enumerate(names): + log = self.root / (str(i) + ".log") + log.write_text("") + tests.append({"name": name, "properties": [{"name": "ENVIRONMENT", "value": [ + "MOBILEGL_LOG_FILE_PATH=" + str(log)]}]}) + return {"tests": tests} + + def results(self, rows): + suite = ET.Element("testsuite") + for name, status, kind in rows: + case = ET.SubElement(suite, "testcase", name=name, status=status) + if kind: + ET.SubElement(case, kind) + ET.ElementTree(suite).write(self.xml) + + def census(self): + with contextlib.redirect_stdout(io.StringIO()): + helper.expect_fatal(self.document, self.xml, self.expected) + + def test_complete_green_census(self): + self.census() + + def test_missing_junit_entry(self): + self.results([(A, "run", None)]) + with self.assertRaisesRegex(ValueError, "missing"): + self.census() + + def test_empty_discovery(self): + self.document = {"tests": []} + self.results([]) + with self.assertRaisesRegex(ValueError, "empty"): + self.census() + + def test_extra_and_duplicate_results(self): + for rows in [[(A, "run", None), (B, "run", None), ("extra", "run", None)], + [(A, "run", None), (B, "run", None), (B, "run", None)]]: + self.results(rows) + with self.assertRaises(ValueError): + self.census() + + def test_notrun_or_disabled_is_not_pass(self): + for status in ["notrun", "disabled"]: + self.results([(A, "run", None), (B, status, None)]) + with self.assertRaisesRegex(ValueError, "did not execute"): + self.census() + + def test_rsp_skip_is_forbidden(self): + self.results([(A, "run", None), (B, "notrun", "skipped")]) + with self.assertRaisesRegex(ValueError, "unexpected skip"): + self.census() + + def test_known_skip_requires_its_exact_reason(self): + name, reason = next(iter(helper.DUALBLOCK_ALLOWED_SKIPS.items())) + self.document = self.discovery([name]) + self.results([(name, "notrun", "skipped")]) + with self.assertRaisesRegex(ValueError, "unexpected skip"): + self.census() + tree = ET.parse(self.xml) + ET.SubElement(tree.getroot().find("testcase"), "system-out").text = reason + tree.write(self.xml) + self.census() + + def test_admitted_or_fatal_on_passed_case_is_not_hidden(self): + for marker in ['Admitted{UnmigratedPipeInput, "GetProgramObject@Clear"}', + 'Fatal{UnmigratedPipeInput, "GetProgramObject@Clear"}']: + (self.root / "0.log").write_text(marker) + with self.assertRaises(ValueError): + self.census() + + def test_required_pair_must_be_exact_and_both_pass(self): + with contextlib.redirect_stdout(io.StringIO()): + helper.require_green(self.document, self.xml, [A, B]) + for row in [(B, "notrun", "skipped"), (B, "fail", "failure")]: + self.results([(A, "run", None), row]) + with self.assertRaises(ValueError): + helper.require_green(self.document, self.xml, [A, B]) + self.document = self.discovery([A]) + self.results([(A, "run", None)]) + with self.assertRaisesRegex(ValueError, "entries differ"): + helper.require_green(self.document, self.xml, [A, B]) + + +if __name__ == "__main__": + unittest.main() diff --git a/MobileGL/MG_Remote/Server/ServerSession.cpp b/MobileGL/MG_Remote/Server/ServerSession.cpp index 27412201..0ce3052e 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.cpp +++ b/MobileGL/MG_Remote/Server/ServerSession.cpp @@ -262,8 +262,21 @@ namespace MobileGL::MG_Remote::Server { std::abort(); } + // Accept owns the single process callback table. Its owner need not be the + // convenience singleton; every reverse event must use the accepted instance's ring. + ServerSession& ReverseCallbackOwner(const char* callback) { + auto* session = ServerSession::Active(); + if (session == nullptr) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"%s.session-missing\"} - " + "a reverse callback has no accepted session owner", callback); + std::abort(); + } + return *session; + } + void ServerOnBufferWriteback(MG_Pipe::MGPipeHandle res, Uint64 offset, MG_Pipe::MGPBlobRef bytes) { + ServerSession& session = ReverseCallbackOwner("OnBufferWriteback"); if (bytes.Seg != MG_Pipe::kMGHostSpanSegNone) { // The backend handed over a segment-tagged blobref. The ONLY legal shape at // this boundary is the monolith one - Offset is the mapped address, valid @@ -275,7 +288,6 @@ namespace MobileGL::MG_Remote::Server { bytes.Seg); std::abort(); } - ServerSession& session = ServerSessionInstance(); const Uint64 payloadBytes = sizeof(Transport::EventBufferWritebackHead) + bytes.Size; void* slot = ReserveEventOrBlock(session, Transport::kEventBufferWriteback, "kEventBufferWriteback", payloadBytes); @@ -294,7 +306,7 @@ namespace MobileGL::MG_Remote::Server { void ServerOnGpuWritten(MG_Pipe::MGPipeHandle res, Uint rangeCount, const MG_Pipe::MGPRange* ranges) { - ServerSession& session = ServerSessionInstance(); + ServerSession& session = ReverseCallbackOwner("OnGpuWritten"); const Uint64 tailBytes = static_cast(rangeCount) * sizeof(Transport::EventRange); const Uint64 payloadBytes = sizeof(Transport::EventGpuWrittenHead) + tailBytes; void* slot = ReserveEventOrBlock(session, Transport::kEventGpuWritten, @@ -313,7 +325,7 @@ namespace MobileGL::MG_Remote::Server { } void ServerOnSurfaceChanged(const MG_Pipe::MGPSurfaceInfo* info) { - ServerSession& session = ServerSessionInstance(); + ServerSession& session = ReverseCallbackOwner("OnSurfaceChanged"); constexpr Uint64 payloadBytes = sizeof(Transport::EventSurfaceChangedHead); void* slot = ReserveEventOrBlock(session, Transport::kEventSurfaceChanged, "kEventSurfaceChanged", payloadBytes); @@ -333,12 +345,7 @@ namespace MobileGL::MG_Remote::Server { // P5f fv: OnGlError now has the same owner and lifetime as the other producers. // PostGlError copies message bytes synchronously into the existing FIFO event ring. void ServerOnGlError(Uint32 code, const char* message) { - auto* session = ServerSession::Active(); - if (session == nullptr) { - MGLOG_F("MGPipe: Fatal{RoleViolation, \"OnGlError.session-missing\"}"); - std::abort(); - } - session->PostGlError(code, message); + ReverseCallbackOwner("OnGlError").PostGlError(code, message); } // One installer for both roles' tables, so the check exists in exactly one spelling: diff --git a/MobileGL/MG_Test/Wire/ServerLoopTest.cpp b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp index 78997831..cbe74d31 100644 --- a/MobileGL/MG_Test/Wire/ServerLoopTest.cpp +++ b/MobileGL/MG_Test/Wire/ServerLoopTest.cpp @@ -144,7 +144,7 @@ namespace { Codec::PipeWireEncoder encoder; Server::ServerSession* session = nullptr; - bool Handshake() { + bool Handshake(Server::ServerSession* owner = nullptr) { Transport::InProcessTransport::CreatePair(clientTransport, serverTransport); { ::flatbuffers::FlatBufferBuilder builder(512); @@ -160,7 +160,7 @@ namespace { return false; } } - session = &Server::ServerSessionInstance(); + session = owner != nullptr ? owner : &Server::ServerSessionInstance(); session->SetSegmentSizes(TestSizes()); // The two halves v1 owns. Neither has a default and CallMask() Fatals on an unset // one, which is s1's BLOCKER fix and the reason this is stated rather than derived. @@ -2586,6 +2586,109 @@ TEST(P5fReverseChannel, GlErrorMessagesShareFifoWithOtherReverseEvents) { events.Drained(); } +TEST(P5fReverseChannel, ANonSingletonSessionOwnsAllFourReverseCallbacks) { + FvGlobals restore; + ASSERT_EQ(Server::ServerSession::Active(), nullptr); + ASSERT_FALSE(Server::ServerSessionInstance().Accepted()); + Server::ServerSession owner; + FvSession fixture; + ASSERT_TRUE(fixture.Handshake(&owner)); + ASSERT_EQ(Server::ServerSession::Active(), &owner); + ASSERT_NE(fixture.session, &Server::ServerSessionInstance()); + ASSERT_TRUE(fixture.StartLoop()); + ASSERT_EQ(Server::ServerLoopInstance().RunProbeOnApplyThreadForTesting( + +[](void*) -> MobileGLResult { + MG_Pipe::gMGPipeCallbacks.OnGlError(static_cast(ErrorCode::InvalidOperation), + "non-singleton owner"); + const MG_Pipe::MGPRange whole{0, MG_Pipe::kMGPipeWholeBuffer}; + MG_Pipe::gMGPipeCallbacks.OnGpuWritten({41, 7}, 1, &whole); + const Uint8 bytes[] = {17, 34, 51, 68}; + MG_Pipe::gMGPipeCallbacks.OnBufferWriteback({41, 7}, 20, + MG_Pipe::MGPBlobRef{reinterpret_cast(bytes), sizeof(bytes), + MG_Pipe::kMGHostSpanSegNone, 0}); + MG_Pipe::MGPSurfaceInfo surface{}; + surface.Width = 321; + surface.Height = 123; + surface.InternalFormat = static_cast(TextureInternalFormat::RGBA8); + surface.Samples = 4; + surface.Layers = 2; + surface.IsDefault = 1; + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&surface); + return MOBILEGL_OK; + }, nullptr), MOBILEGL_OK); + + Transport::EventRingConsumer events(fixture.clientSegments.EventControl(), + fixture.clientSegments.CmdControl(), fixture.clientSegments.EventRingBase(), + fixture.clientSegments.EventRingCapacity(), fixture.clientSegments.EventSegmentBase()); + ASSERT_TRUE(events.Valid()); + Transport::RingRecordView event; + ASSERT_TRUE(events.Pop(event)); + ASSERT_EQ(event.kind, Transport::kEventGlError); + const auto* error = static_cast(event.payload); + EXPECT_EQ(error->Code, static_cast(ErrorCode::InvalidOperation)); + EXPECT_STREQ(reinterpret_cast(error + 1), "non-singleton owner"); + + ASSERT_TRUE(events.Pop(event)); + ASSERT_EQ(event.kind, Transport::kEventGpuWritten); + const auto* written = static_cast(event.payload); + EXPECT_EQ(written->Resource.Slot, 41u); + EXPECT_EQ(written->Resource.Gen, 7u); + ASSERT_EQ(written->RangeCount, 1u); + const auto* range = reinterpret_cast(written + 1); + EXPECT_EQ(range->Offset, 0u); + EXPECT_EQ(range->Size, MG_Pipe::kMGPipeWholeBuffer); + + ASSERT_TRUE(events.Pop(event)); + ASSERT_EQ(event.kind, Transport::kEventBufferWriteback); + const auto* writeback = static_cast(event.payload); + EXPECT_EQ(writeback->Resource.Slot, 41u); + EXPECT_EQ(writeback->Resource.Gen, 7u); + EXPECT_EQ(writeback->Offset, 20u); + ASSERT_EQ(writeback->Size, 4u); + const Uint8 expected[] = {17, 34, 51, 68}; + EXPECT_EQ(std::memcmp(writeback + 1, expected, sizeof(expected)), 0); + + ASSERT_TRUE(events.Pop(event)); + ASSERT_EQ(event.kind, Transport::kEventSurfaceChanged); + const auto* surface = static_cast(event.payload); + EXPECT_EQ(surface->Width, 321u); + EXPECT_EQ(surface->Height, 123u); + EXPECT_EQ(surface->InternalFormat, static_cast(TextureInternalFormat::RGBA8)); + EXPECT_EQ(surface->Samples, 4u); + EXPECT_EQ(surface->Layers, 2u); + EXPECT_EQ(surface->IsDefault, 1u); + EXPECT_FALSE(events.Pop(event)); + events.Drained(); + EXPECT_FALSE(Server::ServerSessionInstance().Accepted()); +} + +#if !defined(_WIN32) +TEST(P5fReverseChannel, CachedReverseCallbacksRejectAMissingSessionOwner) { + FvGlobals restore; + Server::ServerSession owner; + FvSession fixture; + ASSERT_TRUE(fixture.Handshake(&owner)); + const auto callbacks = MG_Pipe::gMGPipeCallbacks; + fixture.Stop(); + ASSERT_EQ(Server::ServerSession::Active(), nullptr); + const char* names[] = {"OnGlError", "OnGpuWritten", "OnBufferWriteback", "OnSurfaceChanged"}; + for (Uint32 callback = 0; callback < 4; ++callback) { + const auto logSize = ReadLog().size(); + EXPECT_EXIT({ + switch (callback) { + case 0: callbacks.OnGlError(0, "retired"); break; + case 1: callbacks.OnGpuWritten(MG_Pipe::kMGPipeNullHandle, 0, nullptr); break; + case 2: callbacks.OnBufferWriteback(MG_Pipe::kMGPipeNullHandle, 0, MG_Pipe::MGPBlobRef{}); break; + case 3: callbacks.OnSurfaceChanged(nullptr); break; + } + std::_Exit(9); + }, ::testing::KilledBySignal(SIGABRT), ""); + const auto marker = String(names[callback]) + ".session-missing"; + EXPECT_NE(ReadLog().substr(logSize).find(marker), String::npos) << marker; + } +} +#endif + int main(int argc, char** argv) { // Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first // write, and caches the FILE*. The name carries this process's pid, because