diff --git a/MobileGL/Config.h b/MobileGL/Config.h index e8d0e7f7..b8689639 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -603,6 +603,13 @@ namespace MobileGL::MG_Config { // red-once of the wait-count gate (PackBufferReadbackScenario): it must show a reply // wait per read. Uint32 PboReadbackSync = 0; + // MOBILEGL_IPC_POLL_ESCALATE (P10, MG_Remote/CONTRACT-P10.md §1): a glClientWaitSync with a + // zero timeout or a glGetSynciv(GL_SYNC_STATUS) is answered by the client from what the + // server reported (kEventFenceSignaled) - "signaled" when it has been, "not yet" otherwise + // - and only the Nth "not yet" in a row on one fence crosses as a real round trip, so a + // poll loop cannot outlive a late report. 0 = every poll crosses, as before P10: the A/B + // arm and the red-once of the no-reply gate (FencePollScenario). + Uint32 PollEscalate = 64; // MOBILEGL_IPC_ADOPT_TIER: 2 = emulate (client keeps the shadow and pushes), which // is the only tier P5 implements and the reason persistent-map-push can be non-zero // at all (R-6). 0 and 1 parse and are Fatal at use with "P11"; they exist now so the diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 7d3b7c42..c90a0941 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -423,6 +423,8 @@ namespace MobileGL::MG_ConfigLoader { ipc.CreateWindow = QueryEnvUint32("MOBILEGL_IPC_CREATE_WINDOW", 10, 1, 12); // P9 W1: the pack-buffer readback's A/B and red-once control (Config.h says both). ipc.PboReadbackSync = QueryEnvUint32("MOBILEGL_IPC_PBO_READBACK_SYNC", 0, 0, 1); + // P10: fence polls answered locally; 0 is the A/B arm (Config.h). + ipc.PollEscalate = QueryEnvUint32("MOBILEGL_IPC_POLL_ESCALATE", 64, 0, 1000000); // The verify harness compares the pushed block against the applier per verb; a // batched queue lets the comparer read a supplied field mid-apply, which is a // torn read rather than a divergence. The batch is therefore off whenever the @@ -524,11 +526,11 @@ namespace MobileGL::MG_ConfigLoader { // changes the wall clock by seconds (the P12 sweep: 28.3 s at 1 down to 10.8 s at 6) a // log that does not name it makes every run ambiguous about its own configuration. MGLOG_I("Config: IPC ring=%uMiB stage=%uMiB wire-deferred=%uMiB spin=%uus event-wait=%ums " - "persistent-block=%uKiB create-window=%u pbo-readback-sync=%u " + "persistent-block=%uKiB create-window=%u pbo-readback-sync=%u poll-escalate=%u " "adopt-tier=%u verb-barrier=%u run-ahead=%u present-credit=%u control-timeout=%ums " "cold-start=%ums strict=%d audit=%d role-split-state=%d affinity='%s' surface=%s", ipc.RingMb, ipc.StageMb, ipc.WireDeferredMb, ipc.SpinUs, ipc.EventWaitMs, - ipc.PersistentBlockKb, ipc.CreateWindow, ipc.PboReadbackSync, ipc.AdoptTier, + ipc.PersistentBlockKb, ipc.CreateWindow, ipc.PboReadbackSync, ipc.PollEscalate, ipc.AdoptTier, ipc.VerbBarrier, ipc.RunAhead, ipc.PresentCredit, ipc.ControlTimeoutMs, ipc.ColdStartMs, static_cast(ipc.StrictErrors), static_cast(ipc.Audit), static_cast(ipc.RoleSplitState), ipc.ServerAffinity.c_str(), diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index d70e1b14..afa23fb2 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -87,6 +87,8 @@ add_executable(MobileGLIntegrationTest # P9 (W1): reads into a pixel pack buffer answered by nothing. Ordinary GL cases too, plus # one wire case that skips where there is no wire. Scenarios/PackBufferReadbackScenario.cpp + # P10 (A): fence polls answered from the server's reports. + Scenarios/FencePollScenario.cpp Scenarios/PrimitiveRestartScenario.cpp Scenarios/FragCoordOriginScenario.cpp Scenarios/ClientVertexArrayScenario.cpp @@ -2458,6 +2460,11 @@ if (MOBILEGL_BUILD_DISAGGREGATED) # reply-slot record, one mark per read). The Magma arms are the `Pbo.` block after the Band # block below. mgl_itest_register_split_arms("PackBufferReadbackScenario.*" "") + # P10 (A, MG_Remote/CONTRACT-P10.md §1): fence polls answered from kEventFenceSignaled - the + # idiom's poll loops end (with and without the flush bit), a signaled fence's polls cross + # nothing, and an idle server still reports a fence that finishes. The Magma arms are the + # `Fence.` block beside the `Pbo.` block below. + mgl_itest_register_split_arms("FencePollScenario.*" "") # DepthStencilReadbackMatrixScenario GETS ITS OWN BLOCK, because it needs a variable the # registration macro cannot carry: MOBILEGL_ESPRYT_FORCE_DS_READBACK_EMULATION=1. @@ -3644,6 +3651,19 @@ if (MOBILEGL_BUILD_DISAGGREGATED) ${mglItestArmProperties} ENVIRONMENT "${mglItestMagmaBandEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p9-pbo${mglItestArmLogTag}-${pboCase}.log") endforeach() + # P10 (A): the fence polls on Magma. The server half is backend-neutral (the backend + # table's FenceSync / GetSyncStatus / ClientWaitSync), so the same three cases. + foreach(fenceCase ZeroTimeoutPollLoopsEndInBoundedTime + PollsOfASignaledFenceCrossNothing + AFenceThatFinishesWhileTheClientOnlyPollsIsReported) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.${mglItestArm}.Fence." + TEST_FILTER "FencePollScenario.${fenceCase}" + DISCOVERY_TIMEOUT 30 + PROPERTIES LABELS "${mglItestMagmaLabels}" TIMEOUT ${MGL_ITEST_TIMEOUT} + ${mglItestArmProperties} + ENVIRONMENT "${mglItestMagmaBandEnvironment}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p10-fence${mglItestArmLogTag}-${fenceCase}.log") + endforeach() endforeach() endif() diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp index cbc8c069..ca3b6248 100644 --- a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp @@ -168,6 +168,11 @@ namespace MGITest { state.replyPostings = session->ReplyPostings(); state.packBufferReadbackMarks = MobileGL::MG_Remote::Client::ProducerMarkCount( MobileGL::MG_Remote::Client::GpuWriteProducer::ReadPixelsPackBuffer); + const auto fences = MobileGL::MG_Remote::Client::ReadFencePollCounters(); + state.fenceLocalAnswers = fences.LocalAnswers; + state.fenceEscalations = fences.Escalations; + state.fenceRoundTrips = fences.RoundTrips; + state.fenceServerReports = fences.ServerReports; } #endif return state; diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h index 1f305d1f..7550b922 100644 --- a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h @@ -130,6 +130,15 @@ namespace MGITest { // apart from both sides. unsigned long long replyPostings = 0; unsigned long long packBufferReadbackMarks = 0; + + // ---- P10 (CONTRACT-P10.md §1): what the fence polls cost ---------------------------- + // EmitTables.h FencePollCounters, verbatim: polls answered with no record, zero-timeout + // polls escalated to a round trip, every fence answer that crossed, and the server's + // kEventFenceSignaled reports consumed. + unsigned long long fenceLocalAnswers = 0; + unsigned long long fenceEscalations = 0; + unsigned long long fenceRoundTrips = 0; + unsigned long long fenceServerReports = 0; }; SplitRuntimeState PeekSplitRuntime(); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/FencePollScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/FencePollScenario.cpp new file mode 100644 index 00000000..b3d7afd6 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/FencePollScenario.cpp @@ -0,0 +1,149 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FencePollScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// P10 (A, MG_Remote/CONTRACT-P10.md §1): FENCE POLLS ANSWERED FROM THE REVERSE CHANNEL. +// +// A glClientWaitSync / glGetSynciv(GL_SYNC_STATUS) used to be a kWaitReply record: its answer +// came back only after the server had applied every record before it, so a poll was a full +// pipeline drain and, over TCP, a round trip - once per frame in Minecraft's fence use. Now the +// server reports each fence the GPU finishes (kEventFenceSignaled) and the client answers polls +// from those reports; the Nth unanswered zero-timeout poll in a row (MOBILEGL_IPC_POLL_ESCALATE) +// still crosses, which is what bounds a loop whose report is late. +// +// Three cases. The first is ordinary GL - the idiom's two poll loops end - and its flush-bit arm +// runs on the monolith lanes too. The other two are about the wire and skip where there is none. + +#include +#include + +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitRuntimePeek.h" +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + using Clock = std::chrono::steady_clock; + constexpr auto kLoopBound = std::chrono::seconds(10); + + class FencePollScenario : public ScenarioTest { + protected: + // Some GPU work for a fence to wait behind. + static void QueueWork() { + glClearColor(0.25f, 0.5f, 0.75f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + } + }; + + // The GL idiom: `while (glClientWaitSync(s, flags, 0) == GL_TIMEOUT_EXPIRED) {}` and the + // glGetSynciv twin. With nothing else in the loop, no record would ever be published if + // the poll were not a doorbell point, and a local "not yet" would be repeated for ever if + // nothing made it true - so the loop ending, bounded, is the property. + // + // WITHOUT GL_SYNC_FLUSH_COMMANDS_BIT GL itself does not promise the loop ends (the fence + // may sit unflushed in the driver), so that arm is split-only: there the transport + // promises it, because the poll flushes the link and an idle server submits. + TEST_F(FencePollScenario, ZeroTimeoutPollLoopsEndInBoundedTime) { + if (!Ready()) return; + const bool split = SplitRuntimeSkipReason().empty(); + for (const GLbitfield flags : {GLbitfield(GL_SYNC_FLUSH_COMMANDS_BIT), GLbitfield(0)}) { + if (flags == 0 && !split) continue; + QueueWork(); + GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + ASSERT_NE(sync, nullptr); + const auto start = Clock::now(); + GLenum status = GL_TIMEOUT_EXPIRED; + unsigned long long polls = 0; + while ((status = glClientWaitSync(sync, flags, 0)) == GL_TIMEOUT_EXPIRED && + Clock::now() - start < kLoopBound) { + ++polls; + } + EXPECT_TRUE(status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED) + << "glClientWaitSync(flags 0x" << std::hex << flags << std::dec << ", 0) still " + << status << " after " << polls << " polls"; + glDeleteSync(sync); + } + QueueWork(); + GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + ASSERT_NE(sync, nullptr); + const auto start = Clock::now(); + GLint value = GL_UNSIGNALED; + while (value != GL_SIGNALED && Clock::now() - start < kLoopBound) { + glGetSynciv(sync, GL_SYNC_STATUS, 1, nullptr, &value); + } + EXPECT_EQ(value, GL_SIGNALED) << "glGetSynciv(GL_SYNC_STATUS) never read signaled"; + glDeleteSync(sync); + EXPECT_EQ(FirstGLError(), static_cast(GL_NO_ERROR)); + } + + // THE WIRE HALF: once a fence is known signaled, polling it costs nothing - no reply-slot + // record, no round trip - however often it is asked. MOBILEGL_IPC_POLL_ESCALATE=0 puts the + // round trip back and must fail both counters (its red-once). + TEST_F(FencePollScenario, PollsOfASignaledFenceCrossNothing) { + if (!Ready()) return; + const std::string why = SplitRuntimeSkipReason(); + if (!why.empty()) GTEST_SKIP() << why; + QueueWork(); + GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + ASSERT_NE(sync, nullptr); + const GLenum waited = glClientWaitSync(sync, GL_SYNC_FLUSH_COMMANDS_BIT, 5000000000ull); + ASSERT_TRUE(waited == GL_ALREADY_SIGNALED || waited == GL_CONDITION_SATISFIED) << waited; + + constexpr unsigned kPolls = 100; + const SplitRuntimeState before = PeekSplitRuntime(); + for (unsigned i = 0; i < kPolls; ++i) { + EXPECT_EQ(glClientWaitSync(sync, 0, 0), static_cast(GL_ALREADY_SIGNALED)); + GLint value = GL_UNSIGNALED; + glGetSynciv(sync, GL_SYNC_STATUS, 1, nullptr, &value); + EXPECT_EQ(value, GL_SIGNALED); + } + const SplitRuntimeState after = PeekSplitRuntime(); + glDeleteSync(sync); + EXPECT_EQ(after.replyPostings - before.replyPostings, 0u) + << "polling a signaled fence published reply-slot records (MOBILEGL_IPC_POLL_ESCALATE=0 " + "does this on purpose)"; + EXPECT_EQ(after.fenceRoundTrips - before.fenceRoundTrips, 0u); + EXPECT_EQ(after.fenceLocalAnswers - before.fenceLocalAnswers, 2ull * kPolls); + } + + // THE SERVER'S HALF: a fence the GPU finishes while the client sends nothing - it only + // polls - is still reported. The apply thread is idle then; it must submit what the driver + // holds and keep re-checking rather than park for ever, or the only way out of the loop is + // the escalation. The polls are paced (10 ms apart) so the escalation cannot come first by + // rate: 64 of them is 640 ms, far more than a clear takes. Red-once: take the idle + // re-check out of ServerLoop and this case escalates. + TEST_F(FencePollScenario, AFenceThatFinishesWhileTheClientOnlyPollsIsReported) { + if (!Ready()) return; + const std::string why = SplitRuntimeSkipReason(); + if (!why.empty()) GTEST_SKIP() << why; + QueueWork(); + GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + ASSERT_NE(sync, nullptr); + const SplitRuntimeState before = PeekSplitRuntime(); + const auto start = Clock::now(); + GLenum status = GL_TIMEOUT_EXPIRED; + while ((status = glClientWaitSync(sync, 0, 0)) == GL_TIMEOUT_EXPIRED && + Clock::now() - start < kLoopBound) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + const SplitRuntimeState after = PeekSplitRuntime(); + glDeleteSync(sync); + EXPECT_TRUE(status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED) << status; + EXPECT_EQ(after.fenceEscalations - before.fenceEscalations, 0u) + << "the loop needed an escalated round trip: the server did not report the fence on its " + "own while the client only polled"; + EXPECT_GE(after.fenceServerReports - before.fenceServerReports, 1u); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/SyncWireScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/SyncWireScenario.cpp index 58530b60..4dec9bf5 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/SyncWireScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/SyncWireScenario.cpp @@ -30,7 +30,9 @@ TEST_F(SyncWireScenario, FenceWaitStatusAndDeletionCrossAndPreserveRenderedPixel glWaitSync(sync, 0, GL_TIMEOUT_IGNORED); glDeleteSync(sync); EXPECT_FALSE(glIsSync(sync)); - EXPECT_GE(PeekSplitRuntime().emitSeq, before + 5); + // Create, the server-side wait and the delete always cross; since P10 the two polls may be + // answered from the server's fence report instead (FencePollScenario), so they are not counted. + EXPECT_GE(PeekSplitRuntime().emitSeq, before + 3); GLubyte pixel[4]{}; glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); const int expected[4] = {64, 128, 191, 255}; diff --git a/MobileGL/MG_Remote/Client/ClientSession.cpp b/MobileGL/MG_Remote/Client/ClientSession.cpp index 994659c4..6697c695 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.cpp +++ b/MobileGL/MG_Remote/Client/ClientSession.cpp @@ -22,6 +22,7 @@ #include "../Transport/FdPassing.h" #include "../Transport/InProcessTransport.h" #include "WireTables.h" +#include "EmitTables.h" #include #include @@ -482,9 +483,22 @@ namespace MobileGL::MG_Remote::Client { ++delivered; break; } + case Transport::kEventFenceSignaled: { + // P10 (CONTRACT-P10.md §1): remembered for every later poll of that fence. + if (view.payloadSize < sizeof(Transport::EventFenceSignaledHead)) { + SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"kEventFenceSignaled\"} - " + "a %llu-byte record is shorter than the fence handle it carries", + static_cast(view.payloadSize)); + } + const auto* head = + static_cast(view.payload); + NoteFenceSignaledByServer(MG_Pipe::MGPipeHandle{head->Fence.Slot, head->Fence.Gen}); + ++delivered; + break; + } default: - MGLOG_W("MG_Remote client: reverse-channel record kind %u is not consumed in " - "P5c (four of the ten callbacks are armed; the rest are P9's)", + MGLOG_W("MG_Remote client: reverse-channel record kind %u is not one this " + "build consumes", static_cast(view.kind)); break; } @@ -2235,6 +2249,12 @@ namespace MobileGL::MG_Remote::Client { } } + void ClientSession::PollEntry() { + if (!m_started || m_events == nullptr || DeviceLost()) return; + Flush(); + DrainEventRing(*m_events); + } + void ClientSession::Finish() { // glFinish. Nothing goes on the wire - Flush and Finish have no record (ARCHITECTURE // §11) - and under lockstep there was nothing to do, because the client had already diff --git a/MobileGL/MG_Remote/Client/ClientSession.h b/MobileGL/MG_Remote/Client/ClientSession.h index 0bf582ac..d2617791 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.h +++ b/MobileGL/MG_Remote/Client/ClientSession.h @@ -281,6 +281,12 @@ namespace MobileGL::MG_Remote::Client { // true while a run-ahead queue still holds them, so under run-ahead glFinish becomes // exactly WaitForApplyToCatchUp + the drain that comes with it. void Finish(); + // P10 (CONTRACT-P10.md §1): a polling entry point (glClientWaitSync, glGetSynciv( + // GL_SYNC_STATUS)) is a doorbell point. Everything published so far - the polled fence's + // own create record above all - is flushed to the server, and the reverse channel is + // drained, so a fence the server has since reported is known before the poll answers. + // Publishes nothing and waits for nothing. + void PollEntry(); // Submit published commands without waiting for application. This is // glFlush's contract and the frame-submit boundary for Present. void Flush(); diff --git a/MobileGL/MG_Remote/Client/EmitTables.cpp b/MobileGL/MG_Remote/Client/EmitTables.cpp index 799cff75..daa493e8 100644 --- a/MobileGL/MG_Remote/Client/EmitTables.cpp +++ b/MobileGL/MG_Remote/Client/EmitTables.cpp @@ -1592,15 +1592,87 @@ namespace MobileGL::MG_Remote::Client { // ============================================================================= // The frontend sees a local opaque token; neither this address nor the driver's // BackendSyncHandle is serialized. The Fence-kind slot allocator supplies wire identity. - struct RemoteFenceProxy { MG_Pipe::MGPipeHandle Handle; }; + struct RemoteFenceProxy { + MG_Pipe::MGPipeHandle Handle; + // P10: zero-timeout polls answered "not yet" in a row since the last escalation. + Uint32 Unanswered = 0; + }; std::mutex g_fenceMutex; UnorderedMap> g_fenceProxies; - MG_Pipe::MGPipeHandle FenceHandle(MG_Backend::BackendSyncHandle proxy) { + RemoteFenceProxy& FenceProxy(MG_Backend::BackendSyncHandle proxy) { const auto it = g_fenceProxies.find(proxy); if (it == g_fenceProxies.end()) Wire::WireProtocolFatal("Fence.proxy", "unknown client fence proxy"); - return it->second->Handle; + return *it->second; + } + + MG_Pipe::MGPipeHandle FenceHandle(MG_Backend::BackendSyncHandle proxy) { return FenceProxy(proxy).Handle; } + + // ---- P10 (CONTRACT-P10.md §1): WHAT THE SERVER HAS SAID IS SIGNALED ----------------- + // + // {slot -> gen} of every fence a kEventFenceSignaled named, or a round trip answered + // signaled. ITS OWN MUTEX, NOT g_fenceMutex: the report arrives in the event drain, and + // the drain runs INSIDE the emitters below while they hold g_fenceMutex (EmitAndWait and + // PollEntry both drain), so taking g_fenceMutex there would deadlock the GL thread on + // itself. A report for a fence already deleted leaves a {slot, old gen} entry that no live + // handle matches; the slot's next occupant overwrites it. + std::mutex g_signaledFenceMutex; + UnorderedMap g_signaledFences; + std::atomic g_fenceLocalAnswers{0}; + std::atomic g_fenceEscalations{0}; + std::atomic g_fenceRoundTrips{0}; + std::atomic g_fenceServerReports{0}; + + Bool FenceKnownSignaled(MG_Pipe::MGPipeHandle handle) { + const std::lock_guard lock(g_signaledFenceMutex); + const auto it = g_signaledFences.find(handle.Slot); + return it != g_signaledFences.end() && it->second == handle.Gen; + } + + void RememberFenceSignaled(MG_Pipe::MGPipeHandle handle) { + const std::lock_guard lock(g_signaledFenceMutex); + g_signaledFences[handle.Slot] = handle.Gen; + } + + void ForgetFence(MG_Pipe::MGPipeHandle handle) { + const std::lock_guard lock(g_signaledFenceMutex); + const auto it = g_signaledFences.find(handle.Slot); + if (it != g_signaledFences.end() && it->second == handle.Gen) g_signaledFences.erase(it); + } + + // MOBILEGL_IPC_POLL_ESCALATE=0 is the A/B control: every poll crosses, as before P10. A lost + // device takes the round-trip arm too, whose DECLINED answer is the no-op a dead fence gets. + Bool FencePollsAnswerLocally() { + return MG_Config::Ipc.PollEscalate != 0 && !ClientSession::DeviceLost(); + } + + // THE LOCAL ANSWER, OR "CROSS". A poll is a doorbell point first (PollEntry: flush what is + // published, drain the reverse channel), so the fence's own create record has reached the + // server and every report it has posted is in. Known signaled -> answered here. Not known + // and the call may not wait (a zero timeout, glGetSynciv) -> answered "not yet" here, + // unless this fence has had POLL_ESCALATE such answers in a row, in which case the poll + // takes one real round trip: the server's own reports are what make a local "not yet" + // eventually true, and the escalation is the bound that holds even if one is late. A call + // that may wait (a non-zero timeout) is never answered "not yet" here - it crosses and + // waits on the server, as the GL call asks. + enum class FencePollAnswer { Signaled, NotYet, Cross }; + FencePollAnswer AnswerFencePollLocally(ClientSession& session, RemoteFenceProxy& fence, Bool mayWait) { + if (!FencePollsAnswerLocally()) return FencePollAnswer::Cross; + session.PollEntry(); + if (FenceKnownSignaled(fence.Handle)) { + fence.Unanswered = 0; + g_fenceLocalAnswers.fetch_add(1, std::memory_order_relaxed); + return FencePollAnswer::Signaled; + } + if (mayWait) return FencePollAnswer::Cross; + if (++fence.Unanswered < MG_Config::Ipc.PollEscalate) { + g_fenceLocalAnswers.fetch_add(1, std::memory_order_relaxed); + return FencePollAnswer::NotYet; + } + fence.Unanswered = 0; + g_fenceEscalations.fetch_add(1, std::memory_order_relaxed); + return FencePollAnswer::Cross; } MG_Backend::BackendSyncHandle EmitFenceSync() { @@ -1669,16 +1741,33 @@ namespace MobileGL::MG_Remote::Client { GLenum EmitClientWaitSync(MG_Backend::BackendSyncHandle proxy, GLbitfield flags, GLuint64 timeout) { ClientSession& session = RequireSession("ClientWaitSync"); const std::lock_guard lock(g_fenceMutex); - const MG_Pipe::MGPFenceWait request{FenceHandle(proxy), timeout, flags, 0}; - return ReadFenceReply(session, MG_Pipe::MGPWireOp::FenceWait, &request, sizeof(request)); + RemoteFenceProxy& fence = FenceProxy(proxy); + switch (AnswerFencePollLocally(session, fence, /*mayWait=*/timeout != 0)) { + case FencePollAnswer::Signaled: return GL_ALREADY_SIGNALED; + case FencePollAnswer::NotYet: return GL_TIMEOUT_EXPIRED; + case FencePollAnswer::Cross: break; + } + g_fenceRoundTrips.fetch_add(1, std::memory_order_relaxed); + const MG_Pipe::MGPFenceWait request{fence.Handle, timeout, flags, 0}; + const Uint32 result = ReadFenceReply(session, MG_Pipe::MGPWireOp::FenceWait, &request, sizeof(request)); + if (result == GL_ALREADY_SIGNALED || result == GL_CONDITION_SATISFIED) RememberFenceSignaled(fence.Handle); + return result; } Bool EmitGetSyncStatus(MG_Backend::BackendSyncHandle proxy) { ClientSession& session = RequireSession("GetSyncStatus"); const std::lock_guard lock(g_fenceMutex); - const MG_Pipe::MGPHandleOnly desc{FenceHandle(proxy), static_cast(MG_Pipe::MGPipeKind::Fence), 0}; + RemoteFenceProxy& fence = FenceProxy(proxy); + switch (AnswerFencePollLocally(session, fence, /*mayWait=*/false)) { + case FencePollAnswer::Signaled: return true; + case FencePollAnswer::NotYet: return false; + case FencePollAnswer::Cross: break; + } + g_fenceRoundTrips.fetch_add(1, std::memory_order_relaxed); + const MG_Pipe::MGPHandleOnly desc{fence.Handle, static_cast(MG_Pipe::MGPipeKind::Fence), 0}; const Uint32 result = ReadFenceReply(session, MG_Pipe::MGPWireOp::FenceStatus, &desc, sizeof(desc)); if (result > 1) Wire::WireProtocolFatal("FenceStatus.reply", "status must be boolean"); + if (result != 0) RememberFenceSignaled(fence.Handle); return result != 0; } @@ -1701,6 +1790,7 @@ namespace MobileGL::MG_Remote::Client { nullptr, 0, nullptr, 0, nullptr); } MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::Fence, handle); + ForgetFence(handle); g_fenceProxies.erase(proxy); } @@ -1946,6 +2036,20 @@ namespace MobileGL::MG_Remote::Client { } // namespace + void NoteFenceSignaledByServer(MG_Pipe::MGPipeHandle fence) { + RememberFenceSignaled(fence); + g_fenceServerReports.fetch_add(1, std::memory_order_relaxed); + } + + FencePollCounters ReadFencePollCounters() { + FencePollCounters counters; + counters.LocalAnswers = g_fenceLocalAnswers.load(std::memory_order_relaxed); + counters.Escalations = g_fenceEscalations.load(std::memory_order_relaxed); + counters.RoundTrips = g_fenceRoundTrips.load(std::memory_order_relaxed); + counters.ServerReports = g_fenceServerReports.load(std::memory_order_relaxed); + return counters; + } + const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable() { // Leaked at exit like every other MG_Remote singleton (ID-8): MG_Backend::Init() // copies it into gBackendFunctionsTable and MobileGL::Destroy() clears that copy from diff --git a/MobileGL/MG_Remote/Client/EmitTables.h b/MobileGL/MG_Remote/Client/EmitTables.h index 8c902939..7e5c40d2 100644 --- a/MobileGL/MG_Remote/Client/EmitTables.h +++ b/MobileGL/MG_Remote/Client/EmitTables.h @@ -284,4 +284,23 @@ namespace MobileGL::MG_Remote::Client { GLsizei drawCount, GLsizei stride, GLintptr parameterOffset, Bool hasParameterBuffer); + // ---- P10 (CONTRACT-P10.md §1): fence polls answered from the reverse channel ---------- + // + // The event drain's half: the server reported `fence` signaled (kEventFenceSignaled). Every + // later glClientWaitSync / glGetSynciv(GL_SYNC_STATUS) on it answers without a round trip. + void NoteFenceSignaledByServer(MG_Pipe::MGPipeHandle fence); + + // What the fence polls cost, for the lanes. `LocalAnswers` = polls answered with no record + // (signaled or not); `Escalations` = zero-timeout polls that had gone unanswered + // MOBILEGL_IPC_POLL_ESCALATE times in a row and took a round trip; `RoundTrips` = every + // fence answer that crossed (escalations, non-zero timeouts, POLL_ESCALATE=0); + // `ServerReports` = kEventFenceSignaled records consumed. + struct FencePollCounters { + Uint64 LocalAnswers = 0; + Uint64 Escalations = 0; + Uint64 RoundTrips = 0; + Uint64 ServerReports = 0; + }; + FencePollCounters ReadFencePollCounters(); + } // namespace MobileGL::MG_Remote::Client diff --git a/MobileGL/MG_Remote/Server/PipeApplier.cpp b/MobileGL/MG_Remote/Server/PipeApplier.cpp index 0b78cd3f..5e5bf9a1 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.cpp +++ b/MobileGL/MG_Remote/Server/PipeApplier.cpp @@ -155,6 +155,8 @@ namespace MobileGL::MG_Remote::Server { entry.Live = true; // GL_Sync.cpp treats an absent slot or a null creation result as always signaled. entry.Native = table->GL.FenceSync == nullptr ? nullptr : table->GL.FenceSync(); + // P10: in creation order, for ReportSignaledFences - the order the GPU finishes them in. + m_unreportedFences.push_back(handle); return true; } @@ -216,6 +218,58 @@ namespace MobileGL::MG_Remote::Server { } } m_fences.clear(); + m_unreportedFences.clear(); + } + + // P10 (CONTRACT-P10.md §1): WHICH OF THE CLIENT'S FENCES HAS THE GPU FINISHED. + // + // The client answers a poll of a fence it knows is signaled without asking - that is the + // whole of package A - and it learns what is signaled only from here. Walked in CREATION + // order and stopped at the first one still pending: one context's GPU work completes in + // the order it was submitted, so a later fence cannot be done while an earlier one is not, + // and the steady-state cost is one status query per drain batch. (Were a backend ever to + // finish them out of order, the later fence would only be reported late - an answer the + // client then gets by its own round trip - never early.) + // + // `flush`: the apply thread is about to go idle. A fence whose commands the driver still + // holds never signals on its own, and an idle server submits nothing more, so the first + // pending fence is asked through ClientWaitSync(GL_SYNC_FLUSH_COMMANDS_BIT, 0) - the one + // backend-neutral way both backends take to submit what they hold - instead of the plain + // status query. Only when idle: mid-stream the next records flush soon enough, and a + // submit per batch would cost Magma its command-buffer batching. + Uint32 ServerVerbSink::ReportSignaledFences(Bool flush) { + if (m_unreportedFences.empty()) return 0; + ServerSession* session = ServerSession::Active(); + if (session == nullptr || m_backend == nullptr) return 0; + const MG_Backend::GLFunctionsTable& gl = m_backend->GetBackendFunctions().GL; + Uint32 reported = 0; + while (!m_unreportedFences.empty()) { + const MG_Pipe::MGPipeHandle handle = m_unreportedFences.front(); + const auto it = m_fences.find(handle.Slot); + if (it == m_fences.end() || !it->second.Live || it->second.Gen != handle.Gen) { + // Deleted before it was seen signaled: nobody can poll it any more. + m_unreportedFences.pop_front(); + continue; + } + const FenceEntry& entry = it->second; + Bool signaled = entry.Native == nullptr; + if (!signaled) { + if (flush && gl.ClientWaitSync != nullptr) { + const GLenum status = gl.ClientWaitSync(entry.Native, GL_SYNC_FLUSH_COMMANDS_BIT, 0); + signaled = status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED; + flush = false; // one submit is all an idle server owes + ++m_fenceIdleFlushes; + } else { + signaled = gl.GetSyncStatus == nullptr || gl.GetSyncStatus(entry.Native); + } + } + if (!signaled) break; + m_unreportedFences.pop_front(); + session->PostFenceSignaled(handle); + ++reported; + } + m_fencesReported += reported; + return reported; } #include "QueryServer.inc" diff --git a/MobileGL/MG_Remote/Server/PipeApplier.h b/MobileGL/MG_Remote/Server/PipeApplier.h index 5047801a..f4e15a13 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.h +++ b/MobileGL/MG_Remote/Server/PipeApplier.h @@ -46,6 +46,8 @@ #include "../Transport/Ring.h" #include "../Wire/PipeWireCodec.h" +#include + namespace MobileGL::MG_Remote::Server { // P5e (gl, ID-111): DOES THIS SERVER PUBLISH kCapRunAheadApply? Asked of the server's OWN @@ -157,6 +159,15 @@ namespace MobileGL::MG_Remote::Server { Bool OnFenceWait(const MG_Pipe::MGPFenceWait&, Uint32&) override; Bool OnFenceWaitServer(const MG_Pipe::MGPFenceWait&) override; void ReleaseFences(); + // P10 (CONTRACT-P10.md §1): post kEventFenceSignaled for each client fence, in creation + // order, that the GPU has finished, up to the first one still pending. `flush` = the + // apply thread is going idle: submit what the driver holds first. Apply thread only. + Uint32 ReportSignaledFences(Bool flush); + // True while a created fence has not been reported: the loop then parks with a deadline + // instead of for ever, so a fence that finishes while no record arrives is still seen. + Bool HasUnreportedFences() const { return !m_unreportedFences.empty(); } + Uint64 FencesReported() const { return m_fencesReported; } + Uint64 FenceIdleFlushes() const { return m_fenceIdleFlushes; } Bool OnQueryCreate(const MG_Pipe::MGPQueryDesc&) override; Bool OnQueryBegin(const MG_Pipe::MGPQueryDesc&) override; Bool OnQueryEnd(const MG_Pipe::MGPQueryDesc&) override; @@ -321,6 +332,10 @@ namespace MobileGL::MG_Remote::Server { }; FenceEntry& FindFence(MG_Pipe::MGPipeHandle handle); UnorderedMap m_fences; + // P10: created and not yet reported signaled, oldest first. + std::deque m_unreportedFences; + Uint64 m_fencesReported = 0; + Uint64 m_fenceIdleFlushes = 0; struct QueryEntry { Uint32 Gen = 0; Uint32 Kind = 0; diff --git a/MobileGL/MG_Remote/Server/ServerLoop.cpp b/MobileGL/MG_Remote/Server/ServerLoop.cpp index 509d3d54..30250d16 100644 --- a/MobileGL/MG_Remote/Server/ServerLoop.cpp +++ b/MobileGL/MG_Remote/Server/ServerLoop.cpp @@ -476,11 +476,24 @@ namespace MobileGL::MG_Remote::Server { return signals.CmdHead->load(std::memory_order_acquire) != ring.LocalTail(); }; + // P10 (CONTRACT-P10.md §1): the client answers a fence poll from what this thread reports, + // so a fence the GPU finishes must be reported whether or not another record ever arrives. + // After every drain batch the pending fences are asked (a status query, no submit); a + // thread about to go idle submits first and, while any fence is still pending, parks for + // at most kFenceIdlePollMs rather than for ever. Never on a full SEG_EVENT: posting there + // would park this thread inside the post, and the ring test below already says "not now". + ServerVerbSink& verbs = session.Applier().Verbs(); + constexpr Uint32 kFenceIdlePollMs = 1; + const auto eventRingHasRoom = [signals] { + return signals.EventRingFull->load(std::memory_order_acquire) == 0; + }; + for (;;) { PumpControlRequest(); if (stopOrForfeit()) break; - if (signals.EventRingFull->load(std::memory_order_acquire) == 0) DrainRing(); + if (eventRingHasRoom()) DrainRing(); if (stopOrForfeit()) break; + if (eventRingHasRoom() && !SessionLatched()) verbs.ReportSignaledFences(/*flush=*/false); // PH-1 (3): a latched session applies nothing more, so this thread leaves the loop // (and does not spin on a ring whose head is still ahead of a tail DrainRing will no // longer move). The exit path below tears the backend down as for a Stop; the @@ -493,6 +506,12 @@ namespace MobileGL::MG_Remote::Server { break; } if (ready()) continue; + // P10: going idle with a client fence still pending - submit what the driver holds and + // ask again, once, before the park. + if (verbs.HasUnreportedFences() && eventRingHasRoom()) { + verbs.ReportSignaledFences(/*flush=*/true); + if (ready()) continue; + } const Uint64 waits = m_parks.fetch_add(1, std::memory_order_acq_rel) + 1; // THE WAIT LEDGER'S SERVER HALF (P5d round 3, package T item 4). The client may not @@ -519,18 +538,21 @@ namespace MobileGL::MG_Remote::Server { } // Flush-on-idle publishes the last reply record even below the batch threshold. session.FlushDataProgress(); + const Bool fencePending = verbs.HasUnreportedFences(); const bool woke = bell.Wait(*signals.ConsumerParked, ready, spinUs, - Transport::kWaitForever, &m_parkBlocks); + fencePending ? kFenceIdlePollMs : Transport::kWaitForever, + &m_parkBlocks); if (!woke) { - // Wait returns false only on a dead bell or an expired deadline, and this park - // has no deadline. A dead bell IS the shutdown signal (table 3's teardown step - // 2); treating it as anything else would spin at full clock for ever, since - // parking on a dead bell no longer blocks. + // Wait returns false only on a dead bell or an expired deadline. A dead bell IS the + // shutdown signal (table 3's teardown step 2); treating it as anything else would + // spin at full clock for ever, since parking on a dead bell no longer blocks. if (bell.Dead()) { MGLOG_D("MG_Remote server: the consumer doorbell is dead; mgl-srv-apply is " "shutting down"); break; } + // P10: the deadline is the fence re-check, and the only park that has one. + if (fencePending) continue; MGLOG_E("MG_Remote server: Doorbell::Wait(kWaitForever) returned false on a live " "bell - that is impossible by Doorbell::Wait's park loop (the only " "false returns are the Dead() arm and an expired deadline) and means " diff --git a/MobileGL/MG_Remote/Server/ServerSession.cpp b/MobileGL/MG_Remote/Server/ServerSession.cpp index 4bec1ed2..1326c795 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.cpp +++ b/MobileGL/MG_Remote/Server/ServerSession.cpp @@ -1128,6 +1128,22 @@ namespace MobileGL::MG_Remote::Server { PublishEvents(); } + void ServerSession::PostFenceSignaled(MG_Pipe::MGPipeHandle fence) { + if (!m_accepted) { + MGLOG_E_ONCE("MG_Remote server: PostFenceSignaled {%u, %u} before Accept - dropped, " + "there is no SEG_EVENT to carry it on", + fence.Slot, fence.Gen); + return; + } + void* slot = ReserveEventOrBlock(*this, Transport::kEventFenceSignaled, "kEventFenceSignaled", + sizeof(Transport::EventFenceSignaledHead)); + Transport::EventFenceSignaledHead head{}; + head.Fence.Slot = fence.Slot; + head.Fence.Gen = fence.Gen; + std::memcpy(slot, &head, sizeof(head)); + PublishEvents(); + } + // PH-6 (ID-P7-2). THE FIRST DROP, AND THE LATCH IT RAISES. // // Logged ONCE, by name, with everything a triage needs: which event, how big, what the diff --git a/MobileGL/MG_Remote/Server/ServerSession.h b/MobileGL/MG_Remote/Server/ServerSession.h index b56b642d..9f66e09b 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.h +++ b/MobileGL/MG_Remote/Server/ServerSession.h @@ -210,6 +210,10 @@ namespace MobileGL::MG_Remote::Server { // ruling (§1), not a check. A session that has not accepted drops the error with a // loud line, exactly as RecordError's own no-live-context arm does. void PostGlError(Uint32 code, const char* message); + // P10 (CONTRACT-P10.md §1): one kEventFenceSignaled for a client fence the apply thread + // found signaled. Posted and published at once - a client may be polling on it right now. + // Dropped with the same loud line as PostGlError before Accept. + void PostFenceSignaled(MG_Pipe::MGPipeHandle fence); Transport::ITransport* Control_Plane(); // ---- PH-6 (ID-P7-2): THE REVERSE CHANNEL IS FORFEITED, NOT FATAL ------------------- diff --git a/MobileGL/MG_Remote/Transport/EventRing.h b/MobileGL/MG_Remote/Transport/EventRing.h index eb6ed1d2..ef8dfb43 100644 --- a/MobileGL/MG_Remote/Transport/EventRing.h +++ b/MobileGL/MG_Remote/Transport/EventRing.h @@ -50,6 +50,7 @@ namespace MobileGL::MG_Remote::Transport { kEventGpuWritten = 2, // MGPipeCallbacks::OnGpuWritten kEventSurfaceChanged = 3, // MGPipeCallbacks::OnSurfaceChanged kEventGlError = 4, // PipeInputs::RecordError, posted by the server session + kEventFenceSignaled = 5, // P10: a client fence the server saw signaled (CONTRACT-P10 §1) }; // The 8-byte {slot, gen} pair, mirrored (MGPipeHandles.h:54-65). @@ -110,6 +111,14 @@ namespace MobileGL::MG_Remote::Transport { std::uint32_t MessageBytes; // strlen + 1, the NUL included }; static_assert(sizeof(EventGlErrorHead) == 8, "wire shape"); + // P10 (CONTRACT-P10.md §1): the fence the server's apply thread found signaled, once per + // fence. The client keeps the answer and gives it to every later glClientWaitSync / + // glGetSynciv(GL_SYNC_STATUS) on that fence without a round trip. + struct EventFenceSignaledHead { + EventHandle Fence; + }; + static_assert(sizeof(EventFenceSignaledHead) == 8, "wire shape"); + inline constexpr std::uint32_t kEventGlErrorMaxMessageBytes = 1024; static_assert(kEventGlErrorMaxMessageBytes == 1024, "CONTRACT-P5C §1 caps an inline GL-error message at 1024 bytes, NUL included"); diff --git a/MobileGL/MG_Test/Wire/RemoteClientControls.inc b/MobileGL/MG_Test/Wire/RemoteClientControls.inc index 982a0d35..a9831ff3 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientControls.inc +++ b/MobileGL/MG_Test/Wire/RemoteClientControls.inc @@ -789,6 +789,9 @@ struct DecliningSyncPeer : Codec::WireVerbSink { TEST(RemoteClientControls, DeclinedFenceReplyAnswersSignaledInsteadOfAborting) { const auto child = RunInChild([] { + // The ROUND-TRIP arm is the subject: since P10 a zero-timeout poll of a fence the server + // has not reported is answered "not yet" locally, and would never reach the reply. + MG_Config::Ipc.PollEscalate = 0; StartControlSession(); DecliningSyncPeer peer; peer.Install(); @@ -831,6 +834,7 @@ TEST(RemoteClientControls, ErrorFenceReplyStillRefusesByName) { // THE TOLERANCE IS NOT BLANKET. An ERROR is a transport fault or a server-side refusal, // never "the server said no" (R-5/M4), and it keeps the named Fatal it always had. const auto child = RunInChild([] { + MG_Config::Ipc.PollEscalate = 0; // the round-trip arm, as above StartControlSession(); // The rows still decline (so the fence is never handed to a backend that cannot // create one) and EVERY answer that is posted says ERROR instead. @@ -845,6 +849,60 @@ TEST(RemoteClientControls, ErrorFenceReplyStillRefusesByName) { ExpectNamedAbort(child, "Fatal{ProtocolCorruption, \"Fence.reply\"}"); } +// ===================================================================================== +// P10 A (CONTRACT-P10.md §1): the escalation bound, counted at the record +// ===================================================================================== +// +// A peer that never reports the fence and answers every FenceWait record TIMEOUT_EXPIRED: with +// MOBILEGL_IPC_POLL_ESCALATE = 4 the first three zero-timeout polls are answered locally and +// cross nothing, the fourth crosses, and the count starts again. A peer that DOES report (here: +// the reply of a wait that saw it signaled) is answered locally from then on, with no record. +struct CountingFencePeer : Codec::WireVerbSink { + std::atomic waits{0}; + std::atomic signaled{false}; + Bool OnFenceCreate(const MG_Pipe::MGPHandleOnly&) override { return true; } + Bool OnFenceWait(const MGPFenceWait&, Uint32& result) override { + waits.fetch_add(1); + result = signaled.load() ? GL_ALREADY_SIGNALED : GL_TIMEOUT_EXPIRED; + return true; + } + void Install() { + const auto rc = Srv::ServerLoopInstance().RunProbeOnApplyThreadForTesting([](void* self) { + auto& decoder = Srv::ServerSessionInstance().Applier().*PeerMember(DecoderTag{}); + decoder.SetVerbSink(static_cast(self)); + return MOBILEGL_OK; + }, this); + if (rc != MOBILEGL_OK) ::_exit(160); + } +}; + +TEST(RemoteClientControls, AnUnreportedFencePollCrossesOnlyEveryPollEscalateTimes) { + const auto child = RunInChild([] { + MG_Config::Ipc.PollEscalate = 4; + StartControlSession(); + CountingFencePeer peer; + peer.Install(); + auto* sync = RemoteEmitTable().GL.FenceSync(); + if (sync == nullptr) ::_exit(161); + for (int i = 0; i < 3; ++i) + if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_TIMEOUT_EXPIRED) ::_exit(162); + ClientSessionInstance().WaitForApplyToCatchUp("P10 control"); + if (peer.waits.load() != 0) ::_exit(163); // three local answers, no record + if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_TIMEOUT_EXPIRED) ::_exit(164); + if (peer.waits.load() != 1) ::_exit(165); // the fourth crossed + peer.signaled.store(true); + for (int i = 0; i < 3; ++i) (void)RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0); + if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_ALREADY_SIGNALED) ::_exit(166); + if (peer.waits.load() != 2) ::_exit(167); // the next escalation learned it + for (int i = 0; i < 10; ++i) + if (RemoteEmitTable().GL.ClientWaitSync(sync, 0, 0) != GL_ALREADY_SIGNALED) ::_exit(168); + if (peer.waits.load() != 2) ::_exit(169); // known signaled: answered locally for good + RemoteEmitTable().GL.DeleteSync(sync); + ClientSessionInstance().Stop(); + }); + ExpectChildSuccess(child); +} + TEST(RemoteClientControls, DeviceLostRespecifyDoesNotDieOfTheUncarriedBytesCheck) { // P12 exit gate (a): killing the server must give a CLEAN device-lost latch. With the // latch set every verb is DECLINED, so the resource_subdata that R-13.3 owes emits