mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
Steps 1 and 2 of the plan the design review set out, landed and measured on the
device. Step 1 answers the question step 2 depended on; step 2 answers the one the
whole line depended on, and the answer is that the window's shape is wrong.
STEP 1 - CAN AN ANSWER BE LOST IN DELIVERY? NO. ReplyPool now counts what the link
took (PostedReplies) and what it refused (FailedReplies), and a non-OK result is
NAMED instead of swallowed: it checked only BUFFER_TOO_SMALL and let
TRANSPORT_CLOSED vanish with no line and no counter, so "the server never sent it"
and "it was sent and the buffer overwrote it" were the same symptom with opposite
fixes. Measured: posted=4501 failed=0. The twenty-answer gap between the server's
tally and the client's was a sampling difference, not a drop - and the server's
m_answered counts ATTEMPTS (incremented before PostReply), which is why it could
never have settled it.
STEP 2 - DOES A RESIDENCY BUDGET FIX THE WINDOW? NO.
Wire_ResourceCreate now measures how many reply-owning records the client has
emitted since the oldest window entry was pushed (ClientSession::ReplyPostings, a
new clock) and takes the oldest answer back as soon as that approaches the reply
buffer's depth, instead of deferring past the point where the answer can still
exist. On the device:
P12win call=4501 cfg=2 eff=2 pending=2 taken=63 pushed=65 blocked=4435
budget=4451 readfail=8872 posts=54377
kind=frame-op frame=14 wait_replies=9078 by_op=2=9071 wall 27.87 s
budget=4451: it fires on essentially EVERY create, the window stops deferring
(pushed=65), and the drain still reads nothing back. THE REASON IS STRUCTURAL, not
a constant:
* the window only gets control WHEN A CREATE ARRIVES. Between two creates this
client emits on the order of a thousand records - at the FIRST failed read the
reply ring already spanned [1175..1504] while the head was seq 310 - and nothing
looks at the window in that gap;
* what displaces a deferred answer is not what this client emits, it is WHAT THE
SERVER ANSWERS. The server is the one behind (one answer per ~5 ms wakeup while
the client runs ahead), so the replies arriving during the gap are its LATE
ANSWERS TO THE EARLIER CREATES - they fill the 8 slots first;
* so the check fires too late by construction. Tightening it does not fix that; it
only removes the deferral entirely, which is what pushed=65 is.
WHAT THAT LEAVES. A deferral is readable only if the client KEEPS the answers it
deferred. The buffer is bounded by COUNT and the client cannot bound how many
answers the server will post between two of its creates, so hoping the buffer still
holds one is a race the client loses - which is also why the earlier two diagnoses
(reply-pool churn, then FIFO-with-no-way-out) were both describing symptoms of the
same thing. The shape that can work is retention: the reader keeps the answers for
the seqs still outstanding (a handful), drops them when read, and reports a LOSS
when a read asks for one it no longer has. That is a transport change - StreamLink's
ring becomes a retention map with a cap and a lost counter - and it is the next step.
BOTH STEPS STAY: step 1 is a net gain on its own, and step 2 is the safe direction
(it can only degrade the window to no-deferral, i.e. the pre-window shape) and its
clock is the one the retention design needs. MOBILEGL_IPC_CREATE_WINDOW stays at 1.
172/172 and 13/13 after the probes were removed. The measurements and the revised
conclusion are in notes/p12/CREATE-WINDOW-MEASURED.md section 9.
1295 lines
86 KiB
C++
1295 lines
86 KiB
C++
// MobileGL - MobileGL/MG_Remote/Client/WireTables.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
|
|
|
|
// The ENCODE TWIN of gMGPipeWireRecordApply: thirty-eight emitters that turn a table call into
|
|
// a wire record. Owner: package c1 (P5 ruling R-17). See WireTables.h for the install order
|
|
// and MG_Pipe/PipeRoute.h for what R-17 actually cost.
|
|
//
|
|
// EVERY EMITTER IS THE SAME FOUR STEPS, and the macros below exist so that a reader can check
|
|
// thirty-eight rows against PipeTables.inc in one pass instead of reading thirty-eight bodies:
|
|
//
|
|
// 1. require a session - a slot that fell through to a driver this role does not have is
|
|
// the failure R-4 exists to prevent, and there is no fall-through here either;
|
|
// 2. stage the blob, if the row has one, and name the SEG_STAGE run in the payload's own
|
|
// MGPBlobRef - which is why the payload is COPIED: the table hands it over const, and
|
|
// the blobref is the one field the client must write after the caller is done with it;
|
|
// 3. EmitAndWait, which is the barrier's wait and the reply's wait at once (R-3/R-5);
|
|
// 4. post the answer, for the rows that have one, into MG_Pipe's reply mailbox.
|
|
//
|
|
// WHAT IS DELIBERATELY NOT HERE. b1's `PushPersistentMapsBeforeVerb` / `MarkGpuWritesFor*` are
|
|
// NOT called from these thirty-eight. They are pre-VERB hooks and these are not verbs: they
|
|
// are the resource, CSO and state records that a verb is later drawn against. The five class-B
|
|
// verbs in EmitTables.cpp call them, once each, immediately before their record, which is the
|
|
// ordering b1's B-1 fix depends on. Calling them here as well would push a persistent map
|
|
// before every `set_dynamic_state` - hundreds of times a frame, and each one a real record.
|
|
|
|
#include "WireTables.h"
|
|
#include <MG_Remote/FatalFunnel.h>
|
|
|
|
#if MOBILEGL_BUILD_DISAGGREGATED
|
|
|
|
#include "ClientSession.h"
|
|
|
|
#include "../Server/ServerLoop.h"
|
|
|
|
#include <MG_Pipe/MGPipe.h>
|
|
#include <MG_Pipe/PipeRoute.h>
|
|
// P12: MGPipeHandleIsPublished, the latch that says an object's existence is CONFIRMED.
|
|
#include <MG_Pipe/PipeMutation.h>
|
|
// P5c (ct): EmitObjectDeathRecord resolves the dying object's handle in the CLIENT's own
|
|
// allocator - a client surface, asked on the client thread, which is the one place the lookup
|
|
// is legal under rule E (CONTRACT-P5C.md §5.2).
|
|
#include <MG_Impl/Pipe/SlotAllocator.h>
|
|
#include <MG_State/GLState/ProgramState/ProgramArtifactsCodec.h>
|
|
#include <MG_State/GLState/StateObjectDeathNotice.h>
|
|
#include <MG_Util/Debug/Log.h>
|
|
// P5e (pg): ID-87 asks this package for bytes-per-link measured rather than argued, and the
|
|
// archive's byte count exists exactly once - here, where it is serialised.
|
|
#include <MG_Util/Metrics/PipeStats.h>
|
|
// P12: kDefaultReplySlotCount, because the create window's ceiling is derived from the pool it
|
|
// shares rather than from a number written twice (see kCreateWindowMax below).
|
|
#include <MG_Remote/Transport/ReplySlot.h>
|
|
// P12: LinkMetricsBeginReply/ReplyApplied, so the window's blocking take is visible in by_op 2.
|
|
#include <MG_Remote/Transport/LinkMetrics.h>
|
|
|
|
#include <atomic>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
|
|
namespace MobileGL::MG_Remote::Client {
|
|
|
|
using MG_Pipe::MGPWireOp;
|
|
|
|
// TABLE 3's ROLE SPLIT, AS A RUNTIME CHECK. gMGPipeScreen / gMGPipeContext are PROCESS
|
|
// globals and under `inproc` the server role is a thread in this same process, so the
|
|
// apply thread running the server's own backend - the EGL bring-up, InitCapabilities,
|
|
// the applier - reaches these very emitters. A record published there would be waited
|
|
// for by the thread that is supposed to apply it: `Fatal{BarrierTimeout,
|
|
// "ResourceRespecify"}` from `mgl-srv-apply` one barrier budget into bring-up, which is
|
|
// exactly how this was found.
|
|
//
|
|
// THE ANSWER IS NOT "SUPPRESS THE RECORD" - it is "run the server's own code", because
|
|
// on that thread this process IS the server and the applier is one call away. It is
|
|
// the same thing PipeWireCodec does on the decode side, where every arm calls
|
|
// MGPipeApply* directly and never goes through a table.
|
|
//
|
|
// Under `spawn` (P6) the predicate is constantly false in the client process and
|
|
// constantly true in the server's, so this costs one atomic load and changes nothing.
|
|
//
|
|
// NOT in the anonymous namespace, because M5 needs it from MG_Impl/Pipe/PipeFill.cpp too
|
|
// (its split-only respecify/flush branches must not run on the apply thread). Declared in
|
|
// WireTables.h.
|
|
//
|
|
// AND IT STAYS OUT OF LINE (P5d round 3, package D). OnApplyThread() became an inline
|
|
// relaxed load plus a thread-pointer compare in ServerLoop.h, so the body this forwards to
|
|
// is now smaller than the call that reaches it - but inlining THIS would mean including a
|
|
// server header from WireTables.h, and the declaration above exists precisely so PipeFill
|
|
// does not have to. One PLT hop per verb is the price of that boundary; the four-part
|
|
// predicate behind it, which was the measured cost (2.66% self on the client thread at
|
|
// head 56a77348), is gone either way. The "one atomic load" the paragraph above promised
|
|
// is now literally what it costs.
|
|
Bool RunsAsTheServerRole() { return Server::ServerLoop::OnApplyThread(); }
|
|
|
|
namespace {
|
|
|
|
Uint64 g_emitted = 0;
|
|
Uint64 g_declined = 0;
|
|
|
|
// TEARDOWN REFUSAL (codex 4). ClientSession::Stop marks the routed tables uninstalled
|
|
// BEFORE it frees the rings, the segments and the transport - so the window between then
|
|
// and the moment the monolith adapters go back is one in which a routed GL-thread call
|
|
// must not run the applier on the caller (the forbidden path table 3 draws) and must not
|
|
// reach a half-freed ring. Round 2 reinstalled the monolith adapters at the top of Stop,
|
|
// which is exactly running the applier on the caller; a routed mutation after
|
|
// UninstallClientWireTables() moved no wire ordinal and never refused (the cross-family
|
|
// verifier reproduced it). So Uninstall now RAISES THIS FLAG and leaves the wire rows in
|
|
// place; RequireSession below reads it and refuses by name before it touches anything.
|
|
// Atomic because the apply thread's own role check races a GL-thread teardown.
|
|
std::atomic<Bool> g_clientTablesUninstalled{false};
|
|
|
|
ClientSession& RequireSession(const char* row) {
|
|
// BEFORE the session lookup and before any ring access. A routed call that arrives
|
|
// once Stop has begun tearing the session down is refused by name rather than run
|
|
// on the caller's thread - the applier is server-exclusive (table 3), and a wire
|
|
// emit into a ring being freed is a use-after-free. The monolith adapters are put
|
|
// back only as the LAST step of teardown, for the at-exit ~BufferObject deletes that
|
|
// legitimately reach a process with no session (see ReinstallMonolithAfterTeardown).
|
|
RequireClientTablesInstalled(row);
|
|
ClientSession* session = ClientSession::Active();
|
|
if (session == nullptr) {
|
|
// @Ph-declined (ID-P7-1): returns ClientSession& and runs in the CLIENT - no
|
|
// session to hand back, no peer bytes, and the latch is a server-session idea.
|
|
SessionFail(MGFatalFamily::NoClientSession, "MGPipe: Fatal{NoClientSession, \"%s\"} - the client wire tables are "
|
|
"installed but no ClientSession is active. A row may not fall through to "
|
|
"a driver this role does not have",
|
|
row);
|
|
}
|
|
return *session;
|
|
}
|
|
|
|
// Stages a mandatory blob. `StageBytes` Fatals on a zero size by design (R-2.2: "the
|
|
// record declared no blob" and "the record declared an empty blob" must not be spelled
|
|
// the same way on a wire), so a row whose decoder calls RequireDeclaredBlob or
|
|
// ResolveOrFatal is checked HERE, on the producing side, where the row has a name.
|
|
MG_Pipe::MGPBlobRef StageRequired(ClientSession& session, const char* row,
|
|
const void* bytes, Uint64 count) {
|
|
if (bytes == nullptr || count == 0) {
|
|
SessionFail(MGFatalFamily::BlobMissing, "MGPipe: Fatal{BlobMissing, \"%s\"} - the row's decoder requires a "
|
|
"declared blob and the call site handed over %llu bytes at %p. Under "
|
|
"monolith the companion pointer carries them; under split they have to "
|
|
"be staged, and there is nothing to stage",
|
|
row, static_cast<unsigned long long>(count), bytes);
|
|
}
|
|
return session.Encoder().StageBytes(bytes, count);
|
|
}
|
|
|
|
// Stages an OPTIONAL blob: the two sub-data rows, whose decoders resolve only when the
|
|
// record's own size field says there are bytes. All three fields zero is the wire's
|
|
// "no blob declared", and CheckBlobIsHonest refuses any other spelling of it.
|
|
MG_Pipe::MGPBlobRef StageOptional(ClientSession& session, const void* bytes, Uint64 count) {
|
|
if (bytes == nullptr || count == 0) return MG_Pipe::MGPBlobRef{};
|
|
return session.Encoder().StageBytes(bytes, count);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// The three regular shapes
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
#define MGP_WIRE_PLAIN(Name, Payload, Table) \
|
|
void Wire_##Name(const MG_Pipe::Payload* payload) { \
|
|
if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolith##Table().Name(payload); return; } \
|
|
ClientSession& session = RequireSession(#Name); \
|
|
session.EmitAndWait(MGPWireOp::Name, payload, sizeof(*payload), nullptr, 0, nullptr, 0, \
|
|
nullptr); \
|
|
++g_emitted; \
|
|
}
|
|
|
|
#define MGP_WIRE_BLOB(Name, Payload, BlobMember) \
|
|
void Wire_##Name(const MG_Pipe::Payload* payload, const void* blobBytes, \
|
|
Uint64 blobByteCount) { \
|
|
if (RunsAsTheServerRole()) { \
|
|
MG_Pipe::MGPipeMonolithContext().Name(payload, blobBytes, blobByteCount); \
|
|
return; \
|
|
} \
|
|
ClientSession& session = RequireSession(#Name); \
|
|
MG_Pipe::Payload record = *payload; \
|
|
record.BlobMember = StageRequired(session, #Name, blobBytes, blobByteCount); \
|
|
session.EmitAndWait(MGPWireOp::Name, &record, sizeof(record), nullptr, 0, nullptr, 0, \
|
|
nullptr); \
|
|
++g_emitted; \
|
|
}
|
|
|
|
#define MGP_WIRE_TAIL(Name, Payload, TailType) \
|
|
void Wire_##Name(const MG_Pipe::Payload* payload, const void* varTail, Uint32 varTailCount) { \
|
|
if (RunsAsTheServerRole()) { \
|
|
MG_Pipe::MGPipeMonolithContext().Name(payload, varTail, varTailCount); \
|
|
return; \
|
|
} \
|
|
ClientSession& session = RequireSession(#Name); \
|
|
session.EmitAndWait(MGPWireOp::Name, payload, sizeof(*payload), varTail, \
|
|
static_cast<Uint64>(varTailCount) * sizeof(MG_Pipe::TailType), \
|
|
nullptr, 0, nullptr); \
|
|
++g_emitted; \
|
|
}
|
|
|
|
// -- screen -------------------------------------------------------------------
|
|
MGP_WIRE_PLAIN(ResourceDestroy, MGPHandleOnly, Screen)
|
|
MGP_WIRE_PLAIN(UnmapPersistent, MGPHandleOnly, Screen)
|
|
|
|
// -- context, plain -----------------------------------------------------------
|
|
MGP_WIRE_PLAIN(BindRenderState, MGPBindRenderState, Context)
|
|
MGP_WIRE_PLAIN(DeleteRenderState, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(BindVertexElements, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(DeleteVertexElements, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(DeleteSamplerState, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(CreateSamplerView, MGPSamplerView, Context)
|
|
MGP_WIRE_PLAIN(DeleteSamplerView, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(BindShaderState, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(DeleteShaderState, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(SetDrawProgram, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(SetDispatchProgram, MGPHandleOnly, Context)
|
|
MGP_WIRE_PLAIN(SetFramebufferState, MGPFramebufferState, Context)
|
|
MGP_WIRE_PLAIN(SetIndexBuffer, MGPIndexBuffer, Context)
|
|
MGP_WIRE_PLAIN(SetPixelPackState, MGPPixelPackState, Context)
|
|
MGP_WIRE_PLAIN(SetPatchState, MGPPatchState, Context)
|
|
// P5c (rv), CONTRACT-P5C.md §5.3: the residual-value record is an ordinary routed
|
|
// set_* row - a fixed POD, no blob, no tail, no reply. The PRODUCER is transport-gated
|
|
// (PipeFill.cpp's EmitContextValues), so under monolith this wrapper is never reached;
|
|
// the server-role arm forwards to the monolith adapter exactly like its siblings.
|
|
MGP_WIRE_PLAIN(SetContextValues, MGPContextValues, Context)
|
|
|
|
// -- context, mandatory blob --------------------------------------------------
|
|
MGP_WIRE_BLOB(CreateRenderState, MGPRenderStateDesc, Blob)
|
|
MGP_WIRE_BLOB(CreateVertexElements, MGPVertexElements, Blob)
|
|
MGP_WIRE_BLOB(CreateSamplerState, MGPSamplerDesc, Parameters)
|
|
MGP_WIRE_BLOB(SetGlobalConstants, MGPGlobalConstants, Blob)
|
|
|
|
// -- context, variable tail ---------------------------------------------------
|
|
MGP_WIRE_TAIL(SetVertexBuffers, MGPVertexBuffers, MGPVertexBuffer)
|
|
MGP_WIRE_TAIL(SetSamplerViews, MGPSamplerViews, MGPBoundView)
|
|
MGP_WIRE_TAIL(BindSamplerStates, MGPSamplerStates, MGPipeHandle)
|
|
MGP_WIRE_TAIL(SetShaderImages, MGPShaderImages, MGPImageView)
|
|
// P5e (sb, CONTRACT-P5E.md §5.6): the indexed buffer binding points, one record per
|
|
// class. The generic tail wrapper carries the FIRST tail only, which is the whole of
|
|
// the record on Espryt - the optional MGHostSpan tail exists for kCapNeedsHostUboBytes
|
|
// and that bit is 0 for the whole of P5.
|
|
MGP_WIRE_TAIL(SetShaderBuffers, MGPShaderBuffers, MGPBufferRange)
|
|
MGP_WIRE_TAIL(SetVertexAttribDefaults, MGPVertexAttribDefaults, MGPAttribValue)
|
|
|
|
#undef MGP_WIRE_PLAIN
|
|
#undef MGP_WIRE_BLOB
|
|
#undef MGP_WIRE_TAIL
|
|
|
|
// -- the rows that fit none of the three shapes --------------------------------
|
|
|
|
// set_dynamic_state. AN OPTIONAL BLOB, NOT A MANDATORY ONE (round-2 regression, the
|
|
// census's Fatal{BlobMissing, "SetDynamicState"}). EmitRenderState (PipeFill.cpp:2405-
|
|
// 2421) sends a set_dynamic_state whenever the RenderState VERSION moved, and when the
|
|
// chunk-level suppressor finds NO dynamic chunk changed it sends the 32-byte header with
|
|
// ChunkMask == 0 and blobByteCount == 0 - a version-only update, which the comment there
|
|
// calls out as deliberate. The generic MGP_WIRE_BLOB wrapper stages through StageRequired,
|
|
// which Fatals on a zero count, so a legal header-only update aborted the process with a
|
|
// blob-shape complaint that had nothing to do with the actual state of the pipe. The
|
|
// DECODER already handles it: PipeWireCodec.cpp:1571-1587 takes the `ChunkMask == 0` arm
|
|
// (CheckBlobIsHonest, no blob resolved) and applies the header alone. So the emitter is
|
|
// the only side that was wrong; it stages OPTIONALLY, exactly like the two sub-data rows.
|
|
// A non-empty mask still stages (blobByteCount > 0), so the reduced-path scenarios, whose
|
|
// first draw is freshly primed with every chunk, are byte-for-byte unchanged.
|
|
void Wire_SetDynamicState(const MG_Pipe::MGPDynamicState* payload, const void* blobBytes,
|
|
Uint64 blobByteCount) {
|
|
if (RunsAsTheServerRole()) {
|
|
MG_Pipe::MGPipeMonolithContext().SetDynamicState(payload, blobBytes, blobByteCount);
|
|
return;
|
|
}
|
|
ClientSession& session = RequireSession("SetDynamicState");
|
|
MG_Pipe::MGPDynamicState record = *payload;
|
|
record.Blob = StageOptional(session, blobBytes, blobByteCount);
|
|
session.EmitAndWait(MGPWireOp::SetDynamicState, &record, sizeof(record), nullptr, 0,
|
|
nullptr, 0, nullptr);
|
|
++g_emitted;
|
|
}
|
|
|
|
// set_residual_value_state. CONTRACT-P5 table 1 row 6: the applier takes a frontend
|
|
// `ResidualValueBlock&` and `MGPResidualValueState` is never instantiated on the live
|
|
// path, so the encoder invents BOTH the record fill and the blob fill. The block IS
|
|
// the blob, whole - the decoder requires exactly sizeof(ResidualValueBlock) and says
|
|
// why ("a size that only ever ratchets down makes a short read silently lose
|
|
// CapabilityBits"), so the two sides state the same number from the same header.
|
|
void Wire_SetResidualValueState(const MG_Pipe::MGPResidualValueState* payload,
|
|
const void* blobBytes, Uint64 blobByteCount) {
|
|
if (RunsAsTheServerRole()) {
|
|
MG_Pipe::MGPipeMonolithContext().SetResidualValueState(payload, blobBytes,
|
|
blobByteCount);
|
|
return;
|
|
}
|
|
ClientSession& session = RequireSession("SetResidualValueState");
|
|
MG_Pipe::MGPResidualValueState record = *payload;
|
|
record.Blob = StageRequired(session, "SetResidualValueState", blobBytes, blobByteCount);
|
|
session.EmitAndWait(MGPWireOp::SetResidualValueState, &record, sizeof(record), nullptr,
|
|
0, nullptr, 0, nullptr);
|
|
++g_emitted;
|
|
}
|
|
|
|
// resource_readback. kReplySlot, and the answer is COMPLETION only: the bytes travel
|
|
// server -> client in SEG_EVENT through OnBufferWriteback, because the destination is
|
|
// the client's shadow and its size is the resource's, not a slot's (CONTRACT-P5 table 1
|
|
// row 22). So the reply buffer is deliberately {nullptr, 0} and the wait is what makes
|
|
// the writeback already drained by the time this returns.
|
|
void Wire_ResourceReadback(const MG_Pipe::MGPReadback* payload, MG_Pipe::MGPReplySlot* reply) {
|
|
if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithContext().ResourceReadback(payload, reply); return; }
|
|
ClientSession& session = RequireSession("ResourceReadback");
|
|
Int32 status = 0;
|
|
const Uint64 seq = session.EmitAndWait(MGPWireOp::ResourceReadback, payload,
|
|
sizeof(*payload), nullptr, 0, nullptr, 0,
|
|
&status);
|
|
// Cancellation has no wire ordinal; retain the caller's nonzero local ticket.
|
|
if (seq != Wire::kInvalidSeq) reply->Id = seq;
|
|
MG_Pipe::MGPipePostReply(*reply, status, 0);
|
|
++g_emitted;
|
|
}
|
|
|
|
// ---- the three acceptance rows that fit a generated signature ----------------
|
|
//
|
|
// THE ANSWER IS THE SERVER'S AND NOTHING ELSE. `EmitAndWait` returns the record's seq
|
|
// and fills `status` from the reply slot the server stamped; DECLINED is `false` and OK
|
|
// is `true`, and neither is derived from anything this side knows. R-5 exists because
|
|
// "always accept" is ID-39's 66 lost DirectVulkan uploads and "accept if we emitted" is
|
|
// the same bug wearing a counter.
|
|
|
|
// ---- P12: the CREATE WINDOW, AND WHY IT IS OFF ----------------------------------------
|
|
//
|
|
// THE IDEA. The create row is the last per-record round trip on the load path (4,536 of one
|
|
// frame's 4,543 reply waits) and the one row whose answer is not optional: PublishCreate
|
|
// latches "the applier holds this handle" on it, and that latch is what every converted row
|
|
// reads. So this row would not STOP reading its answer - it would DEFER it. Up to
|
|
// MOBILEGL_IPC_CREATE_WINDOW creates go out fire-and-forget, the caller reads a provisional
|
|
// accept (so the latch is taken), and the answers are collected IN ORDER: the oldest blocks
|
|
// when the window is full, and one that comes back DECLINED un-latches its object
|
|
// (MGPipeNoteHandleUnpublished), which sends every later row for that object back down the
|
|
// blocking path.
|
|
//
|
|
// IT DOES NOT WORK, AND NOT FOR A REASON THAT TUNING REACHES. MEASURED on the device (the
|
|
// same frame, the same binary, the same 101,421,396 B staged, axe aikjs4s8cys8ba7l):
|
|
//
|
|
// MOBILEGL_IPC_CREATE_WINDOW=4 by_op 2 = 4,536 wall 28.64 s
|
|
// MOBILEGL_IPC_CREATE_WINDOW=1 by_op 2 = 4,536 wall 28.64 s
|
|
// (the handoff's own pre-window baseline was 31.3 s on this device)
|
|
//
|
|
// by_op 2 counts the creates that took the BLOCKING path, and it did not move, so no create
|
|
// deferred. A temporary counter in the row said why: pushed=24, blocked=4,476, took=20,
|
|
// readfail=8,951, pending pinned at 4. The drain READ the window's answers back and failed
|
|
// every time.
|
|
//
|
|
// ---- AND THE DIAGNOSIS BELOW WAS HALF WRONG; READ THIS FIRST ----------------------
|
|
//
|
|
// The arithmetic that follows is real and the no-reply bit it motivated WORKS (measured:
|
|
// the server skipped 50,001 answers and wrote 4,521, cutting a frame's replies by 92%).
|
|
// IT DID NOT FIX THE WINDOW: the drain still read back nothing (readfail 8,951 -> 8,872,
|
|
// -0.9%). The binding constraint is not how fast the bounded buffer is overwritten but
|
|
// that the drain is STRICTLY FIFO AND HAS NO WAY OUT OF A FAILED READ: it retries the
|
|
// same head for ever. Measured on the device (P12miss): `want=310` on every miss while
|
|
// `replySeq` climbed from 1,504 to 102,915 - one answer lost to a bounded buffer wedges
|
|
// the window permanently, and with 4,536 deferred answers that is a certainty rather
|
|
// than a risk. notes/p12/CREATE-WINDOW-MEASURED.md section 8 has the numbers and the
|
|
// shape of the fix (abandon the head: un-latch the object, add it to the suspect list,
|
|
// and pop - the DECLINE path reused for "unknown").
|
|
//
|
|
// WHY, IN THE PROTOCOL'S OWN ARITHMETIC. An answer is readable only until its reply-pool
|
|
// slot is reused, and the pool is kDefaultReplySlotCount = 8 slots deep. The frame posts a
|
|
// reply for EVERY reply-owning record - not only for the rows that wait, because the server
|
|
// cannot know which those are (ClientSession.cpp:1897-1900: "The server posts either way")
|
|
// - and that is 55,903 replies in this one frame (op 4 ResourceDestroy is NOT counted: its
|
|
// catalogue flags are kNone, so PostReply would Fatal on it and it never answers):
|
|
//
|
|
// create 4,536 + respecify 20,633 + params 18,056 + sub-data 12,671 + 3,770 + 6 + 1
|
|
//
|
|
// 8 slots against 55,903 replies is a pool that churns 6,988 times per frame, i.e. 12.3
|
|
// replies between two creates: an answer posted at create N is overwritten after EIGHT
|
|
// replies, which is 0.65 of a create. There is no window depth for which the deferred
|
|
// answer still exists when the client comes back for it - the deferral is not slow, it is
|
|
// UNREADABLE. (A 1-deep "window" is the blocking path, which is what the row does.)
|
|
//
|
|
// AND THE FAILURE IS NOT NEUTRAL: with the window on, the provisional accept latches an
|
|
// object the applier may have refused, and the un-latch that would correct it lives in the
|
|
// drain - the same drain that cannot read. A refusal therefore becomes an object the client
|
|
// believes is published, which is exactly the silent divergence the suspect list exists to
|
|
// prevent. So the default is 1: the row is the pre-window shape unless someone sets
|
|
// MOBILEGL_IPC_CREATE_WINDOW explicitly, and the machinery below is kept as the record of
|
|
// what was tried and what it cost (a fix would have to make a deferred answer outlive
|
|
// 12.3 intervening replies, which means a deeper pool, or NOT sending the ~51,000 answers
|
|
// nobody will read - and that second one needs its predicate stated exactly, because
|
|
// "fire-and-forget" is the WRONG one: the window's own creates are fire-and-forget and
|
|
// the drain above reads them, so a bit keyed on wantReply disables the mechanism it is
|
|
// meant to enable. The bit has to mean "this answer will never be read" - which is true
|
|
// of respecify/params/sub-data and false of a windowed create. See
|
|
// docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md section 7, reviewed).
|
|
//
|
|
// The gates that pin it are in MG_Test/Wire/RemoteClientControls.inc, and they still hold:
|
|
// the window is a coherent mechanism, it is simply unusable on a frame with this much
|
|
// other reply traffic.
|
|
constexpr Uint32 kCreateWindowMax = 4;
|
|
// THE WINDOW MAY NOT OWN MORE THAN HALF THE POOL, and this is where that stops being a
|
|
// sentence in a comment. The pool is kDefaultReplySlotCount slots addressed seq % count, so
|
|
// an answer is readable only while its slot has not been reused; every other reply-owning
|
|
// row - fence, query, and the blocking rows themselves - needs a slot too. The change the
|
|
// handoff names as the one worth pinning ("someone raises kCreateWindowMax to 8 and steps on
|
|
// the pool") goes red HERE, by name, at compile time, instead of as a wrong picture on a
|
|
// device. Raised to 8 it would take the whole pool and leave nothing for the answer the
|
|
// blocking rows are waiting on.
|
|
static_assert(kCreateWindowMax <= Transport::kDefaultReplySlotCount / 2,
|
|
"the create window may hold at most half the reply pool: the other half "
|
|
"belongs to the rows that take their answers immediately");
|
|
// AND THE ENVIRONMENT MAY NOT RAISE IT EITHER. The configured value is clamped to the
|
|
// array's own size, so the fixed array below cannot be overrun by a config bound and this
|
|
// constant having drifted apart. 1 (and anything below it) is the pre-window shape.
|
|
Uint32 CreateWindowEffective() {
|
|
if (MG_Config::Ipc.CreateWindow <= 1) return 1;
|
|
return MG_Config::Ipc.CreateWindow < kCreateWindowMax ? MG_Config::Ipc.CreateWindow
|
|
: kCreateWindowMax;
|
|
}
|
|
struct PendingCreate {
|
|
Uint64 Seq = 0;
|
|
MG_Pipe::MGPipeKind Kind = MG_Pipe::MGPipeKind::None;
|
|
MG_Pipe::MGPipeHandle Handle{};
|
|
// P12: ClientSession::ReplyPostings() at the moment this entry was pushed. The
|
|
// difference between it and the live count is how many answers could have displaced
|
|
// this one - see the residency budget below.
|
|
Uint64 ReplyStamp = 0;
|
|
};
|
|
PendingCreate g_createWindow[kCreateWindowMax];
|
|
Uint32 g_createWindowHead = 0;
|
|
Uint32 g_createWindowCount = 0;
|
|
Uint64 g_createRefusals = 0;
|
|
// How many deferred answers the window has actually TAKEN BACK, which is the one number
|
|
// that says the deferral worked rather than merely happened. It is not inferable from the
|
|
// others: `pending` staying at the ceiling is consistent BOTH with a drain that collects
|
|
// one per create and with a drain that collects nothing while a server-skip keeps the
|
|
// answers from existing at all - the second is what a no-reply bit keyed on wantReply
|
|
// would produce, and it would leave every other reading green.
|
|
Uint64 g_createWindowTaken = 0;
|
|
Uint64 g_createWindowBudgetStops = 0;
|
|
|
|
// A HANDLE WHOSE CREATE CAME BACK DECLINED IS NEVER WINDOWED AGAIN. Without this the window
|
|
// would UNDO the repair it exists to keep: every later use of the object sends another create,
|
|
// reads another provisional accept, and latches the object as published again - so a server
|
|
// that keeps refusing (the consumer-gate asymmetry the audit found, or any bug) would be fed
|
|
// fire-and-forget records for ever with the client believing all of them landed. With it, a
|
|
// refused object leaves the window and its next create is BLOCKING, so the refusal reaches the
|
|
// caller, the latch stays clear, and every converted row for that object is back on the loud
|
|
// blocking path. The window is for objects that have never been refused.
|
|
//
|
|
// BOUNDED, AND THE BOUND IS THE SAFE DIRECTION: past kCreateSuspectMax suspects the window
|
|
// switches itself off entirely, which is the pre-window shape.
|
|
constexpr SizeT kCreateSuspectMax = 256;
|
|
Vector<MG_Pipe::MGPipeHandle> g_createSuspects;
|
|
|
|
Bool CreateIsSuspect(MG_Pipe::MGPipeHandle handle) {
|
|
for (const MG_Pipe::MGPipeHandle& suspect : g_createSuspects) {
|
|
if (suspect.Slot == handle.Slot && suspect.Gen == handle.Gen) return true;
|
|
}
|
|
return false;
|
|
}
|
|
void NoteCreateSuspect(MG_Pipe::MGPipeHandle handle) {
|
|
if (CreateIsSuspect(handle) || g_createSuspects.size() >= kCreateSuspectMax) return;
|
|
g_createSuspects.push_back(handle);
|
|
}
|
|
void ClearCreateSuspect(MG_Pipe::MGPipeHandle handle) {
|
|
for (SizeT i = 0; i < g_createSuspects.size(); ++i) {
|
|
if (g_createSuspects[i].Slot != handle.Slot || g_createSuspects[i].Gen != handle.Gen)
|
|
continue;
|
|
for (SizeT j = i + 1; j < g_createSuspects.size(); ++j)
|
|
g_createSuspects[j - 1] = g_createSuspects[j];
|
|
g_createSuspects.pop_back();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Which publication latch an object's create owns. The three resource kinds are spelled out
|
|
// rather than defaulted, so a target added later cannot silently borrow the texture latch.
|
|
MG_Pipe::MGPipeKind CreateRecordKind(const MG_Pipe::MGPResourceDesc& desc) {
|
|
switch (static_cast<MG_Pipe::MGPipeResourceTarget>(desc.Target)) {
|
|
case MG_Pipe::MGPipeResourceTarget::Buffer: return MG_Pipe::MGPipeKind::Buffer;
|
|
case MG_Pipe::MGPipeResourceTarget::Renderbuffer: return MG_Pipe::MGPipeKind::Renderbuffer;
|
|
default: return MG_Pipe::MGPipeKind::Texture;
|
|
}
|
|
}
|
|
|
|
// Collect every answer that is already available. `blockForOldest` is the window being full,
|
|
// which is the one moment an answer is worth waiting for - appliedSeq is monotone, so taking
|
|
// the oldest is what frees the slot the next create needs. FIFO throughout: an unapplied
|
|
// oldest means none of the rest are ready either.
|
|
void DrainCreateWindow(ClientSession& session, Bool blockForOldest) {
|
|
Bool blocking = blockForOldest;
|
|
while (g_createWindowCount != 0) {
|
|
const PendingCreate head = g_createWindow[g_createWindowHead];
|
|
// THE WAIT THIS ROW PAYS IS PAID HERE (P12), so the metric has to see it HERE.
|
|
// Without this, by_op 2 counts only the fallback emissions and a WORKING window
|
|
// reads as "create stopped waiting" when the row had merely moved the waiting
|
|
// into this loop - the false green the design review named. Counted for the
|
|
// blocking take only: the poll is not a round trip, and it is the blocking take
|
|
// that is the thing MOBILEGL_IPC_CREATE_WINDOW divides (4,536 -> creates/window).
|
|
const Uint64 metricStart =
|
|
blocking ? Transport::LinkMetricsBeginReply(
|
|
true, static_cast<std::uint32_t>(MGPWireOp::ResourceCreate))
|
|
: 0;
|
|
const Transport::SessionWait wait =
|
|
session.WaitForApplied(head.Seq, blocking ? 120000u : 0u);
|
|
if (blocking) Transport::LinkMetricsReplyApplied(metricStart);
|
|
if (wait != Transport::SessionWait::Reached) return;
|
|
Int32 status = Wire::ReplySink::kStatusError;
|
|
Uint64 size = 0;
|
|
// AN UNREADABLE SLOT IS NOT AN ERROR: ReadReply refuses when the stamp does not
|
|
// match yet (or when the answer does not fit the scratch), and treating that as
|
|
// ERROR would invent a transport fault out of a reader that arrived early. The
|
|
// entry stays in the window and is taken at the next drain.
|
|
if (!session.ReadReply(head.Seq, nullptr, 0, &status, &size)) return;
|
|
// ERROR IS NOT A DECLINE (M4 / R-5), and a deferred answer does not change that:
|
|
// the same named Fatal the blocking take raises.
|
|
if (status == Wire::ReplySink::kStatusError) {
|
|
SessionFail(MGFatalFamily::ReplyError, "MGPipe: Fatal{ReplyError, \"resource_create\"} - the row answered "
|
|
"ERROR, which is not an acceptance answer; folding it into accepted or "
|
|
"refused would make a transport fault look like a resource decision");
|
|
}
|
|
if (status == Wire::ReplySink::kStatusDeclined) {
|
|
MG_Pipe::MGPipeNoteHandleUnpublished(head.Kind, head.Handle);
|
|
NoteCreateSuspect(head.Handle);
|
|
++g_createRefusals;
|
|
} else {
|
|
// It landed: the object is published for real, so it is no longer suspect.
|
|
ClearCreateSuspect(head.Handle);
|
|
}
|
|
g_createWindowHead = (g_createWindowHead + 1) % kCreateWindowMax;
|
|
--g_createWindowCount;
|
|
++g_createWindowTaken;
|
|
blocking = false;
|
|
}
|
|
}
|
|
// THE PRE-WINDOW SHAPE, AND THE HEAL BESIDE IT. Every path that cannot defer takes this
|
|
// one: the window off, the object already refused, the suspect list full, and the window
|
|
// still full after a blocking drain. The record waits and its caller reads the REAL
|
|
// answer.
|
|
//
|
|
// AND AN ACCEPTED CREATE TAKES ITS OBJECT BACK OUT OF THE SUSPECT LIST. "Never windowed
|
|
// again" is the answer to a REFUSAL, and a blocking create that landed means the refusal
|
|
// is over - the object is published, by the caller's own latch, on the answer this call
|
|
// just read. Leaving the entry standing would retire the object from the window for the
|
|
// rest of the process, and kCreateSuspectMax such recoveries would retire the WINDOW
|
|
// itself, which is the one way this row's speed-up can be lost with nothing going wrong.
|
|
void EmitCreateBlocking(ClientSession& session, const MG_Pipe::MGPResourceDesc* payload,
|
|
MG_Pipe::MGPReplySlot* reply) {
|
|
Int32 status = Wire::ReplySink::kStatusError;
|
|
const Uint64 seq = session.EmitAndWait(MGPWireOp::ResourceCreate, payload,
|
|
sizeof(*payload), nullptr, 0, nullptr, 0,
|
|
&status);
|
|
// Cancellation has no wire ordinal; retain the caller's nonzero local ticket.
|
|
if (seq != Wire::kInvalidSeq) reply->Id = seq;
|
|
MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u);
|
|
++g_emitted;
|
|
if (status == 1) ++g_declined;
|
|
if (status == 0) ClearCreateSuspect(payload->Resource);
|
|
}
|
|
// ---- P12: THE RESIDENCY BUDGET --------------------------------------------------------
|
|
//
|
|
// A deferred answer is readable only while the reply buffer has not reused its slot, and
|
|
// that buffer is bounded by construction: kDefaultReplySlotCount slots in the shared pool,
|
|
// and the stream link's ring is that same depth (StreamLink.cpp's kReplyRing, whose
|
|
// comment says why). So the window may defer only an answer it can still read back, and
|
|
// what it measures that against is HOW MANY REPLY-OWNING RECORDS IT HAS EMITTED since the
|
|
// oldest entry was pushed - an upper bound on the answers that could have displaced it.
|
|
//
|
|
// ONE LESS THAN THE BUFFER, because the eighth newer answer is the one that displaces it.
|
|
//
|
|
// THIS IS PREVENTION, NOT REPAIR, AND THAT IS THE WHOLE POINT. The alternative - read the
|
|
// head, and when the read fails call the object unknown - UN-LATCHES a create that very
|
|
// probably SUCCEEDED, and the server's create starts that object's record over
|
|
// (PipeApply.cpp:2033: `*record = MGPipeResourceRecord{}`), PendingUploads included
|
|
// (PipeApply.h:332); the client cleared its dirty flags at EMISSION (PipeApply.h:298-304),
|
|
// so nothing re-sends those texels. Silent content loss for a healthy object. An answer
|
|
// read inside its residency never has to be given up on at all.
|
|
// ---- MEASURED: NECESSARY BUT NOT SUFFICIENT, AND THE CLOCK IS WHY ---------------------
|
|
//
|
|
// On the device this fires on essentially every create (budget=4,451 of 4,501 calls) and
|
|
// the drain still reads nothing back (taken=63, readfail=8,872, by_op 2 = 9,071, wall
|
|
// 27.9 s). The clock is wrong, and the reason is structural rather than a bad constant:
|
|
//
|
|
// * THE WINDOW ONLY GETS CONTROL WHEN A CREATE ARRIVES. Between two creates this client
|
|
// emits on the order of a thousand records (measured: at the FIRST failed read the
|
|
// reply ring already spanned [1175..1504] while the head was seq 310), and nothing
|
|
// looks at the window during that gap.
|
|
// * WHAT DISPLACES A DEFERRED ANSWER IS NOT WHAT THIS CLIENT EMITS, IT IS WHAT THE
|
|
// SERVER ANSWERS. The server is behind - one answer per ~5 ms wakeup while the client
|
|
// runs ahead - so the replies arriving during the gap are its LATE ANSWERS TO THE
|
|
// EARLIER CREATES, and they fill the 8-slot buffer before the client comes back.
|
|
// * SO THIS CHECK FIRES TOO LATE BY CONSTRUCTION: it is consulted at the next create,
|
|
// and by then the answer is already gone. Tightening the constant does not fix that;
|
|
// it only removes the deferral entirely, which is what `pushed=65` is.
|
|
//
|
|
// A DEFERRAL IS THEREFORE READABLE ONLY IF THE CLIENT KEEPS THE ANSWERS IT DEFERRED. The
|
|
// reply buffer is bounded by COUNT (8) and the client cannot bound how many answers the
|
|
// server will post between two of its creates, so hoping the buffer still holds one is not
|
|
// a policy - it is a race the client loses. The shape that can work is RETENTION: the
|
|
// reader keeps the answers for the seqs still outstanding (a handful), drops them when
|
|
// they are read, and reports a LOSS when a read asks for one it no longer has. That is a
|
|
// transport change (StreamLink's ring becomes a retention map with a cap and a lost
|
|
// counter). Kept as-is because it is the safe direction - it degrades to the pre-window
|
|
// shape - and because the clock it needs is the one ReplyPostings() already provides.
|
|
constexpr Uint64 kCreateReplyResidency = Transport::kDefaultReplySlotCount - 1;
|
|
Bool CreateWindowBudgetSpent(ClientSession& session) {
|
|
if (g_createWindowCount == 0) return false;
|
|
return session.ReplyPostings() - g_createWindow[g_createWindowHead].ReplyStamp >=
|
|
kCreateReplyResidency;
|
|
}
|
|
void Wire_ResourceCreate(const MG_Pipe::MGPResourceDesc* payload, MG_Pipe::MGPReplySlot* reply) {
|
|
if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithScreen().ResourceCreate(payload, reply); return; }
|
|
ClientSession& session = RequireSession("ResourceCreate");
|
|
const Uint32 window = CreateWindowEffective();
|
|
// THE WINDOW OFF RESTORES THE OLD SHAPE EXACTLY, and is the negative control for it.
|
|
// THE WINDOW OFF, THE SUSPECT LIST FULL, OR AN OBJECT THAT HAS ALREADY BEEN REFUSED:
|
|
// all four take the blocking branch, which is the pre-window shape - and on the device
|
|
// this is the branch the whole row takes, because the deferral above it cannot be
|
|
// collected. See the section comment for the measurement and the arithmetic.
|
|
if (window <= 1 || g_createSuspects.size() >= kCreateSuspectMax ||
|
|
CreateIsSuspect(payload->Resource)) {
|
|
EmitCreateBlocking(session, payload, reply);
|
|
return;
|
|
}
|
|
// Take what is already answerable, and if the window is full take the oldest for real.
|
|
// The poll is not politeness: it is what keeps a burst shorter than the window from
|
|
// leaving its answers unread until the next burst.
|
|
DrainCreateWindow(session, /*blockForOldest=*/false);
|
|
// A SPENT BUDGET IS TREATED EXACTLY LIKE A FULL WINDOW: take the oldest answer back
|
|
// NOW, while it still exists, instead of deferring another one past its residency.
|
|
const Bool budgetSpent = CreateWindowBudgetSpent(session);
|
|
if (g_createWindowCount >= window || budgetSpent) {
|
|
if (budgetSpent) ++g_createWindowBudgetStops;
|
|
DrainCreateWindow(session, /*blockForOldest=*/true);
|
|
// IF IT IS STILL FULL the oldest answer is not readable yet, and the window is a
|
|
// fixed array: pushing would silently OVERWRITE a confirmation. This record takes
|
|
// the blocking path instead - slower for this one call, never lossy. Reaching it
|
|
// at all means the oldest answer did not arrive inside the drain's own budget, so
|
|
// the session is on its way out; it is a net, not a hot path. The comparison is
|
|
// against the effective window, which is what the array below is sized for, so
|
|
// this line IS the overwrite guard.
|
|
if (g_createWindowCount >= window) {
|
|
EmitCreateBlocking(session, payload, reply);
|
|
return;
|
|
}
|
|
}
|
|
Int32 status = Wire::ReplySink::kStatusError;
|
|
// willReadReply IS TRUE AND THAT IS THE WHOLE POINT OF THE WINDOW: this record does
|
|
// not wait for its answer, and the drain above reads it later. A P12 no-reply bit
|
|
// keyed on wantReply would tell the server to skip the very answer this row comes
|
|
// back for (docs/.../p12/CREATE-WINDOW-MEASURED.md section 7).
|
|
const Uint64 seq = session.EmitAndWaitTails(
|
|
MGPWireOp::ResourceCreate, payload, sizeof(*payload), nullptr, 0, nullptr, 0,
|
|
&status, nullptr, /*wantReply=*/false, /*willReadReply=*/true);
|
|
if (seq == Wire::kInvalidSeq) {
|
|
// Cancelled: there is no ordinal to read an answer from, and the caller must NOT
|
|
// latch an object the record never described.
|
|
MG_Pipe::MGPipePostReply(*reply, Wire::ReplySink::kStatusDeclined, 0u);
|
|
++g_emitted;
|
|
++g_declined;
|
|
return;
|
|
}
|
|
g_createWindow[(g_createWindowHead + g_createWindowCount) % kCreateWindowMax] =
|
|
PendingCreate{seq, CreateRecordKind(*payload), payload->Resource,
|
|
session.ReplyPostings()};
|
|
++g_createWindowCount;
|
|
reply->Id = seq;
|
|
MG_Pipe::MGPipePostReply(*reply, Wire::ReplySink::kStatusOk, 1u);
|
|
++g_emitted;
|
|
}
|
|
|
|
// A PARAMETER CHANGE FOR AN OBJECT WHOSE EXISTENCE IS CONFIRMED OWES NO ANSWER (P12).
|
|
//
|
|
// The acceptance this row used to wait for says one thing: "the applier holds a record for
|
|
// this handle". That is a fact about the SERVER's table, and the client already keeps its
|
|
// own copy of exactly that fact - MGPipeHandleIsPublished, set when the object's create was
|
|
// ACCEPTED and never cleared while the record lives (the applier keeps texture records
|
|
// across a make-current: PipeFill.cpp:3756-3759). So the wait is asking the server a
|
|
// question the client has already answered.
|
|
//
|
|
// WHAT REPLACES IT, on the device run's numbers: this row paid 18,056 of one frame's
|
|
// 43,232 round trips at ~5 ms each, for records carrying a few hundred bytes. The object's
|
|
// ONE confirmation is its create, paid at its first use (the emitter sends it proactively
|
|
// when the latch is clear - EmitTextureParams' HealUnpublishedRecord); every parameter
|
|
// record after that is emitted fire-and-forget and the caller reads a PROVISIONAL accept,
|
|
// exactly as resource_subdata has done since item B.
|
|
//
|
|
// AND WHEN THE LATCH IS CLEAR THIS ROW STILL WAITS, which is the whole fallback: an object
|
|
// whose create was refused stays unpublished, so its next parameter record takes the real
|
|
// answer and the emitter's existing one-retry heal runs. A refusal can only reach here
|
|
// for an object the client thinks it never published, or for a bug - the two cases that
|
|
// were already the only ones the per-record wait could name.
|
|
void Wire_SetTextureParams(const MG_Pipe::MGPTextureParams* payload, MG_Pipe::MGPReplySlot* reply) {
|
|
if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithContext().SetTextureParams(payload, reply); return; }
|
|
ClientSession& session = RequireSession("SetTextureParams");
|
|
const Bool confirmed = MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture,
|
|
payload->Res);
|
|
Int32 status = Wire::ReplySink::kStatusError;
|
|
Uint64 seq = Wire::kInvalidSeq;
|
|
if (confirmed) {
|
|
seq = session.EmitAndWaitTails(MGPWireOp::SetTextureParams, payload, sizeof(*payload),
|
|
nullptr, 0, nullptr, 0, &status, nullptr,
|
|
/*wantReply=*/false, /*willReadReply=*/false);
|
|
status = seq == Wire::kInvalidSeq ? Wire::ReplySink::kStatusDeclined
|
|
: Wire::ReplySink::kStatusOk;
|
|
} else {
|
|
seq = session.EmitAndWait(MGPWireOp::SetTextureParams, payload, sizeof(*payload),
|
|
nullptr, 0, nullptr, 0, &status);
|
|
}
|
|
// Cancellation has no wire ordinal; retain the caller's nonzero local ticket.
|
|
if (seq != Wire::kInvalidSeq) reply->Id = seq;
|
|
MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u);
|
|
++g_emitted;
|
|
if (status == 1) ++g_declined;
|
|
}
|
|
|
|
void Wire_ResourceSubData(const MG_Pipe::MGPSubData* payload, const void* blobBytes,
|
|
Uint64 blobByteCount, const void* varTail, Uint32 varTailCount,
|
|
MG_Pipe::MGPReplySlot* reply) {
|
|
if (RunsAsTheServerRole()) {
|
|
MG_Pipe::MGPipeMonolithContext().ResourceSubData(payload, blobBytes, blobByteCount,
|
|
varTail, varTailCount, reply);
|
|
return;
|
|
}
|
|
ClientSession& session = RequireSession("ResourceSubData");
|
|
MG_Pipe::MGPSubData record = *payload;
|
|
record.Blob = StageOptional(session, blobBytes, blobByteCount);
|
|
// P5e (ra, CONTRACT-P5E §2.5 / ruling 15): the BUFFER half does not want its
|
|
// answer, and MGPipeSubDataWantsItsReply is the one place that decides - the same
|
|
// function the route reads, so the two halves of this call cannot disagree about
|
|
// whether a wait is owed. Under run-ahead the record is published and this thread
|
|
// returns; the persistent-map push (PersistentMapTracker's 64 KB blocks) stops
|
|
// being a hidden round trip per block, which is the single biggest wait left on
|
|
// the steady path that is not a draw.
|
|
//
|
|
// THE ANSWER IS ACCEPT-BY-CONSTRUCTION, AND THAT IS HONEST HERE AND NOWHERE ELSE:
|
|
// the only caller discards it (PipeFill.cpp's MGPipeEmitResourceSubData), so
|
|
// "accepted" is not a re-derivation of a server decision - it is the absence of a
|
|
// question. The server still posts the real answer into the slot; nothing reads
|
|
// it, which ReplySlot.h:16, 105 makes legal. A row whose acceptance a caller USES
|
|
// may never take this path - R-5 has not moved.
|
|
const Bool wantReply = MG_Pipe::MGPipeSubDataWantsItsReply(record);
|
|
Int32 status = 0;
|
|
const Wire::WireTail tail{varTail, static_cast<Uint64>(varTailCount) *
|
|
sizeof(MG_Pipe::MGPSubRegion)};
|
|
const Uint64 seq = session.EmitAndWaitTails(
|
|
MGPWireOp::ResourceSubData, &record, sizeof(record),
|
|
varTail != nullptr ? &tail : nullptr, varTail != nullptr ? 1u : 0u, nullptr, 0,
|
|
&status, nullptr, wantReply,
|
|
// ONE ANSWER TO BOTH QUESTIONS, and on this row that is not a shortcut: the
|
|
// wire arm's MGPipeSubDataWantsItsReply is false for the whole texture half
|
|
// (PipeRoute.h), the caller discards the Bool either way, and the record's own
|
|
// comment above already says "nothing reads it". A buffer-half caller that DID
|
|
// read it would pass wantReply=true and get its answer.
|
|
wantReply);
|
|
// Cancellation has no wire ordinal; retain the caller's nonzero local ticket.
|
|
if (seq != Wire::kInvalidSeq) reply->Id = seq;
|
|
const Int32 postedStatus = (seq == Wire::kInvalidSeq || status == Wire::ReplySink::kStatusDeclined)
|
|
? Wire::ReplySink::kStatusDeclined
|
|
: wantReply ? status : Wire::ReplySink::kStatusOk;
|
|
MG_Pipe::MGPipePostReply(*reply, postedStatus, postedStatus == Wire::ReplySink::kStatusOk ? 1u : 0u);
|
|
if (seq != Wire::kInvalidSeq) ++g_emitted;
|
|
if (postedStatus == Wire::ReplySink::kStatusDeclined) ++g_declined;
|
|
}
|
|
|
|
void Wire_BufferSubDataResident(const MG_Pipe::MGPSubData* payload, const void* blobBytes,
|
|
Uint64 blobByteCount) {
|
|
if (RunsAsTheServerRole()) {
|
|
MG_Pipe::MGPipeMonolithContext().BufferSubDataResident(payload, blobBytes,
|
|
blobByteCount);
|
|
return;
|
|
}
|
|
ClientSession& session = RequireSession("BufferSubDataResident");
|
|
MG_Pipe::MGPSubData record = *payload;
|
|
record.Blob = StageOptional(session, blobBytes, blobByteCount);
|
|
session.EmitAndWait(MGPWireOp::BufferSubDataResident, &record, sizeof(record), nullptr,
|
|
0, nullptr, 0, nullptr);
|
|
++g_emitted;
|
|
}
|
|
|
|
// ---- the four escapes --------------------------------------------------------
|
|
|
|
// resource_respecify. R-13.3: `initialBytes` is ALWAYS nullptr under split and the
|
|
// initial content arrives as resource_subdata records immediately after this one. The
|
|
// caller's bytes are therefore not dropped - they are re-expressed - and PipeFill.cpp's
|
|
// emitter is where that happens, because the chunking walk that has to size them
|
|
// (MGPipeForEachSubDataRecordRange) lives there. What this emitter owes is the REFUSAL:
|
|
// a non-null pointer arriving here means the call site was not converted, and silently
|
|
// ignoring it would lose exactly the bytes R-13.3 promised would follow.
|
|
Bool Wire_Escape_ResourceRespecify(const MG_Pipe::MGPResourceDesc* desc,
|
|
const void* initialBytes,
|
|
const MG_Pipe::MGPRespecifiedLevel* level) {
|
|
if (RunsAsTheServerRole()) {
|
|
return MG_Pipe::MGPipeMonolithEscapes().ResourceRespecify(desc, initialBytes, level);
|
|
}
|
|
ClientSession& session = RequireSession("ResourceRespecify");
|
|
if (initialBytes != nullptr) {
|
|
SessionFail(MGFatalFamily::UncarriedInitialBytes, "MGPipe: Fatal{UncarriedInitialBytes, \"resource_respecify\"} - a call "
|
|
"site handed initial content to a split respecify. R-13.3 rules that "
|
|
"initialBytes never crosses and that the content follows as "
|
|
"resource_subdata; a caller that still passes it has bytes nothing will "
|
|
"carry");
|
|
}
|
|
// Scope and exact mutable mip extent travel together through the descriptor helper.
|
|
// It temporarily reuses BufOffset/BufSize on non-buffer image targets; the server
|
|
// checks and clears that carrier before persisting the resource descriptor.
|
|
MG_Pipe::MGPResourceDesc record = *desc;
|
|
if (level != nullptr) {
|
|
#if MOBILEGL_BUILD_DISAGGREGATED
|
|
MG_Pipe::MGPipeSetRespecifiedLevel(record, level->UploadTarget, level->Level,
|
|
level->Width, level->Height, level->Depth);
|
|
#else
|
|
MG_Pipe::MGPipeSetRespecifiedLevel(record, level->UploadTarget, level->Level);
|
|
#endif
|
|
} else {
|
|
MG_Pipe::MGPipeClearRespecifiedLevel(record);
|
|
}
|
|
// P12: WHETHER THIS RECORD OWES AN ANSWER IS ONE PREDICATE, and it lives beside the
|
|
// route (PipeRoute.h, MGPipeResourceRespecifyWantsItsReply) so the emitter and the wire
|
|
// cannot disagree about it. The two halves are argued there; the short version is that the
|
|
// buffer half's Bool is discarded at both call sites, and the texture half's is owed
|
|
// until the object's create has been accepted - the confirmation it pays at first use.
|
|
//
|
|
// WHAT A PROVISIONAL ACCEPT DOES TO THE TEXTURE HALF, stated here because it is a
|
|
// deliberate trade rather than an oversight: the emitter's acceptance mirror
|
|
// (entry.LastDesc) now advances on an answer nobody waited for. ID-18 M3
|
|
// (TextureEmit.h:885-888) forbids exactly that for a record whose verdict is UNKNOWN -
|
|
// "the next identical call is suppressed against a record that was never stored" - and
|
|
// what makes it safe here is that the object is PUBLISHED: the client is not guessing,
|
|
// it is reading a latch the server set by accepting this handle's create. The residue
|
|
// is the one case where the server loses a record it had, which today has no production
|
|
// path (PipeApply.cpp:1593's only callers are tests), and whose guard is the
|
|
// invalidation notice the route's notes call ①.
|
|
const Bool wantsReply = MG_Pipe::MGPipeResourceRespecifyWantsItsReply(record);
|
|
Int32 status = Wire::ReplySink::kStatusError;
|
|
if (!wantsReply) {
|
|
const Uint64 seq = session.EmitAndWaitTails(
|
|
MGPWireOp::ResourceRespecify, &record, sizeof(record), nullptr, 0, nullptr, 0,
|
|
&status, nullptr, /*wantReply=*/false, /*willReadReply=*/false);
|
|
status = seq == Wire::kInvalidSeq ? Wire::ReplySink::kStatusDeclined
|
|
: Wire::ReplySink::kStatusOk;
|
|
} else {
|
|
(void)session.EmitAndWait(MGPWireOp::ResourceRespecify, &record, sizeof(record),
|
|
nullptr, 0, nullptr, 0, &status);
|
|
}
|
|
++g_emitted;
|
|
// ERROR IS NOT A DECLINE (M4 / codex 5 / R-5). status is 0 OK / 1 DECLINED / 2 ERROR;
|
|
// an escape may not fold 2 into `false`, which is what a bare `return status == 0`
|
|
// did - a transport fault then read as "the server said no". Every reply-owning row
|
|
// - the generated acceptance rows through MGPipeTakeReplyBool, and now these two
|
|
// escapes - answers ERROR with the same named Fatal.
|
|
if (status == 2) {
|
|
SessionFail(MGFatalFamily::ReplyError, "MGPipe: Fatal{ReplyError, \"resource_respecify\"} - the row answered "
|
|
"ERROR, which is not an acceptance answer; folding it into accepted or "
|
|
"refused would make a transport fault look like a resource decision");
|
|
}
|
|
if (status == 1) ++g_declined;
|
|
return status == 0;
|
|
}
|
|
|
|
// resource_flush_range. R-13.2: it carries NO bytes under split - it is a
|
|
// {range, AccessFlags} control record and the bytes of exactly that range arrive ahead
|
|
// of it as resource_subdata. Same refusal as above, for the same reason: a blobref here
|
|
// would be "a second, forgeable way to say the same thing".
|
|
void Wire_Escape_ResourceFlushRange(const MG_Pipe::MGPFlushRange* record,
|
|
const void* bytes) {
|
|
if (RunsAsTheServerRole()) {
|
|
MG_Pipe::MGPipeMonolithEscapes().ResourceFlushRange(record, bytes);
|
|
return;
|
|
}
|
|
ClientSession& session = RequireSession("ResourceFlushRange");
|
|
(void)bytes; // ruled uncarried; the emitter in PipeFill.cpp sends the range first
|
|
session.EmitAndWait(MGPWireOp::ResourceFlushRange, record, sizeof(*record), nullptr, 0,
|
|
nullptr, 0, nullptr);
|
|
++g_emitted;
|
|
}
|
|
|
|
// map_persistent. R-6/R-2.4: the split answer is a CONSTANT DECLINE, and it still costs
|
|
// a record, because the server has to know the client asked - the applier's
|
|
// MapPersistentRoundtrips counter is defined as "one per storage definition in both
|
|
// modes" and a client that answered locally would zero it. `size` and `seedBytes` have
|
|
// no carrier (MGPHandleOnly is {Handle, Kind}) and need none: nothing is minted.
|
|
void* Wire_Escape_MapPersistent(const MG_Pipe::MGPHandleOnly* handle, Uint64 size,
|
|
const void* seedBytes) {
|
|
if (RunsAsTheServerRole()) {
|
|
return MG_Pipe::MGPipeMonolithEscapes().MapPersistent(handle, size, seedBytes);
|
|
}
|
|
ClientSession& session = RequireSession("MapPersistent");
|
|
(void)size;
|
|
(void)seedBytes;
|
|
Int32 status = 0;
|
|
session.EmitAndWait(MGPWireOp::MapPersistent, handle, sizeof(*handle), nullptr, 0,
|
|
nullptr, 0, &status);
|
|
++g_emitted;
|
|
// ERROR IS NOT A DECLINE (M4 / codex 5 / R-5), the same rule the respecify escape and
|
|
// the generated acceptance rows obey: status 2 is a transport fault, and returning
|
|
// nullptr for it would make it indistinguishable from R-6's legitimate decline.
|
|
if (status == 2) {
|
|
SessionFail(MGFatalFamily::ReplyError, "MGPipe: Fatal{ReplyError, \"map_persistent\"} - the row answered ERROR, "
|
|
"which is not an acceptance answer; a transport fault is not a resource "
|
|
"decision and may not be folded into the decline R-6 predicts");
|
|
}
|
|
if (status == 1) ++g_declined;
|
|
// NOT "always nullptr": the answer is READ. R-6 says the server declines, and the
|
|
// day it stops declining this returns what it actually said rather than what the
|
|
// ruling predicted.
|
|
if (status == 0) {
|
|
SessionFail(MGFatalFamily::UnexpectedMapAccept, "MGPipe: Fatal{UnexpectedMapAccept, \"map_persistent\"} - the server "
|
|
"accepted a persistent map under split. R-6 makes the split answer a "
|
|
"constant decline because there is no way to hand a host pointer across "
|
|
"a process boundary in P5; a pointer arriving here is one this client "
|
|
"cannot dereference");
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// create_shader_state. SEVEN blobrefs and TWO typed frontend pointers; one blobBytes
|
|
// pair cannot express seven runs. The serializer already exists and no package may
|
|
// write a second one (CONTRACT-P5 table 1 row 3): EncodeProgramArtifacts produces one
|
|
// archive, the decoder's DecodeProgramArtifacts consumes it, and the six per-stage runs
|
|
// stay UNDECLARED because the modules already travel inside the archive - a declared
|
|
// Spirv[i] is Fatal on the far side rather than ignored.
|
|
void Wire_Escape_CreateShaderState(const MG_Pipe::MGPProgramDesc* desc,
|
|
const MG_State::GLState::LinkArtifacts* link,
|
|
const MG_State::GLState::SpirvArtifacts* spirv,
|
|
const Uint32* linkedStages, Uint32 linkedStageCount) {
|
|
if (RunsAsTheServerRole()) {
|
|
MG_Pipe::MGPipeMonolithEscapes().CreateShaderState(desc, link, spirv, linkedStages,
|
|
linkedStageCount);
|
|
return;
|
|
}
|
|
ClientSession& session = RequireSession("CreateShaderState");
|
|
if (link == nullptr || spirv == nullptr) {
|
|
SessionFail(MGFatalFamily::ArtefactsMissing, "MGPipe: Fatal{ArtefactsMissing, \"create_shader_state\"} - the record's "
|
|
"two typed companions are null. Under monolith the applier reads the "
|
|
"modules out of spirv->generatedSpirv; under split there is nothing to "
|
|
"serialise, and emitting the record anyway would create a CSO with no "
|
|
"code");
|
|
}
|
|
// EncodeProgramArchive APPENDS and never fails - everything it walks is owned
|
|
// plain data - so an empty archive means the two structs themselves were empty,
|
|
// which is a linked program with no artefacts and is not a codec question.
|
|
//
|
|
// P5e (pg): THE FRAMED form, so the record's own copy on the far side can pair each
|
|
// module with its stage. The bare EncodeProgramArtifacts is still what the verify
|
|
// build's round-trip pin uses; this is the one that crosses.
|
|
Vector<Uint8> archive;
|
|
Vector<Uint32> stages(linkedStages, linkedStages + linkedStageCount);
|
|
MG_State::GLState::EncodeProgramArchive(*link, *spirv, stages, archive);
|
|
if (MG_Util::PipeStats::Enabled()) {
|
|
// ID-87's measurement, taken where the bytes actually exist rather than
|
|
// estimated: one sample per link, so the steady-state cost is (links this
|
|
// frame) x (bytes per link) and both halves are countable from the stats line.
|
|
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::CsoBlobBytes,
|
|
static_cast<Uint64>(archive.size()));
|
|
}
|
|
if (archive.empty()) {
|
|
SessionFail(MGFatalFamily::ArchiveEmpty, "MGPipe: Fatal{ArchiveEmpty, \"create_shader_state\"} - the program's "
|
|
"artefacts serialised to nothing");
|
|
}
|
|
MG_Pipe::MGPProgramDesc record = *desc;
|
|
for (Uint32 i = 0; i < 6; ++i) record.Spirv[i] = MG_Pipe::MGPBlobRef{};
|
|
record.Reflection = session.Encoder().StageBytes(archive.data(), archive.size());
|
|
session.EmitAndWait(MGPWireOp::CreateShaderState, &record, sizeof(record), nullptr, 0,
|
|
nullptr, 0, nullptr);
|
|
++g_emitted;
|
|
}
|
|
|
|
// set_program_bindings (P5e, pg). THE FIFTH ESCAPE, for PipeRoute.h's reason: three
|
|
// tails in three index spaces plus a parallel name array, which no generated
|
|
// (payload, varTail, varTailCount) row can express.
|
|
//
|
|
// THE NAMES ARE STAGED ONE BY ONE and each element's own MGHostSpan names its run, so
|
|
// the third tail is self-describing the way set_storage_block_binding's single name
|
|
// already is. Staged rather than packed into one run with offsets, because the honesty
|
|
// pass the decoder runs is per span and a packed run would have to be re-split there
|
|
// against arithmetic nothing on the wire declares.
|
|
void Wire_Escape_SetProgramBindings(const MG_Pipe::MGPProgramBindings* hdr,
|
|
const Int32* blockBindings,
|
|
const MG_Pipe::MGPProgramSamplerUnit* samplerUnits,
|
|
const MG_Pipe::MGPProgramStorageOverride* storageOverrides,
|
|
const char* const* storageOverrideNames) {
|
|
if (RunsAsTheServerRole()) {
|
|
MG_Pipe::MGPipeMonolithEscapes().SetProgramBindings(hdr, blockBindings, samplerUnits,
|
|
storageOverrides,
|
|
storageOverrideNames);
|
|
return;
|
|
}
|
|
ClientSession& session = RequireSession("SetProgramBindings");
|
|
MG_Pipe::MGPProgramBindings record = *hdr;
|
|
|
|
// The override tail is COPIED before it is emitted, because the span is the one
|
|
// field the client must write after the caller is done with it - the same reason
|
|
// MGP_WIRE_BLOB copies its payload.
|
|
Vector<MG_Pipe::MGPProgramStorageOverride> overrides;
|
|
overrides.reserve(record.StorageOverrideCount);
|
|
for (Uint32 i = 0; i < record.StorageOverrideCount; ++i) {
|
|
MG_Pipe::MGPProgramStorageOverride entry = storageOverrides[i];
|
|
const char* const name = storageOverrideNames[i];
|
|
// Size = strlen + 1: THE NUL TRAVELS, exactly as set_storage_block_binding's
|
|
// name does (contract table 0's block-name row), and the decoder refuses a run
|
|
// whose last byte is not NUL.
|
|
const Uint64 nameBytes = static_cast<Uint64>(std::strlen(name)) + 1ull;
|
|
const MG_Pipe::MGPBlobRef staged = session.Encoder().StageBytes(name, nameBytes);
|
|
// A HOST SPAN AND NOT A BLOBREF, which is what the row's kHostSpan flag means:
|
|
// the same run, described in the shape the decoder's honesty pass reads. Ptr
|
|
// stays null - rule B, "the encoder writes nullptr and names SEG_STAGE" - so
|
|
// the name is unreachable on a second process except through the segment table,
|
|
// which is the whole point of the flag.
|
|
entry.Name = MG_Pipe::MGHostSpan{};
|
|
entry.Name.Ptr = nullptr;
|
|
entry.Name.Seg = staged.Seg;
|
|
entry.Name.Offset = staged.Offset;
|
|
entry.Name.Size = staged.Size;
|
|
overrides.push_back(entry);
|
|
}
|
|
|
|
const Wire::WireTail tails[3] = {
|
|
{blockBindings, static_cast<Uint64>(record.BlockBindingCount) * sizeof(Int32)},
|
|
{samplerUnits, static_cast<Uint64>(record.SamplerUnitCount) *
|
|
sizeof(MG_Pipe::MGPProgramSamplerUnit)},
|
|
{overrides.empty() ? nullptr : overrides.data(),
|
|
static_cast<Uint64>(record.StorageOverrideCount) *
|
|
sizeof(MG_Pipe::MGPProgramStorageOverride)},
|
|
};
|
|
// ALWAYS THREE, even when a count is 0 - the decoder derives the third tail's
|
|
// offset from the first two, so declaring fewer would put the same bytes somewhere
|
|
// else (PipeWireCodec.cpp's layout arm says this from the other side).
|
|
session.EmitAndWaitTails(MGPWireOp::SetProgramBindings, &record, sizeof(record), tails, 3,
|
|
nullptr, 0, nullptr);
|
|
++g_emitted;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// =====================================================================================
|
|
// P5c (ct), CONTRACT-P5C.md §5: the two control records' producers
|
|
// =====================================================================================
|
|
|
|
namespace {
|
|
// The ContextSerial of the NEXT applier_reset record. NOWHERE IN THE FRONTEND NUMBERS
|
|
// A MAKE-CURRENT: the tracker knows the EDGE (FreshlyPrimed) and no session or context
|
|
// carries a serial for it (checked at the contract commit), so the serial is this
|
|
// client's own count of applier_reset emissions - 0 for the first make-current, one
|
|
// more per primed edge. That is the only value the server can hold the client to in a
|
|
// one-context session: the sink keeps the same count and ASSERTS equality
|
|
// (CONTRACT-P5C.md §1: the value is asserted, never dispatched on; P6's multi-context
|
|
// shape is what will read it for real). GL-thread only, like every emitter here.
|
|
Uint64 g_applierResetContextSerial = 0;
|
|
} // namespace
|
|
|
|
Bool EmitApplierResetRecord() {
|
|
// NO SESSION, NO RECORD - and false rather than the RequireSession Fatal, because a
|
|
// validate can legitimately prime with a transport CONFIGURED but no live session:
|
|
// the bring-up window before ClientSession::Start() (§6 layer 2's documented
|
|
// exception), and the server-role-only shape a ServerLoop fixture drives with
|
|
// Transport=InProcess and no client at all. In both this process has no wire for the
|
|
// reset to cross, and the caller answers with the direct call the record replaced.
|
|
ClientSession* session = ClientSession::Active();
|
|
if (session == nullptr || !session->Started()) return false;
|
|
if (g_clientTablesUninstalled.load(std::memory_order_acquire)) return false;
|
|
MG_Pipe::MGPApplierReset record{};
|
|
record.ContextSerial = g_applierResetContextSerial++;
|
|
session->EmitAndWait(MGPWireOp::ApplierReset, &record, sizeof(record), nullptr, 0, nullptr,
|
|
0, nullptr);
|
|
++g_emitted;
|
|
return true;
|
|
}
|
|
|
|
// P5c (rv), CONTRACT-P5C.md §5.3. Whether set_context_values can cross RIGHT NOW: a live,
|
|
// started session whose tables are not being torn down. PipeFill.cpp gates BOTH halves of
|
|
// the row on this - the emission and the residual-fill skip - so a configured-but-wireless
|
|
// transport (the bring-up window, a server-role-only fixture) keeps the pull, and the two
|
|
// can never disagree about who supplies the eight fields.
|
|
Bool ContextValuesWireLive() {
|
|
ClientSession* session = ClientSession::Active();
|
|
return session != nullptr && session->Started() &&
|
|
!g_clientTablesUninstalled.load(std::memory_order_acquire);
|
|
}
|
|
|
|
ObjectDeathEmit EmitObjectDeathRecord(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
|
|
using MG_Pipe::MGPipeHandle;
|
|
|
|
// NO SESSION, NO RECORD - and that is NOT the RequireSession shape on purpose. A death
|
|
// notice can outlive the session by construction: frontend objects die at context
|
|
// teardown and at process exit, after Stop has joined the apply thread and freed the
|
|
// rings, and the twins this record would kill died with the server. Emitting into that
|
|
// window is a use-after-free; Fatal-ing on it is an abort at exit() for a legal death.
|
|
// NoSession is its own answer (not folded into NoHandle) because the caller's fallback
|
|
// for "no wire exists" is delivery to the server loop this process still has, while
|
|
// "no handle" means there is nothing to deliver at all.
|
|
ClientSession* session = ClientSession::Active();
|
|
if (session == nullptr || !session->Started()) return ObjectDeathEmit::NoSession;
|
|
// The same window, one step earlier: Stop has raised the teardown refusal but not yet
|
|
// joined the apply thread. A death landing here is destructor-driven, not an app bug,
|
|
// and the twin it would kill dies with the backend Stop is destroying - so it is
|
|
// skipped, where a routed call in the same window is Fatal{ClientTablesUninstalled}.
|
|
if (g_clientTablesUninstalled.load(std::memory_order_acquire)) {
|
|
return ObjectDeathEmit::NoSession;
|
|
}
|
|
|
|
// THE CLIENT'S OWN ALLOCATOR, ON THE CLIENT'S OWN THREAD (§5.2 step 1). A lifetime id
|
|
// the allocator cannot resolve names an object that never crossed - no create record
|
|
// ever carried its handle, so the server has no twin to kill and NOTHING is emitted.
|
|
// This replaces the mailbox's unconditional delivery, which hopped every death to the
|
|
// apply thread whether or not the server had ever seen the object.
|
|
const MGPipeHandle handle = MG_Pipe::MGPipeSlots().FindByLifetimeId(kind, lifetimeId);
|
|
if (MG_Pipe::MGPipeHandleIsNull(handle)) return ObjectDeathEmit::NoHandle;
|
|
|
|
MG_Pipe::MGPHandleOnly record{};
|
|
record.Handle = handle;
|
|
record.Kind = static_cast<Uint32>(kind);
|
|
// The WAIT is the only property the blocking mailbox hop provided and the only one P5c
|
|
// keeps (§5.2 step 3): it orders the death against in-flight verbs that name the
|
|
// handle, so the server's twin cannot be released underneath a verb that is using it.
|
|
session->EmitAndWait(MGPWireOp::ObjectDeath, &record, sizeof(record), nullptr, 0, nullptr,
|
|
0, nullptr);
|
|
++g_emitted;
|
|
return ObjectDeathEmit::Emitted;
|
|
}
|
|
|
|
void RequireClientTablesInstalled(const char* row) {
|
|
if (g_clientTablesUninstalled.load(std::memory_order_acquire)) {
|
|
SessionFail(MGFatalFamily::ClientTablesUninstalled, "MGPipe: Fatal{ClientTablesUninstalled, \"%s\"} - the client tables are "
|
|
"being torn down; refusing before session or ring access", row);
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
void WireStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
|
|
(void)EmitObjectDeathRecord(kind, lifetimeId);
|
|
}
|
|
|
|
const MG_State::GLState::StateObjectDeathOps kClientStateObjectDeathOps = {
|
|
.OnDestroyed = &WireStateObjectDestroyed,
|
|
};
|
|
}
|
|
|
|
void InstallClientWireTables() {
|
|
using namespace MG_Pipe;
|
|
|
|
// DirectGLES normally installs this notice while creating its handle
|
|
// backend. An independent client never creates that backend: without a
|
|
// client emitter, NotifyAndFree silently drops object_death before
|
|
// freeing the slot. Keep inproc's existing backend dispatcher, and give
|
|
// the remote client the same wire delivery without a local twin table.
|
|
//
|
|
// P7 PACKAGE L: THE BACKEND TEST IS GONE, AND ITS ABSENCE IS THE POINT. P6.5 wrote
|
|
// `ActiveBackendType == DirectGLES` because DirectGLES was the only backend with a
|
|
// two-process lane, so the condition read as "the case this can happen in" rather than
|
|
// as a policy. Under a Magma spawn or tcp client it is a silent hole of exactly the
|
|
// shape P6.5 closed for Espryt: CtWireScenario's two death cases would report
|
|
// ObjectDeaths=0 and PASS NOTHING, because no notice was ever installed and
|
|
// NotifyAndFree would drop every object_death before freeing the slot - a green that
|
|
// means "the mechanism is absent". MEASURED on this tree: with the install site
|
|
// disabled, both DirectGLES spawn death cases red on CtWireScenario.cpp:187/:246 with
|
|
// `Expected: (afterCounters.deaths) > (deathsBefore), actual: 0 vs 0`.
|
|
//
|
|
// THE REMAINING TWO CONDITIONS STILL CARRY P6.5's INTENT, both halves of it:
|
|
// * Transport == Spawn: this is the REMOTE client, the one with no local twin table.
|
|
// Under inproc the server role is a thread in this process and its backend's own
|
|
// dispatcher is the right one.
|
|
// * GetStateObjectDeathOps() == nullptr: whoever installed first keeps the notice.
|
|
// That is what "keep inproc's existing backend dispatcher" meant, and it is also
|
|
// what keeps this from stomping a backend that installs ops of its own later.
|
|
//
|
|
// MAGMA NOW HAS A DEATH TABLE OF ITS OWN (P7 wave 2 package C, CONTRACT-P7 §5.5:
|
|
// DirectVulkan.cpp's g_magmaStateObjectDeathOps), and the two installs do NOT race,
|
|
// which is worth stating because the global is a bare last-writer-wins pointer with
|
|
// no stacking. They cannot meet: Magma's is installed from
|
|
// BackendObject_DirectVulkan::Initialize(), i.e. only in a process that OWNS a
|
|
// DirectVulkan backend, and the arm here runs only under `Transport == Spawn`, which
|
|
// is by construction the process that owns NO backend at all (InitSplitRoles builds a
|
|
// BackendObject_Remote there). So the spawn client keeps this emitter, the inproc and
|
|
// server-side roles keep Magma's, and `GetStateObjectDeathOps() == nullptr` below
|
|
// keeps meaning what P6.5 meant by it.
|
|
//
|
|
// MEASURED, both directions (package C's red-once): with Magma's install
|
|
// short-circuited, the two CtWireScenario death cases red on the INPROC arm alone
|
|
// (`deaths` 0 vs 0) and stay green on spawn and tcp - which is exactly the split of
|
|
// responsibility this comment claims.
|
|
if (MG_Config::Transport == MG_Config::TransportMode::Spawn &&
|
|
MG_State::GLState::GetStateObjectDeathOps() == nullptr) {
|
|
MG_State::GLState::SetStateObjectDeathOps(&kClientStateObjectDeathOps);
|
|
}
|
|
|
|
// A fresh install means the routed tables are live again: a Start after a previous
|
|
// session's Stop clears the teardown-refusal flag so its own routed calls are not
|
|
// refused. (Stop already puts the monolith adapters back and clears the flag; this is
|
|
// the belt to that braces.)
|
|
g_clientTablesUninstalled.store(false, std::memory_order_release);
|
|
|
|
gMGPipeScreen.ResourceCreate = &Wire_ResourceCreate;
|
|
gMGPipeScreen.ResourceDestroy = &Wire_ResourceDestroy;
|
|
gMGPipeScreen.UnmapPersistent = &Wire_UnmapPersistent;
|
|
|
|
gMGPipeContext.CreateRenderState = &Wire_CreateRenderState;
|
|
gMGPipeContext.BindRenderState = &Wire_BindRenderState;
|
|
gMGPipeContext.DeleteRenderState = &Wire_DeleteRenderState;
|
|
gMGPipeContext.CreateVertexElements = &Wire_CreateVertexElements;
|
|
gMGPipeContext.BindVertexElements = &Wire_BindVertexElements;
|
|
gMGPipeContext.DeleteVertexElements = &Wire_DeleteVertexElements;
|
|
gMGPipeContext.CreateSamplerState = &Wire_CreateSamplerState;
|
|
gMGPipeContext.DeleteSamplerState = &Wire_DeleteSamplerState;
|
|
gMGPipeContext.CreateSamplerView = &Wire_CreateSamplerView;
|
|
gMGPipeContext.DeleteSamplerView = &Wire_DeleteSamplerView;
|
|
gMGPipeContext.BindShaderState = &Wire_BindShaderState;
|
|
gMGPipeContext.DeleteShaderState = &Wire_DeleteShaderState;
|
|
gMGPipeContext.SetDrawProgram = &Wire_SetDrawProgram;
|
|
gMGPipeContext.SetDispatchProgram = &Wire_SetDispatchProgram;
|
|
gMGPipeContext.SetDynamicState = &Wire_SetDynamicState;
|
|
gMGPipeContext.SetFramebufferState = &Wire_SetFramebufferState;
|
|
gMGPipeContext.SetVertexBuffers = &Wire_SetVertexBuffers;
|
|
gMGPipeContext.SetIndexBuffer = &Wire_SetIndexBuffer;
|
|
gMGPipeContext.SetSamplerViews = &Wire_SetSamplerViews;
|
|
gMGPipeContext.BindSamplerStates = &Wire_BindSamplerStates;
|
|
gMGPipeContext.SetShaderImages = &Wire_SetShaderImages;
|
|
gMGPipeContext.SetShaderBuffers = &Wire_SetShaderBuffers;
|
|
gMGPipeContext.SetGlobalConstants = &Wire_SetGlobalConstants;
|
|
gMGPipeContext.SetVertexAttribDefaults = &Wire_SetVertexAttribDefaults;
|
|
gMGPipeContext.SetPixelPackState = &Wire_SetPixelPackState;
|
|
gMGPipeContext.SetPatchState = &Wire_SetPatchState;
|
|
gMGPipeContext.SetContextValues = &Wire_SetContextValues;
|
|
gMGPipeContext.SetResidualValueState = &Wire_SetResidualValueState;
|
|
gMGPipeContext.SetTextureParams = &Wire_SetTextureParams;
|
|
gMGPipeContext.ResourceSubData = &Wire_ResourceSubData;
|
|
gMGPipeContext.BufferSubDataResident = &Wire_BufferSubDataResident;
|
|
gMGPipeContext.ResourceReadback = &Wire_ResourceReadback;
|
|
|
|
gMGPipeRouteEscapes.ResourceRespecify = &Wire_Escape_ResourceRespecify;
|
|
gMGPipeRouteEscapes.ResourceFlushRange = &Wire_Escape_ResourceFlushRange;
|
|
gMGPipeRouteEscapes.MapPersistent = &Wire_Escape_MapPersistent;
|
|
gMGPipeRouteEscapes.CreateShaderState = &Wire_Escape_CreateShaderState;
|
|
gMGPipeRouteEscapes.SetProgramBindings = &Wire_Escape_SetProgramBindings;
|
|
|
|
MGPipeNoteInstalledArm(MGPipeRouteArm::kClientWire);
|
|
}
|
|
|
|
void UninstallClientWireTables() {
|
|
// MARK THE ROUTED TABLES UNINSTALLED (codex 4). It does NOT reinstall the monolith
|
|
// adapters, and that is the whole fix: round 2 reinstalled them here, at the TOP of
|
|
// Stop, so a routed GL-thread call arriving during teardown ran the applier on the
|
|
// caller - the forbidden path - and moved no wire ordinal, with no refusal. Raising the
|
|
// flag leaves the Wire_* rows in place; the next routed call reaches RequireSession,
|
|
// reads the flag and aborts by name (Fatal{ClientTablesUninstalled}) before it touches a
|
|
// ring that Stop is about to free. The monolith adapters are put back only once teardown
|
|
// is complete, by ReinstallMonolithAfterTeardown, for the at-exit deletes that reach a
|
|
// process with no session at all. Idempotent: safe to call when nothing was installed.
|
|
g_clientTablesUninstalled.store(true, std::memory_order_release);
|
|
if (MG_State::GLState::GetStateObjectDeathOps() == &kClientStateObjectDeathOps) {
|
|
MG_State::GLState::SetStateObjectDeathOps(nullptr);
|
|
}
|
|
}
|
|
|
|
void ReinstallMonolithAfterTeardown() {
|
|
// THE LAST STEP OF ClientSession::Stop, after the rings, segments and transport are gone
|
|
// and the apply thread has joined. Now a routed call can only be a process that no
|
|
// longer has a session - the canonical case is ~BufferObject running from an exit
|
|
// handler (ID-8) - and the monolith adapter, which runs the applier synchronously, is
|
|
// the correct answer for it, exactly as it is in a pure-monolith build. Clearing the
|
|
// flag re-enables the (now monolith) rows. A null row here would be an undiagnosed crash
|
|
// at whatever GL call an exit handler makes; the applier is a defined no-op-or-apply.
|
|
MG_Pipe::MGPipeInstallMonolithTables();
|
|
g_clientTablesUninstalled.store(false, std::memory_order_release);
|
|
}
|
|
|
|
Uint64 ClientWireRecordsEmitted() { return g_emitted; }
|
|
Uint64 ClientWireRecordsDeclined() { return g_declined; }
|
|
|
|
// ---- P12: the CREATE WINDOW, as numbers a test can read ---------------------------------
|
|
//
|
|
// The window is a DEFERRAL, so the property that has to be checkable is not "is it faster"
|
|
// but "was every deferred answer eventually taken, and did the window ever hold more than it
|
|
// is sized for". Pending is that second question, and it is the one a silent overwrite would
|
|
// answer wrongly: the ring wraps, so an unguarded push would not grow the count past the
|
|
// ceiling - it would REPLACE the oldest confirmation and leave the count where it was. Read it
|
|
// against the effective ceiling and the array size, which are the two numbers the guard itself
|
|
// is written in terms of, and not against a literal repeated in the test.
|
|
Uint32 ClientCreateWindowPending() { return g_createWindowCount; }
|
|
// Taken + Pending is every create the window ever accepted: the answers it has read back and
|
|
// the ones still outstanding. A create the window accepted and then LOST would break that
|
|
// sum, which is the failure a provisional accept makes possible and a count cannot show.
|
|
Uint64 ClientCreateWindowTaken() { return g_createWindowTaken; }
|
|
// How many times the residency budget, and not a full window, forced the blocking read. A
|
|
// non-zero value is the budget DOING its job (an answer was about to outlive its slot); a
|
|
// value near the record count means the window is deferring almost nothing, which is the
|
|
// honest way to say "the budget is too tight for this workload".
|
|
Uint64 ClientCreateWindowBudgetStops() { return g_createWindowBudgetStops; }
|
|
Uint32 ClientCreateWindowEffective() { return CreateWindowEffective(); }
|
|
Uint32 ClientCreateWindowArraySize() { return kCreateWindowMax; }
|
|
// Objects whose create came back DECLINED: out of the window, next create blocking, and back
|
|
// in the moment a blocking create lands.
|
|
Uint32 ClientCreateWindowSuspects() { return static_cast<Uint32>(g_createSuspects.size()); }
|
|
Uint64 ClientCreateWindowRefusals() { return g_createRefusals; }
|
|
|
|
} // namespace MobileGL::MG_Remote::Client
|
|
|
|
#endif // MOBILEGL_BUILD_DISAGGREGATED
|