[MG_Pipe, MG_Remote, MG_Backend, MG_Impl, MG_IntegrationTest] (Disaggregated): P5c ct - the applier_reset and object_death control records

Two appended wire records replace the two cross-role control bypasses:
applier_reset (opcode 77, MGPApplierReset{Uint64 ContextSerial}, kScreen) is emitted by
the GL thread at the FreshlyPrimed make-current edge, before the client-side resets, so
the record's barrier orders the server's MGPipeApplierReset against every verb that
follows; a direct MGPipeApplierReset() from a non-apply thread with a live session is
Fatal{RoleViolation, "g_applier"} (MGPipeApply.cpp carries the layer-2 guard; the
apply-thread and no-session bring-up shapes keep the direct call verbatim).
object_death (opcode 78, reuses MGPipeHandleOnly, kCtxObject) is the framebuffer
family's first wire delete opcode: the client thread resolves the dying object's handle
in its OWN allocator, emits nothing for a handle the server never saw, and EmitAndWaits
otherwise - the wait preserving the death's ordering against in-flight records that
name the handle, the only property the blocking mailbox hop provided. The sink releases
per-kind twins through a handle-keyed ReleaseTwinByHandle (seven kinds, SamplerViewCso's
idempotent second path kept); a null arriving handle is
Fatal{ProtocolCorruption, "ObjectDeath.Handle"}. A documented NoSession fallback arm
delivers a death to a server-only fixture's loop; monolith keeps the mailbox hop and
the FreshlyPrimed direct call character for character.

Evidence: gen_pipe --check/--self-test and gen_pipe_field_ownership --check/--self-test
green; unit 2155/2155; integration-split 111/111 (107 plus four new CtWireScenario
entries, all genuinely run, each with its own log path and an EXPECT_EXIT death case);
red-once - shorting the emission to the direct call turns the four Ct cases red with
Fatal{RoleViolation, "g_applier"} by name, then restores green verbatim.
CONTRACT-P5C sections 1, 5.1-5.2, 7.
This commit is contained in:
2026-09-17 06:08:54 -04:00
parent 1eeb4740ca
commit a7e55ee62a
22 changed files with 1114 additions and 36 deletions
+79
View File
@@ -38,6 +38,10 @@
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeRoute.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_Util/Debug/Log.h>
@@ -503,6 +507,81 @@ namespace MobileGL::MG_Remote::Client {
} // 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;
}
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)) {
MGLOG_F("MGPipe: Fatal{ClientTablesUninstalled, \"%s\"} - the client tables are "