[Feature] (P14): key the backend twin tables by session and share group

The last piece of state ownership this phase owed: the eight DirectGLES twin
tables resolved a handle process-wide, so two contexts of one session that do
NOT share a share group - which have independent name spaces, so the same
{slot, gen} in both is legal - were handed the SAME twin, driver ids,
persistent map and pooled store included. Two contexts that DO share a group
were served by whichever of them twinned first. Design and per-slice shape:
docs/Disaggregated/design/11-state-ownership.md (S6).

Key: DirectGLES.h's TwinKey{SessionKey, ShareGroupKey} - the same two words
the applier's object records are filed under, with the group derived by
MGPipeApplierShareGroupKeyFor so a record and the twin describing it can never
land in different buckets. Session is in the key because a twin names a driver
id and no driver id crosses a connection.

Resolution: SessionRuntime.cpp's ResolveThreadTwinKey, installed by
InstallSessionRuntimeHooks beside the S2 PipeInputs probe, the S4 native
tuple key and the S5 applier key. It reads the ThreadSessionScope's session
and CurrentContextToken() - apply-thread state only, never MG_State, so the
frontend-keyed-lookup prohibition on the apply thread still holds. A thread
that belongs to no session answers {0, 0}, which is the process singleton this
replaces; the monolith and every unit case are unchanged by construction.

Shape: SlotTables.h's SlotTableRegistry holds one SharedPtr bucket per key,
with a thread-local last-key memo. It forwards every handle-keyed entry -
GetOrCreate, FindByHandle, LiveGenAt, HandleOf, StateForHandle,
NoteStateForHandle, ReleaseByHandle, ForEachLive, the counters - to the
CALLING THREAD's bucket, so the ~150 call sites in Managers.cpp and
DirectGLES.cpp are literally unchanged. The holder list stays process-wide: a
death notice is about an object, not a bucket, and each shared holder is one
bucket. ForEachLive now walks the calling thread's bucket (what a context can
see is its own group); the death and diagnostic walks take the new
ForEachBucket.

DropEveryTwinForEndedServerSession is session-scoped: it runs on the apply
thread of the session that is ending, while S2 lets neighbour sessions keep
rendering in the same process. No session on the thread keeps the old
process-wide drop verbatim. Managers.h's "a texture is owned by exactly one
context (share groups are not implemented)" is corrected: a texture belongs to
a share group and its sibling contexts legitimately mutate it.

Tests: MG_Test/Wire/TwinOwnershipTest.cpp, five cases driving the real twin
tables with no EGL and no wire - the same handle in two nonshared groups is
two twins and neither overwrites the other; two contexts of one group resolve
ONE twin; a per-session drop takes only that session's; a thread with no
session still gets the process-wide table; and the six-registry template
repeats the first three. Negative control run and reverted: making the
registry answer a single {0, 0} bucket turns four of the five red.

Full host ctest against a stashed baseline, compared by test name: 0 new
failures. The one name in the difference set
(LogForwardChannel.APeerThatStopsReadingCostsWarnLinesNotTheCallersTime) is a
pre-existing flake, failing on the baseline too with the identical assertion.
The 103-case acceptance set plus the five new cases is 108/108 green.

Remaining debts (documented): the unit shadows (g_activeTextureUnit,
g_boundTexturesCache, g_boundSamplersCache) are still process-global; the
driver-level glIsBuffer probe is still not usable on this host, so sharing is
proven at the twin layer (the twin IS the object holding the driver id); the
backend context/sync generations are still process-wide; the per-record
ContextSlot is still S1's edge declaration.
This commit is contained in:
2026-10-01 20:11:53 -04:00
parent 5c6399e240
commit ff2120616d
11 changed files with 831 additions and 30 deletions
@@ -16268,6 +16268,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (resolver != nullptr) (void)resolver(&outSession, &outToken);
}
#if MOBILEGL_PIPE_PUSH
// P14 S6: the twin tables' key. Same probe/fallback shape as the native tuple's above and
// for the same reason (the whole single-context world must keep getting one key), and
// deliberately a SEPARATE resolver: this one answers for a table that is consulted on paths
// the native tuple is not, and a case that drives twins without EGL must be able to set it
// alone. See DirectGLES.h for what the two halves mean.
static std::atomic<TwinKeyResolver> g_twinKeyResolver{nullptr};
void SetTwinKeyResolver(TwinKeyResolver resolver) {
g_twinKeyResolver.store(resolver, std::memory_order_release);
}
Bool CurrentTwinKey(TwinKey* outKey) {
outKey->SessionKey = 0;
outKey->ShareGroupKey = 0;
const TwinKeyResolver resolver = g_twinKeyResolver.load(std::memory_order_acquire);
if (resolver == nullptr) return false;
return resolver(outKey);
}
#endif // MOBILEGL_PIPE_PUSH
Uint64 CurrentNativeSessionKey() {
Uint64 session = 0;
Uint64 token = 0;
@@ -223,6 +223,47 @@ namespace MobileGL::MG_Backend::DirectGLES {
using NativeContextKeyResolver = Bool (*)(Uint64* outSessionKey, Uint64* outContextToken);
void SetNativeContextKeyResolver(NativeContextKeyResolver resolver);
#if MOBILEGL_PIPE_PUSH
// ---------------------------------------------------------------------------------
// P14 S6 (docs/Disaggregated/design/11-state-ownership.md): THE TWIN TABLES' KEY, AND ITS
// SHAPE IS S4's, ONE LEVEL DOWN. A twin is a DRIVER object, so it lives in one native
// context's object namespace; the identity a twin table must be keyed on is therefore
// {session, share group} - the SAME two words the applier's OBJECT RECORDS are filed under
// (PipeApply.h's MGPipeApplierKey, derived by the same probe and the same `0 means its own
// group` rule, so the two agree by construction rather than by two conventions).
//
// Answers, for the CALLING THREAD, which session it belongs to and which share group that
// session's current context is in. Installed once by the server layer beside the native and
// applier probes; with no probe installed - or on a thread that is no session's own, which
// is the client process, the in-process client+server shape and every unit case - the
// answer is the single key {0, 0}, exactly the process-wide table this replaces.
struct TwinKey {
Uint64 SessionKey = 0;
Uint64 ShareGroupKey = 0;
Bool operator==(const TwinKey& other) const {
return SessionKey == other.SessionKey && ShareGroupKey == other.ShareGroupKey;
}
Bool operator!=(const TwinKey& other) const { return !(*this == other); }
};
struct TwinKeyHash {
SizeT operator()(const TwinKey& key) const {
// The 64-bit mix SlotAllocator and the applier's registry already use for their own
// pair keys, so every keyed table in this tree hashes a session the same way.
Uint64 mixed = key.SessionKey ^ (key.ShareGroupKey + 0x9E3779B97F4A7C15ull +
(key.SessionKey << 6) + (key.SessionKey >> 2));
mixed ^= mixed >> 33;
mixed *= 0xFF51AFD7ED558CCDull;
mixed ^= mixed >> 33;
return static_cast<SizeT>(mixed);
}
};
using TwinKeyResolver = Bool (*)(TwinKey* outKey);
void SetTwinKeyResolver(TwinKeyResolver resolver);
// The calling thread's key. Answers false for {0, 0} - the process-wide group - which is
// also the answer when no probe has ever been installed.
Bool CurrentTwinKey(TwinKey* outKey);
#endif
// Builds the native context for `contextToken` under the calling thread's session. A
// `shareGroupToken` whose group already has a live native context in this session hands that
// context to eglCreateContext as the share argument - which is what makes two contexts see one
+32 -10
View File
@@ -416,17 +416,41 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the textures they view, framebuffers before their attachments, VAOs before the buffers they
// fetch from), so the last reference to each twin goes here and its destructor - answered
// InProcessTeardown() - touches neither the driver nor another TU's statics.
//
// P14 S6: "EVERY TABLE" IS NOW "EVERY BUCKET OF THIS SESSION". The tables are keyed by
// {session, share group}, so the same rebuild of the eight registries would take a NEIGHBOUR
// session's twins with it - and with S2's in-process supervisor serving several sessions at
// once that is not a hypothetical: this runs from ~BackendObject_DirectGLES, on the apply
// thread of the session that is ending. So the drop is scoped to the calling thread's
// session when it has one, and is the process-wide drop (key {0, 0} and nothing else) when it
// does not - which is the shape every unit case and the fork-worker world still has.
void DropEveryTwinForEndedServerSession() {
if (g_processTeardown) return;
g_serverSessionTwinTeardown = true;
SamplerViewImpl::g_backendSamplerViews = {};
FramebufferImpl::g_backendFramebufferObjects = {};
VertexArrayImpl::g_backendVertexArrayObjects = {};
PrgramImpl::g_backendProgramObjects = {};
SamplerImpl::g_backendSamplerObjects = {};
TextureImpl::g_backendTextureObjects = {};
RenderbufferImpl::g_backendRenderbufferObjects = {};
BufferImpl::g_backendBufferResources = {};
TwinKey key;
if (CurrentTwinKey(&key) && key.SessionKey != 0) {
Uint32 buckets = 0;
buckets += SamplerViewImpl::g_backendSamplerViews.DropBucketsOfSession(key.SessionKey);
buckets += FramebufferImpl::g_backendFramebufferObjects.DropBucketsOfSession(key.SessionKey);
buckets += VertexArrayImpl::g_backendVertexArrayObjects.DropBucketsOfSession(key.SessionKey);
buckets += PrgramImpl::g_backendProgramObjects.DropBucketsOfSession(key.SessionKey);
buckets += SamplerImpl::g_backendSamplerObjects.DropBucketsOfSession(key.SessionKey);
buckets += TextureImpl::g_backendTextureObjects.DropBucketsOfSession(key.SessionKey);
buckets += RenderbufferImpl::g_backendRenderbufferObjects.DropBucketsOfSession(key.SessionKey);
buckets += BufferImpl::g_backendBufferResources.DropBucketsOfSession(key.SessionKey);
MGLOG_I("DirectGLES: the ended server session dropped %u twin group(s) across eight kinds "
"(no driver call); the other sessions in this process keep theirs",
buckets);
} else {
SamplerViewImpl::g_backendSamplerViews.ResetBuckets();
FramebufferImpl::g_backendFramebufferObjects.ResetBuckets();
VertexArrayImpl::g_backendVertexArrayObjects.ResetBuckets();
PrgramImpl::g_backendProgramObjects.ResetBuckets();
SamplerImpl::g_backendSamplerObjects.ResetBuckets();
TextureImpl::g_backendTextureObjects.ResetBuckets();
RenderbufferImpl::g_backendRenderbufferObjects.ResetBuckets();
BufferImpl::g_backendBufferResources.ResetBuckets();
}
// P12 review fix (major): AND THE UNIT SHADOWS THAT POINT AT THEM. A texture or sampler twin
// scrubs itself out of g_boundTexturesCache / g_boundSamplersCache in its destructor - but
// not under InProcessTeardown(), which answered true for every twin just dropped. Left
@@ -438,8 +462,6 @@ namespace MobileGL::MG_Backend::DirectGLES {
TextureImpl::g_activeTextureUnit = 0;
SamplerImpl::g_boundSamplersCache = {};
g_serverSessionTwinTeardown = false;
MGLOG_I("DirectGLES: the ended server session's twins are dropped (every kind, no driver call); the next "
"session in this process starts from empty twin tables");
}
#endif
+86 -17
View File
@@ -332,6 +332,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
using const_iterator = typename BackendMap::const_iterator;
#if MOBILEGL_PIPE_PUSH
using SlotTable = BackendSlotTable<StateObject, BackendObject, kKind>;
// P14 S6: the ONE bucket of this kind the calling thread is in. Every handle-keyed entry
// below resolves through it, so the six twin registries answer per {session, share
// group} without a single call site naming a bucket.
using SlotTables = SlotTableRegistry<StateObject, BackendObject, kKind>;
using TwinKey = DirectGLES::TwinKey;
#endif
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
@@ -348,7 +353,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// insertion (D13; SlotTables.h) - so a table used outside a registry arms
// it too, which is right: it is the twin, not the registry, that owns the
// driver id a guarded destructor exists for.
return m_slotTable.GetOrCreate(stateObj);
return m_slotTables.ForCallingThread().GetOrCreate(stateObj);
}
#endif
// Twin creation is the moment a driver-owned id starts needing a guarded
@@ -407,7 +412,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
#endif
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
return m_slotTable.Find(stateObj);
return m_slotTables.ForCallingThread().Find(stateObj);
}
#endif
const auto entryIt = m_entries.find(stateObj);
@@ -445,7 +450,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// backend memo stores instead of a raw pointer, a GL name or a bare lifetime id.
MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const {
if (EsprytSlotTablesEnabled()) {
return m_slotTable.HandleOf(stateObj);
return m_slotTables.HandleOf(stateObj);
}
return MG_Pipe::kMGPipeNullHandle;
}
@@ -454,7 +459,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the lookup a backend memo that already holds a handle wants: no lifetime-id probe.
BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) {
if (EsprytSlotTablesEnabled()) {
return m_slotTable.FindByHandle(handle);
return m_slotTables.FindByHandle(handle);
}
return nullptr;
}
@@ -490,7 +495,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
return nullptr;
#endif
}
const Uint32 liveGen = m_slotTable.LiveGenAt(handle.Slot);
const Uint32 liveGen = m_slotTables.LiveGenAt(handle.Slot);
if (liveGen != 0 && liveGen > handle.Gen) {
#if MOBILEGL_BUILD_DISAGGREGATED
MG_Pipe::MGPipeSessionFail( // @Ph-declined (ID-P7-1): PH-2 stays Fatal, CONTRACT-P7 §12
@@ -502,7 +507,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
return nullptr;
#endif
}
return &m_slotTable.GetOrCreate(handle);
return &m_slotTables.GetOrCreate(handle);
}
// The generation of the LIVE entry at this slot, or 0. It exists so a caller can
@@ -510,7 +515,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// performs silently.
Uint32 LiveGenAt(Uint32 slot) const {
if (!EsprytSlotTablesEnabled()) return 0;
return m_slotTable.LiveGenAt(slot);
return m_slotTables.LiveGenAt(slot);
}
// P5c (hd): the two halves of SlotTables.h's state note, forwarded. A caller holding
@@ -524,12 +529,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// which is the answer the legacy walk's own weak_ptr test already gives.
void NoteStateForHandle(MG_Pipe::MGPipeHandle handle, const StatePtr& stateObj) {
if (EsprytSlotTablesEnabled()) {
m_slotTable.NoteStateForHandle(handle, stateObj);
m_slotTables.NoteStateForHandle(handle, stateObj);
}
}
StatePtr StateForHandle(MG_Pipe::MGPipeHandle handle) const {
if (EsprytSlotTablesEnabled()) {
return m_slotTable.StateForHandle(handle);
return m_slotTables.StateForHandle(handle);
}
return nullptr;
}
@@ -591,9 +596,52 @@ namespace MobileGL::MG_Backend::DirectGLES {
template <typename Fn>
void ForEachLive(Fn&& fn) const {
if (EsprytSlotTablesEnabled()) {
m_slotTable.ForEachLive(fn);
m_slotTables.ForEachLive(fn);
}
}
// ---- P14 S6: the KEYED shape's own controls ----------------------------------------
//
// Everything above is the shape a CALL SITE sees, and it is deliberately the shape it
// saw before the rekey. These four are the shape only a caller that has to reason about
// buckets needs: the teardown of a session, a test asking whether two contexts landed in
// two namespaces, and a diagnostic.
//
// THE ONE BUCKET THIS THREAD RESOLVES TO. A by-value copy of it is exactly what a
// fixture's saved registry was, and it is what makes `= {}` and "put it back" both mean
// something under a key.
SlotTable& BucketForCallingThread() {
if (EsprytSlotTablesEnabled()) return m_slotTables.ForCallingThread();
// The legacy arm has no buckets; the empty table's Reset() is the only honest
// answer, and no shipping path asks (the arm is compiled out or the bit is clear).
return m_legacyEmptyTable;
}
Vector<SharedPtr<SlotTable>> BucketSnapshot() const {
if (EsprytSlotTablesEnabled()) return m_slotTables.Snapshot();
return {};
}
SizeT BucketCount() const {
return EsprytSlotTablesEnabled() ? m_slotTables.BucketCount() : 0;
}
// Drop one {session, share group}. Answers whether such a bucket existed.
Bool DropBucket(const TwinKey& key) {
return EsprytSlotTablesEnabled() && m_slotTables.Drop(key);
}
// Drop every bucket of one session - the difference between "this session's twins go"
// and "the process's do" - and answer how many groups that took with it.
Uint32 DropBucketsOfSession(Uint64 sessionKey) {
return EsprytSlotTablesEnabled() ? m_slotTables.DropSession(sessionKey) : 0;
}
// Empty every bucket. The teardown of a process-shaped world, where the key is {0, 0}.
void ResetBuckets() {
if (EsprytSlotTablesEnabled()) m_slotTables.Reset();
}
// Every bucket, walked with its key.
template <typename Fn>
void ForEachBucket(Fn&& fn) const {
if (EsprytSlotTablesEnabled()) m_slotTables.ForEachBucket(fn);
}
#endif
// The seven DirectGLES.cpp call sites drive the LEGACY arm and nothing else. On the
@@ -664,7 +712,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 m_creationTick = 0;
Bool m_isCollecting = false;
#if MOBILEGL_PIPE_PUSH
BackendSlotTable<StateObject, BackendObject, kKind> m_slotTable;
// P14 S6: the ONE bucket per {session, share group}, hidden behind the same member names
// the single table had, so every handle-keyed call site above and every caller below is
// unchanged. See SlotTables.h's SlotTableRegistry for the resolution chain.
SlotTables m_slotTables;
// The legacy arm's stand-in for BucketForCallingThread(). Never read after a Reset and
// never populated: the two entries that could write it are the handle-keyed ones, which
// return early on that arm.
SlotTable m_legacyEmptyTable;
#endif
};
@@ -1081,8 +1136,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// HandleOf, no ForEachLive), and the handle overloads never look at it. Death is
// announced by the family's own ResourceDestroy call, not by the shared death notice
// (D-L), and the slot is freed by the CLIENT after that call returns.
//
// P14 S6: A BUCKET REGISTRY, like the six TwinRegistry tables. A buffer twin owns a
// driver buffer id, so the SAME {slot, gen} in two contexts that do not share a group
// must be two resources - and it was one process-wide table until this slice. The
// handle-keyed member-for-member forwarding in SlotTables.h is what leaves every
// `g_backendBufferResources.X(...)` call site below untouched.
using BackendBufferResourceTable =
BackendSlotTable<MG_State::GLState::BufferObject, GLESBufferResource, MG_Pipe::MGPipeKind::Buffer>;
SlotTableRegistry<MG_State::GLState::BufferObject, GLESBufferResource, MG_Pipe::MGPipeKind::Buffer>;
extern BackendBufferResourceTable g_backendBufferResources;
// Resolved once per process and latched, exactly like EsprytSlotTablesEnabled() and
@@ -2186,10 +2247,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// unconditionally bumps it), the content version on every CPU pixel mutation, and
// the params version covers SetSamples/SetFixedSampleLocations, which bump neither
// of the other two but feed the shape probe. The context id pins the generation to
// the context that produced it - generations restart at 0 with a new context, and a
// texture is owned by exactly one context (share groups are not implemented), so a
// mutation can never happen under a context this key does not name. 0 = never
// stamped (real context ids start at 1). Backend-side invalidation rides on
// the context that produced it - generations restart at 0 with a new context, so the
// id is part of the key. IT IS NOT AN OWNERSHIP CLAIM: a texture belongs to a SHARE
// GROUP (P14 S3/S6 - share groups ARE implemented, `ShareGroupState` holds the object
// tables and the twin tables are keyed by {session, share group}), so two contexts of
// one group both legitimately mutate this texture. What the id buys is only the
// cheaper half of the gate: a mutation made under a context sharing this texture
// stamps a different id and re-derives the shape, which is a spurious re-derivation
// (safe, and only on a shape mutation) rather than a missed one - the per-mutation
// versions below are what actually catch a change, and they are group-independent.
// 0 = never stamped (real context ids start at 1). Backend-side invalidation rides on
// m_isInitialized: RequireImageBindableStorage and RecreateBackendTexture clear it.
Uint64 m_syncedShapeContextId = 0;
Uint64 m_syncedShapeGeneration = 0;
@@ -3639,7 +3706,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
// what makes HandleOf below resolve at all. Not one member that would dereference it is
// instantiated - no Find(StateObject*), no ForEachLive - and the handle overloads never
// look at it.
using BackendSamplerViewTable = BackendSlotTable<MG_State::GLState::ITextureObject,
// P14 S6: and a bucket registry for the same reason the buffer one above is - a sampler
// view's twin is a driver object of the texture's group.
using BackendSamplerViewTable = SlotTableRegistry<MG_State::GLState::ITextureObject,
BackendSamplerViewObject,
MG_Pipe::MGPipeKind::SamplerViewCso>;
extern BackendSamplerViewTable g_backendSamplerViews;
+267
View File
@@ -11,6 +11,12 @@
#include <MG_Pipe/MGPipeHandles.h>
// P14 S6: the key this table is filed under includes the calling thread's {session, share
// group}, and the probe that answers it lives on the backend's public surface beside S4's
// native-tuple key (DirectGLES.h). Included here so this header is self-contained for the two
// translation units that include it without copying Managers.h's include order.
#include "DirectGLES.h"
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#endif
@@ -155,6 +161,249 @@ namespace MobileGL::MG_Backend::DirectGLES {
return enabled;
}
// =====================================================================================
// P14 S6 (docs/Disaggregated/design/11-state-ownership.md): THE TWIN TABLE IS PER
// {session, share group}, NOT PER PROCESS.
// =====================================================================================
//
// WHAT WAS WRONG, in one sentence: a twin is a DRIVER object - a GL buffer id, a program, a
// texture, the storage behind them - so it belongs to one native context's object namespace,
// and a table keyed by {slot, gen} ALONE resolves the same handle value to the same twin
// whichever context asked. The frontend gives two contexts that do NOT share a group two
// independent name spaces, so the same {slot, gen} in both is legal and means two different
// objects; one process-wide table made the second context adopt the first's twin (including
// its driver ids, its persistent map and its pooled store). At the same time two contexts of
// ONE group - which must see one set of objects - were served by whichever of them twinned
// first, i.e. by luck rather than by the group the two agree on.
//
// THE KEY IS the same pair the applier files its OBJECT RECORDS under: {session, share
// group}. Same probe, same `0 means this context started its own group` rule (see
// DirectGLES.h's TwinKey and SessionRuntime.cpp's ResolveThreadTwinKey), so a record and the
// twin it describes are always in the same bucket. Session is in the key because a twin
// names a driver id and no driver id crosses a connection - two connections in one process
// are two clients, and nothing may be shared with either from the other.
//
// THE HOLDER LIST STAYS PROCESS-WIDE AND THAT IS NOT A COMPROMISE. A notice is about an
// OBJECT, not about a bucket: OnFrontendObjectDestroyed and ReleaseTwinByHandle must reach
// the by-value copies a fixture or a context reset is holding, whichever bucket they sit in,
// and "which bucket holds it" is exactly the question that produced the two-holder leak the
// list exists to fix. Each SHARED holder of the list is ONE bucket; the walk therefore covers
// every bucket of every table.
//
// THE SAME TABLE OBJECT IS REUSED, NOT REPLACED, and that is what keeps ~200 call sites
// alive: `g_backendTextureObjects.Find(...)` still answers from the bucket the calling thread
// is in, because the object the global names multiplexes on the key at each lookup. A caller
// that must address a bucket OTHER than the calling thread's - the death walks, the
// session-teardown drop - takes Shared() or calls the static entries, which walk buckets;
// and a caller that wants the whole process's view (the teardown, the tests) takes Snapshot().
// FORWARD-DECLARED, because the registry below is DEFINED before the table it holds; the
// members that name the table are defined out of line at the bottom of this header.
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
class BackendSlotTable;
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
class SlotTableRegistry {
public:
using Table = BackendSlotTable<StateObject, BackendObject, kKind>;
using Key = TwinKey;
using StatePtr = SharedPtr<StateObject>;
using BackendPtr = SharedPtr<BackendObject>;
// The bound the two tables that ARE a bucket registry have always spelled by name; the
// six registries reach it through their own `SlotTable` alias.
static constexpr Uint32 kMaxHandleSlot = 1u << 20;
// One bucket. SHARED, so a caller may keep it across other lookups: the map owns the
// pointee and never moves it.
SharedPtr<Table> Shared(const Key& key) const {
// The common case on the hot path: the same key as last time. One compare, no probe.
if (m_memoTable != nullptr && m_memoKey == key) return m_memoTable;
const auto found = m_buckets.find(key);
if (found != m_buckets.end()) {
m_memoKey = key;
m_memoTable = found->second;
return found->second;
}
SharedPtr<Table> fresh = MakeShared<Table>();
m_buckets.emplace(key, fresh);
m_memoKey = key;
m_memoTable = fresh;
return fresh;
}
// The calling thread's bucket - the ONE entry every in-place call site below resolves
// through, and the process-wide answer on a thread that belongs to no session.
//
// CONST, AND IT ANSWERS A MUTABLE BUCKET: the index is `mutable` because a LOOKUP may
// have to create the bucket it is about to miss in (a handle with no twin yet is the
// ordinary case), and the bucket's own entries are the table's business rather than the
// registry's. The alternative - a const overload returning "no table" - would make every
// read-only caller handle a case that cannot arise.
Table& ForCallingThread() const {
Key key;
CurrentTwinKey(&key);
return *Shared(key);
}
// Every bucket, in no particular order.
Vector<SharedPtr<Table>> Snapshot() const {
Vector<SharedPtr<Table>> all;
all.reserve(m_buckets.size());
for (const auto& bucket : m_buckets) all.push_back(bucket.second);
return all;
}
SizeT BucketCount() const { return m_buckets.size(); }
// THE PER-BUCKET ONE-SHOT, because a bucket held by SharedPtr is never dropped by an
// assignment: a caller that means "this context/group is gone" has to be able to say so,
// and the key is how it says which one. The bucket LEAVES the index before its twins are
// released, so a twin destructor that re-entered Shared() cannot find a half-erased one.
Bool Drop(const Key& key) {
const auto found = m_buckets.find(key);
if (found == m_buckets.end()) return false;
SharedPtr<Table> dropped = found->second;
m_buckets.erase(found);
if (m_memoTable == dropped) {
m_memoKey = Key{};
m_memoTable = nullptr;
}
dropped->Reset();
return true;
}
// EVERY bucket of one session, for the teardown of a session that owned several groups.
// The session is the key's high half, so this is one pass and no per-group bookkeeping -
// and it is the difference between "this session's twins go" and "the process's do".
Uint32 DropSession(Uint64 sessionKey) {
Uint32 dropped = 0;
for (auto bucket = m_buckets.begin(); bucket != m_buckets.end();) {
if (bucket->first.SessionKey != sessionKey) {
++bucket;
continue;
}
SharedPtr<Table> table = bucket->second;
if (m_memoTable == table) {
m_memoKey = Key{};
m_memoTable = nullptr;
}
bucket = m_buckets.erase(bucket);
table->Reset();
++dropped;
}
return dropped;
}
// Replace this registry with an empty one, releasing every bucket's twins. This is what
// `registry = {}` meant when the registry WAS one table; a caller that must keep the
// tables alive past the call takes a Snapshot() first.
void Reset() {
const Vector<SharedPtr<Table>> all = Snapshot();
m_buckets.clear();
m_memoKey = Key{};
m_memoTable = nullptr;
for (const SharedPtr<Table>& table : all) table->Reset();
}
// ---- the calling thread's bucket, member for member ---------------------------------
//
// THIS IS WHY THE SHAPE COST THE CALL SITES NOTHING. Every handle-keyed entry the table
// has is repeated here and forwarded to the bucket the calling thread resolves to, so
// `g_backendTextureObjects.FindByHandle(h)` and `g_backendBufferResources.GetOrCreate(h)`
// - unchanged source - answer from the right context's object namespace. A caller that
// must name a bucket OTHER than its own (a death walk, a case) takes Shared()/Snapshot()
// and calls the table directly.
BackendPtr& GetOrCreate(MG_Pipe::MGPipeHandle handle) {
return ForCallingThread().GetOrCreate(handle);
}
BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) {
return ForCallingThread().FindByHandle(handle);
}
const BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) const {
return ForCallingThread().FindByHandle(handle);
}
// Resolve-or-create in a bucket the caller NAMES rather than the one it is in. The
// monolith-glue frontend-keyed entry (StateBackendObjectRegistry::GetOrCreate below)
// needs it: a state object handed over on the client side of a sync is described by a
// record in the CALLING thread's group, and a twin built for it must land in the same
// bucket as that record.
BackendPtr& GetOrCreateIn(const Key& key, MG_Pipe::MGPipeHandle handle) {
return Shared(key)->GetOrCreate(handle);
}
Uint32 LiveGenAt(Uint32 slot) const { return ForCallingThread().LiveGenAt(slot); }
MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const {
return ForCallingThread().HandleOf(stateObj);
}
void NoteStateForHandle(MG_Pipe::MGPipeHandle handle, const StatePtr& stateObj) {
ForCallingThread().NoteStateForHandle(handle, stateObj);
}
StatePtr StateForHandle(MG_Pipe::MGPipeHandle handle) const {
return ForCallingThread().StateForHandle(handle);
}
template <typename Fn>
void ForEachLive(Fn&& fn) const {
// THE CALLING THREAD'S BUCKET, not every bucket, and that is the semantic answer
// rather than a shortcut: a caller walking twins is asking "which of the objects I
// can see need this", and what it can see is its own share group. Every bucket in
// one process would walk a neighbour session's objects.
ForCallingThread().ForEachLive(fn);
}
// Every bucket, in one pass. The caller receives the key with the handle so it can tell
// which context a twin belongs to - the only walks that need the whole process's view
// are a diagnostic and a teardown.
template <typename Fn>
void ForEachBucket(Fn&& fn) const {
for (const auto& bucket : m_buckets) {
bucket.second->ForEachLive(
[&](MG_Pipe::MGPipeHandle handle, const BackendPtr& twin) { fn(bucket.first, handle, twin); });
}
}
// The process-wide answer for the two statics: a notice is about an OBJECT, so it must
// reach the twin in EVERY bucket of EVERY holder. They forward to the table's own
// statics, which walk the holder list - each SHARED holder of it is one bucket.
static Bool ReleaseTwinByHandle(MG_Pipe::MGPipeHandle handle) {
return Table::ReleaseTwinByHandle(handle);
}
static Bool OnFrontendObjectDestroyed(Uint64 lifetimeId) {
return Table::OnFrontendObjectDestroyed(lifetimeId);
}
// The instance spelling, and it HANDS THE TWIN OUT rather than answering a Bool: the two
// tables that ARE a bucket registry (buffers, sampler views) have always been called as
// `g_backendBufferResources.ReleaseByHandle(res)` and their callers still have to decide
// what happens to the driver id the twin owns. The bucket is the CALLING THREAD'S, which
// is the bucket that twin was created in.
BackendPtr ReleaseByHandle(MG_Pipe::MGPipeHandle handle) {
return ForCallingThread().ReleaseByHandle(handle);
}
static Uint32 HolderCount() { return Table::HolderCount(); }
Uint32 LiveCount() const { return ForCallingThread().LiveCount(); }
Uint32 CompositeLiveCount() const { return ForCallingThread().CompositeLiveCount(); }
SizeT OrdinaryCapacityForTest() const { return ForCallingThread().OrdinaryCapacityForTest(); }
SizeT CompositeCapacityForTest() const { return ForCallingThread().CompositeCapacityForTest(); }
// ---- the bucket index's own controls, for the two tables that ARE a registry --------
//
// The six TwinRegistry registries carry the same three under the names in Managers.h
// (DropBucket / DropBucketsOfSession / ResetBuckets); they are repeated here because
// SlotTableRegistry IS the public type of the buffer and sampler-view tables, so a caller
// of those has no registry object in between.
Bool DropBucket(const Key& key) { return Drop(key); }
// Drop every bucket of one session; answers how many groups that took with it.
Uint32 DropBucketsOfSession(Uint64 sessionKey) { return DropSession(sessionKey); }
// Empty every bucket (the process-shaped world's teardown, key {0, 0}).
void ResetBuckets() { Reset(); }
private:
// ONE BUCKET PER LIVE {session, share group}, held by SHARED pointer so a caller may
// snapshot one across other lookups and so `Drop` can take a bucket's twins away
// explicitly - an embedded table could do neither. `std::unordered_map` rather than the
// tree's own flat Vector: this map is probed once per twin lookup on the draw path.
mutable std::unordered_map<Key, SharedPtr<Table>, TwinKeyHash> m_buckets;
// Hot-path memo. A registry is per KIND and each kind is touched several times per draw;
// a thread only ever moves between buckets when its session binds another context.
mutable SharedPtr<Table> m_memoTable;
mutable Key m_memoKey;
};
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
class BackendSlotTable {
public:
@@ -616,6 +865,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// Empty this table, releasing every twin it holds. THE SPACES ARE MOVED OUT FIRST and
// that is the whole of the care taken here: a twin's destructor is a driver call that
// could re-enter GetOrCreate on this table (SlotTables.h's ReleaseTwinAt says why), and
// destroying the entries in place would leave that re-entry resizing the vector the
// destructors are being walked out of. Here the table is already empty when the entries
// die, so a re-entrant insert grows a fresh vector.
//
// This is what `table = {}` used to mean, spelled so a caller cannot miss the ordering.
void Reset() {
Vector<Entry> slots = std::move(m_slots);
Vector<Entry> band = std::move(m_band);
BackendPtr nullTwin = std::move(m_nullTwin);
ForgetHandle();
slots.clear();
band.clear();
nullTwin.reset();
}
Uint32 LiveCount() const {
Uint32 count = 0;
for (const Entry& entry : m_slots) {
+12
View File
@@ -643,6 +643,18 @@ namespace MobileGL::MG_Pipe {
return sessionIt == g_applierSessions.end() ? 0 : static_cast<Uint64>(sessionIt->second.Groups.size());
}
Uint64 MGPipeApplierShareGroupKeyFor(Uint64 sessionKey, Uint64 contextToken) {
std::lock_guard<std::mutex> guard(g_applierSessionsMutex);
const auto sessionIt = g_applierSessions.find(sessionKey);
if (sessionIt != g_applierSessions.end()) {
const auto groupIt = sessionIt->second.ContextGroup.find(contextToken);
if (groupIt != sessionIt->second.ContextGroup.end()) return groupIt->second;
}
// Not registered - the same fallback FindApplierForKey takes, spelled by the same one
// function so the two cannot drift.
return ApplierShareGroupKey(contextToken, 0);
}
namespace {
// The installed handle-shaped resource table. Null until a backend registers one,
+10
View File
@@ -1056,6 +1056,16 @@ namespace MobileGL::MG_Pipe {
// "shared" apart from the behaviour they exist to explain.
Uint64 MGPipeApplierShareGroupCountForTesting(Uint64 sessionKey);
// P14 S6: THE SHARE-GROUP BUCKET A {session, context token} PAIR IS FILED UNDER, answered
// from the registry rather than re-derived. The backend's twin tables are keyed by the same
// pair (DirectGLES.h's TwinKey), and a record and the twin describing it must never land in
// different buckets - so there is ONE spelling of the rule, here. A registered context
// answers its group; anything else (token 0, the 0 of a session that has not bound one yet,
// a context no CreateContext ever named) answers the group that context starts itself,
// exactly as FindApplierForKey mints it. Never fails, so a caller has no third answer to
// invent.
Uint64 MGPipeApplierShareGroupKeyFor(Uint64 sessionKey, Uint64 contextToken);
// ---------------------------------------------------------------------------------
// P5e: THE BARRIERED PREDICATE (MG_Remote/CONTRACT-P5E.md §2.1, ruling 3)
// ---------------------------------------------------------------------------------
@@ -62,6 +62,24 @@ namespace MobileGL::MG_Remote::Server {
outKey->ContextToken = runtime->Session().CurrentContextToken();
return true;
}
// P14 S6 (docs/Disaggregated/design/11-state-ownership.md). WHICH TWIN TABLE DOES THIS
// THREAD'S BACKEND OBJECT BELONG TO? The backend's twin tables are per {session, share
// group} - a twin owns a DRIVER id, and a driver id belongs to one native context's
// object namespace - so this answers the same two words the applier's OBJECT RECORDS are
// filed under, resolved through the same `CurrentContextToken()` and the same
// MGPipeApplierShareGroupKeyFor rule the applier itself uses. Sharing a helper rather
// than re-deriving the rule is the point: a record and the twin it describes must always
// land in the same bucket, and a second spelling of "0 means its own group" is exactly
// how they would stop doing that.
Bool ResolveThreadTwinKey(MG_Backend::DirectGLES::TwinKey* outKey) {
SessionRuntime* runtime = t_runtime;
if (runtime == nullptr) return false;
outKey->SessionKey = reinterpret_cast<Uint64>(&runtime->Session());
outKey->ShareGroupKey = MG_Pipe::MGPipeApplierShareGroupKeyFor(
outKey->SessionKey, runtime->Session().CurrentContextToken());
return true;
}
} // namespace
void Detail::InstallSessionRuntimeHooks() {
@@ -70,6 +88,9 @@ namespace MobileGL::MG_Remote::Server {
}
MG_Backend::DirectGLES::SetNativeContextKeyResolver(&ResolveThreadNativeContextKey);
MG_Pipe::MGPipeSetApplierKeyResolver(&ResolveThreadApplierKey);
// P14 S6: the twin tables' key. Same probe, one level down, installed beside the two it
// is the twin of (the native tuple's and the applier's).
MG_Backend::DirectGLES::SetTwinKeyResolver(&ResolveThreadTwinKey);
}
SessionRuntime* Detail::CurrentRuntime() { return t_runtime; }
+4
View File
@@ -24,6 +24,10 @@ set(MOBILEGL_WIRE_TESTS
# the key they resolve under - {session, context} for the working state, {session, share
# group} for the object records. Drives the registry directly: no EGL, no wire.
ApplierOwnershipTest
# P14 S6 (same doc): the twin tables' key - {session, share group}, the same pair, so a
# record and the twin it describes cannot land in different buckets. Drives the real
# twin tables directly: no EGL, no wire.
TwinOwnershipTest
)
if (NOT WIN32)
+291
View File
@@ -0,0 +1,291 @@
// MobileGL - MobileGL/MG_Test/Wire/TwinOwnershipTest.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
// P14 S6 (docs/Disaggregated/design/11-state-ownership.md): WHOSE TWIN IS THIS?
//
// WHAT THIS SUITE OWNS. A backend twin is a DRIVER object - a GL buffer id, a texture, a
// program, the storage behind them - so it belongs to one native context's object namespace.
// Until this slice the six twin registries were keyed by {slot, gen} ALONE, process-wide, which
// is wrong in both directions:
//
// * two contexts of one session that do NOT share a share group have independent name spaces,
// so the same handle value in both is legal and means two different objects. One table gave
// the second context the FIRST one's twin - the same driver ids, the same persistent map,
// the same pooled store, addressed as if they were its own;
// * two contexts that DO share a group must see one set of objects. They were served by
// whichever of them twinned first, which is right by accident and wrong as soon as the
// other one asks first under a different key.
//
// This suite drives the twin tables directly - no EGL, no wire, no transport - because that is
// exactly what the slice is: `g_backendSamplerViews` (and the six TwinRegistry registries)
// resolve per {session, share group}, and every case below is a way of using two contexts that
// the process-wide table got wrong.
//
// HOW IT INSTALLS A KEY. The production resolver is the server's (SessionRuntime.cpp's
// `ResolveThreadTwinKey`, installed by ThreadSessionScope beside the native-tuple and applier
// probes, and deriving the group through MGPipeApplierShareGroupKeyFor so a twin and the record
// that describes it cannot land in different buckets). Here the resolver is this file's,
// driven by two thread_locals, so a case can be in whichever session and share group it means
// without standing up a transport. The session keys are large sentinels no real
// ServerSession address can collide with, and every case drops what it created on the way out.
//
// THE NEGATIVE CONTROL, run once and reverted (S5's shape, and the reason these cases are not
// an empty green): make SlotTableRegistry::ForCallingThread answer a single fixed {0, 0} bucket
// - i.e. restore the process-wide table this slice replaces - and FOUR of the five cases go red
// at their first per-group assertion: the nonshared pair, the per-session drop, the
// no-session case (which then cannot be told from a session's) and the six-registry one.
// `TwoContextsOfOneGroupShareTheGroupsTwins` STAYS GREEN under the control, and that is the
// correct answer rather than a gap: with ONE key both shapes agree, which is why the case
// asserts what sharing must do and not merely that two buckets exist.
#include <MG_Backend/DirectGLES/Managers.h>
#include <MG_Pipe/PipeApply.h>
// The twin tables run on the handle arm only while kMGPipeSubsystemEsprytSlots is set, and a
// unit binary never runs ConfigLoader's default (which sets every migrated subsystem). Same
// drive as ServerLoopTest's EGL fixture: set the bit, restore it on the way out.
#include <Config.h>
#include <gtest/gtest.h>
namespace {
namespace GL = MobileGL::MG_Backend::DirectGLES;
namespace P = MobileGL::MG_Pipe;
using MobileGL::Bool;
using MobileGL::SizeT;
using MobileGL::Uint32;
using MobileGL::Uint64;
// The calling thread's {session, share group}, exactly the shape the production resolver
// fills in. A zero session key is "not a session's thread" - the client process, the inproc
// one-process shape, every unit case - and that is what keeps the process-wide bucket the
// answer there.
thread_local Uint64 t_sessionKey = 0;
thread_local Uint64 t_shareGroupKey = 0;
Bool TestTwinKeyResolver(GL::TwinKey* outKey) {
if (t_sessionKey == 0) return false;
outKey->SessionKey = t_sessionKey;
outKey->ShareGroupKey = t_shareGroupKey;
return true;
}
// A twin with no driver id and a trivial destructor, so a case can create and drop entries
// on a REAL production table without a GL context and without a driver call at teardown.
// (BackendSamplerViewObject's own comment says the same thing about itself; the alias keeps
// the cases readable.)
using Twin = GL::SamplerViewImpl::BackendSamplerViewObject;
using RealTable = GL::SamplerViewImpl::BackendSamplerViewTable;
// The six-registry shape, on the same trivial twin and on a kind no shipping path twins
// (Query), so a case can instantiate it without touching a real slot space.
using RegistryShapedTable = GL::TwinRegistry<MobileGL::MG_State::GLState::ITextureObject, Twin,
P::MGPipeKind::Query>;
class TwinOwnershipTest : public ::testing::Test {
protected:
// Sentinels no real ServerSession address can be, so a stray registration elsewhere in
// the process can never be what a case reads.
static constexpr Uint64 kSessionA = 0x5E5510C000000000ull;
static constexpr Uint64 kSessionB = 0x5E5510D000000000ull;
// Share-group tokens are minted by the frontend (EGLState::CreateContext), dense and
// 0-free: two contexts that share pass the SAME number, two that do not pass different
// ones - which is why the key can be the group itself.
static constexpr Uint64 kGroupOne = 0x2001;
static constexpr Uint64 kGroupTwo = 0x2002;
Uint64 m_savedPush = 0;
void SetUp() override {
m_savedPush = MobileGL::MG_Config::Features.PipePush;
MobileGL::MG_Config::Features.PipePush |= P::kMGPipeSubsystemEsprytSlots;
GL::SetTwinKeyResolver(&TestTwinKeyResolver);
BindKey(kSessionA, kGroupOne);
}
void TearDown() override {
MobileGL::MG_Config::Features.PipePush = m_savedPush;
GL::SamplerViewImpl::g_backendSamplerViews.DropBucketsOfSession(kSessionA);
GL::SamplerViewImpl::g_backendSamplerViews.DropBucketsOfSession(kSessionB);
// The process-wide bucket the no-session case writes ({0, 0}): nothing else in this
// binary uses it, and leaving it would make that case order-dependent.
GL::SamplerViewImpl::g_backendSamplerViews.DropBucket(GL::TwinKey{0, 0});
t_sessionKey = 0;
t_shareGroupKey = 0;
GL::SetTwinKeyResolver(nullptr);
}
static void BindKey(Uint64 session, Uint64 shareGroup) {
t_sessionKey = session;
t_shareGroupKey = shareGroup;
}
// Resolve-or-create on the real table, then stamp the twin so a case can tell WHICH
// object it got back. The pointer is the twin the driver ids would hang off.
static Twin* Mint(RealTable& table, P::MGPipeHandle handle, Uint64 stamp) {
MobileGL::SharedPtr<Twin>& slot = table.GetOrCreate(handle);
slot = MobileGL::MakeShared<Twin>();
slot->SyncedSerial = stamp;
return slot.get();
}
};
// THE DEFECT THIS SLICE EXISTS FOR, in its first direction. Two contexts of one session that
// do NOT share a group have independent name spaces, so the same handle value in both is
// legal and means two different objects. One process-wide table answered both handles with
// one twin, and the second context was handed the first one's driver ids.
TEST_F(TwinOwnershipTest, TheSameHandleInTwoNonsharedGroupsIsTwoTwins) {
const P::MGPipeHandle handle{7u, 1u};
RealTable& table = GL::SamplerViewImpl::g_backendSamplerViews;
BindKey(kSessionA, kGroupOne);
EXPECT_EQ(table.FindByHandle(handle), nullptr) << "the group began with a twin it never built";
Twin* const first = Mint(table, handle, 11u);
ASSERT_NE(first, nullptr);
BindKey(kSessionA, kGroupTwo);
EXPECT_EQ(table.FindByHandle(handle), nullptr)
<< "a context of a group the object was NOT made in resolved the other group's twin - the "
"same {slot, gen} in two name spaces is two objects, and this answers one";
Twin* const second = Mint(table, handle, 22u);
ASSERT_NE(second, nullptr);
EXPECT_NE(first, second) << "two groups that do not share were given one twin";
// AND THE FIRST GROUP'S OBJECT IS UNTOUCHED - the direction the old shape fails hardest,
// because the second create overwrote the first twin's contents in place.
BindKey(kSessionA, kGroupOne);
const MobileGL::SharedPtr<Twin>* const back = table.FindByHandle(handle);
ASSERT_NE(back, nullptr) << "returning to the first group lost its twin";
EXPECT_EQ(back->get(), first) << "a twin was minted for a group that already had one";
EXPECT_EQ((*back)->SyncedSerial, 11u)
<< "the other group's sync rewrote this group's twin - the two contexts were one name space";
EXPECT_EQ(table.BucketCount(), 2u) << "two groups resolved to one bucket";
}
// THE SECOND DIRECTION, and the half a literal per-context keying would break: two contexts
// that DO share a group must see ONE set of objects. That is what makes
// `eglCreateContext(share = ...)` mean anything at the driver.
TEST_F(TwinOwnershipTest, TwoContextsOfOneGroupShareTheGroupsTwins) {
const P::MGPipeHandle handle{9u, 1u};
RealTable& table = GL::SamplerViewImpl::g_backendSamplerViews;
BindKey(kSessionA, kGroupOne);
Twin* const first = Mint(table, handle, 33u);
// The same group, reached by the sibling context. Same key, therefore same bucket: the
// resolver's group half is what decides, not the context that happens to be current.
BindKey(kSessionA, kGroupOne);
const MobileGL::SharedPtr<Twin>* const found = table.FindByHandle(handle);
ASSERT_NE(found, nullptr)
<< "a sharing context cannot see the object its group already has - driver-level sharing "
"is impossible without this";
EXPECT_EQ(found->get(), first) << "the sharing context was given a twin of its own";
EXPECT_EQ((*found)->SyncedSerial, 33u) << "the sharing context resolved an empty object";
EXPECT_EQ(table.BucketCount(), 1u) << "one share group was split into two buckets";
}
// A SHARE GROUP DOES NOT CROSS A SESSION. Two served sessions are two connections and no
// driver id crosses between them, so the same group token and the same handle in both must
// be two objects - and a session's teardown must take ITS twins and no one else's.
TEST_F(TwinOwnershipTest, ASessionDropTakesOnlyThatSessionsTwins) {
const P::MGPipeHandle handle{5u, 1u};
RealTable& table = GL::SamplerViewImpl::g_backendSamplerViews;
BindKey(kSessionA, kGroupOne);
Twin* const inA = Mint(table, handle, 44u);
BindKey(kSessionB, kGroupOne);
EXPECT_EQ(table.FindByHandle(handle), nullptr)
<< "session B resolved the twin session A built under the same names";
Twin* const inB = Mint(table, handle, 55u);
EXPECT_NE(inA, inB);
// This is what DropEveryTwinForEndedServerSession now does, and the reason it is scoped:
// it runs on the apply thread of the session that is ENDING, while a neighbour session
// may still be rendering in this process (P14 S2).
EXPECT_EQ(table.DropBucketsOfSession(kSessionA), 1u) << "the ended session's group was not dropped";
BindKey(kSessionA, kGroupOne);
EXPECT_EQ(table.FindByHandle(handle), nullptr)
<< "the ended session still resolves a twin it built";
BindKey(kSessionB, kGroupOne);
const MobileGL::SharedPtr<Twin>* const survivor = table.FindByHandle(handle);
ASSERT_NE(survivor, nullptr)
<< "ending one session dropped another session's twin - the process-wide drop this slice "
"replaces would have done exactly that";
EXPECT_EQ(survivor->get(), inB);
}
// THE PROCESS-WIDE TABLE IS STILL THE ANSWER FOR EVERY THREAD THAT BELONGS TO NO SESSION,
// and a session's twins must not leak into it or out of it. This is the property the whole
// single-context world - the monolith, the client process, every unit case - depends on.
TEST_F(TwinOwnershipTest, AThreadWithNoSessionStillGetsTheProcessWideTwinTable) {
const P::MGPipeHandle handle{3u, 1u};
RealTable& table = GL::SamplerViewImpl::g_backendSamplerViews;
t_sessionKey = 0;
t_shareGroupKey = 0;
GL::TwinKey key;
EXPECT_FALSE(GL::CurrentTwinKey(&key)) << "the resolver claimed a thread that is no session's";
EXPECT_EQ(key.SessionKey, 0u);
EXPECT_EQ(key.ShareGroupKey, 0u);
Twin* const processWide = Mint(table, handle, 66u);
BindKey(kSessionA, kGroupOne);
EXPECT_EQ(table.FindByHandle(handle), nullptr)
<< "a served context resolved the process-wide twin";
EXPECT_NE(Mint(table, handle, 77u), processWide);
t_sessionKey = 0;
const MobileGL::SharedPtr<Twin>* const back = table.FindByHandle(handle);
ASSERT_NE(back, nullptr);
EXPECT_EQ(back->get(), processWide) << "the process-wide bucket followed the session's";
}
// THE SIX REGISTRIES ARE THE SAME SHAPE. The cases above drive the buffer/sampler-view
// table, which IS a SlotTableRegistry; the six TwinRegistry registries reach the same
// resolution through their own forwarding members, and this is what pins that they do -
// including the generation gate, which must still refuse a handle from a slot's past.
TEST_F(TwinOwnershipTest, TheSixTwinRegistriesResolvePerGroupToo) {
RegistryShapedTable table;
const P::MGPipeHandle handle{21u, 1u};
BindKey(kSessionA, kGroupOne);
MobileGL::SharedPtr<Twin>* slot = table.GetOrCreateByHandle(handle);
ASSERT_NE(slot, nullptr);
*slot = MobileGL::MakeShared<Twin>();
(*slot)->SyncedSerial = 88u;
Twin* const first = slot->get();
ASSERT_EQ(table.LiveGenAt(handle.Slot), handle.Gen);
BindKey(kSessionA, kGroupTwo);
EXPECT_EQ(table.FindByHandle(handle), nullptr)
<< "the six-registry shape is still one process-wide table";
EXPECT_EQ(table.LiveGenAt(handle.Slot), 0u) << "the other group's slot reads as live";
MobileGL::SharedPtr<Twin>* const other = table.GetOrCreateByHandle(handle);
ASSERT_NE(other, nullptr);
*other = MobileGL::MakeShared<Twin>();
(*other)->SyncedSerial = 99u;
EXPECT_NE(other->get(), first);
EXPECT_EQ(table.BucketCount(), 2u);
BindKey(kSessionA, kGroupOne);
MobileGL::SharedPtr<Twin>* const back = table.FindByHandle(handle);
ASSERT_NE(back, nullptr);
EXPECT_EQ(back->get(), first);
EXPECT_EQ((*back)->SyncedSerial, 88u);
// The generation gate is per bucket, because a bucket is a name space: a handle behind
// THIS group's live generation is still refused.
const P::MGPipeHandle stale{handle.Slot, handle.Gen - 1u};
EXPECT_EQ(table.FindByHandle(stale), nullptr) << "a stale generation resolved a live twin";
EXPECT_EQ(table.DropBucketsOfSession(kSessionA), 2u);
EXPECT_EQ(table.BucketCount(), 0u);
}
} // namespace
@@ -132,7 +132,7 @@
1. **native tuple per context**。`g_Display` 保留进程级(native display 一次 initialize 合理);`g_Context`/`g_Surface`/`g_Config`(`DirectGLES.cpp:16183-16186`)改为 per-(session, contextSlot) 表,native context 按 share group 用 `eglCreateContext(share=…)` 建。
2. **拆掉无条件 teardown**。`InitDisplayAndContext` 首句 `DestroyEGLContext()`(`DirectGLES.cpp:16569`)与 `BackendObject_DirectGLES` 建 surface 前的 `DestroyEGLContext()+ResetEGLRuntimeState()`(`BackendObject_DirectGLES.cpp:972-982`、`:1007-1014`)改为:目标 context 已存在则复用其 native context,仅换绑 surface;pbuffer→window→pbuffer 不丢资源。`DestroyEGLContext` 只拆指定 context;最后一个 context 走了才 `eglTerminate`。
3. **Managers 按 share group / context 分表**。六个 twin registry(`Managers.cpp:6895`、`:11035`、`:12835`、`:13188`、`:16255`、`:16580`)按 share group 实例化;`g_activeTextureUnit`/`g_boundTexturesCache`/sampler cache(`Managers.cpp:11031-11032` 等)按 context 实例化;`DropEveryTwinForEndedServerSession`(`Managers.cpp:425-443`)只清本 session。`g_backendContextGeneration` 改为 per-context 世代。
3. **Managers 按 share group / context 分表**。六个 twin registry(`Managers.cpp:6895`、`:11035`、`:12835`、`:13188`、`:16255`、`:16580`)按 share group 实例化;`g_activeTextureUnit`/`g_boundTexturesCache`/sampler cache(`Managers.cpp:11031-11032` 等)按 context 实例化;`DropEveryTwinForEndedServerSession`(`Managers.cpp:425-443`)只清本 session。`g_backendContextGeneration` 改为 per-context 世代。**(twin 键与 per-session 清理已落地,见 S6;单元影子与 context 世代仍欠。)**
4. **DirectVulkan 同步审视**。Magma 的 per-device/per-queue 结构也要按 session 分;本阶段先保证不编译退化,多 session 下 Magma 的验收单列。
### S4 已落地形状(每 context 原生 tuple + share-group 键控)
@@ -152,7 +152,7 @@
**本切片明确没做(下一层的账)**:
- **`g_applier` 没有改成 {shareGroup → applier}**(上面 server 第 3 条)。原因不是时间,是**形状**:`MGPipeApplierState` 把「share group 的对象记录」和「每 context 的 working state」混在一个结构里(`PipeApply.h:515-933`,其自身注释第 101 行已指出该二分),照字面把它按 share group 键控会让**同一 share group 的两个 context 共用 working state**——bindings、current program、unit 集互相覆盖,比不改更错。正确的做法是先按该二分拆结构(对象表进 share group、working state 进 context),那是独立的一刀。今天 `g_applier` 与 `gMGPipeApplierReset` 逐字未动。**(S5 已做,见下节。)**
- **六个 twin registry 与 twin 键 `{shareGroup, slot, gen}` 未改**(`Managers.cpp`)。twin 仍按引用活着的后端 context 复用,多 context 下的正确性依赖「一个原生 context 一个 twin 世代」,这在两个 context 都活着时成立(本次测到的),在跨 context 迁移 twin 的路径上还没有证据。**(S5 仍未做,见 S5 的未做项。)**
- **六个 twin registry 与 twin 键 `{shareGroup, slot, gen}` 未改**(`Managers.cpp`)。twin 仍按引用活着的后端 context 复用,多 context 下的正确性依赖「一个原生 context 一个 twin 世代」,这在两个 context 都活着时成立(本次测到的),在跨 context 迁移 twin 的路径上还没有证据。**(S5 仍未做;S6 已做,见「S6 已落地形状」。)**
- **share group 的驱动级证明未落地**。`eglCreateContext(share=…)` 已接线,但用 headless EGL 直接问 `glIsBuffer` 的探针在本机没有得到可用的控制(同 context 内 `glGenBuffers` 后 `glIsBuffer` 即返回 false),没有作为「跑了个空的绿」收进来。要证到驱动层,需要先弄清该探针,再补一个用例。**(S5 也没弄清:见 S5 的未做项与「为什么没做」。)**
- **Wire 第 1 条的逐记录 `ContextSlot` 未做**,仍是 S1 的边沿宣告 + `bind_context`。
- **`g_backendContextGeneration`/`g_syncContextGeneration` 仍是进程级**:销毁单个 context 会推进全局世代,其他 context 的旧 fence/query 句柄因此读作「已 signal」。安全方向(最坏是多等),但不是每 context 世代,属 P14b/c。
@@ -201,13 +201,56 @@ S4 留下的最后一块:`PipeApply.cpp` 的进程级 `g_applier` 现在是一
**S5 明确没做(下一层的账)**:
- **六个 twin registry 与 twin 键仍未改**(`MG_Backend/DirectGLES/Managers.cpp` 的六个 `g_backend*` 静态注册表;键在 `Managers.h:315` 的 `StateBackendObjectRegistry` → `SlotTables.h` 的 `BackendSlotTable<MGPipeHandle{Slot,Gen}>`)。**这是 S5 之后"两个 nonshared context 的同名 handle"剩下的那一半**:applier 里两条记录是对的(有测试),但 twin 层按 `{slot, gen}` 键,两个 context 的同一个 handle 值会解析到同一个 twin;反向也一样,跨 context 复用 twin 的路径没有按 share group 分组。S4 报告里那条 `glIsBuffer` 探针本机仍无可用控制,所以**驱动级的 shared / nonshared 证据仍然是缺的**,本次没有以"跑了个空的绿"收进来(这是本切片最该被下一刀补上的证据)。改形状是把 `BackendSlotTable` 加一个 group 维度或按组实例化那六个注册表,触及 `Managers.cpp` 里全部调用点,不是本轮时间能稳妥做完的一刀。
- **六个 twin registry 与 twin 键仍未改**(`MG_Backend/DirectGLES/Managers.cpp` 的六个 `g_backend*` 静态注册表;键在 `Managers.h:315` 的 `StateBackendObjectRegistry` → `SlotTables.h` 的 `BackendSlotTable<MGPipeHandle{Slot,Gen}>`)。**这是 S5 之后"两个 nonshared context 的同名 handle"剩下的那一半**:applier 里两条记录是对的(有测试),但 twin 层按 `{slot, gen}` 键,两个 context 的同一个 handle 值会解析到同一个 twin;反向也一样,跨 context 复用 twin 的路径没有按 share group 分组。S4 报告里那条 `glIsBuffer` 探针本机仍无可用控制,所以**驱动级的 shared / nonshared 证据仍然是缺的**,本次没有以"跑了个空的绿"收进来(这是本切片最该被下一刀补上的证据)。改形状是把 `BackendSlotTable` 加一个 group 维度或按组实例化那六个注册表,触及 `Managers.cpp` 里全部调用点,不是本轮时间能稳妥做完的一刀。**(S6 已做,见下节;驱动级探针仍未弄清,S6 走的是 twin 身份这条替代证据。)**
- **pbuffer→pbuffer→pbuffer 的资源保留**只到 S4 已落地的程度(`ServerLoopEglTest.CreatingAnotherSurfaceKeepsTheSameNativeContext`:换 surface 不动 tuple 的 `EGLContext` 句柄)。真正的"三个 pbuffer 之间来回且资源都在"要 window 半边,本机 headless 验不了;按 S4 的口径只记录。
- **wire 第 1 条的逐记录 `ContextSlot`** 仍未做,仍是 S1 的边沿宣告 + `bind_context`。所以"同一 ring 上两个 context 的记录交错"只在 client 按 make-current 顺序发射时成立(本轮的交错压力用例走的正是这个顺序)。
- **`MGPipeApplierShareGroupCountForTesting`** 是测试观测面,不是生产 API。
- **`MGPipeApplierState` 的九张表名与 `Objects.X` 两种拼法并存**是刻意换取 ~200 个读点不动的代价;改名的机会在 twin registry 一起做的那一刀。
- **`g_backendContextGeneration`/`g_syncContextGeneration`、ServerLoop 的 MakeCurrent 去重**同 S4 一节所列,仍未动。
### S6 已落地形状(twin 表按 {session, shareGroup} 键控)
S5 留下的最后一块:八个后端 twin 表(六个 `StateBackendObjectRegistry`,加 `BackendBufferResourceTable` 与 `BackendSamplerViewTable`)从 `{slot, gen}` 的进程级表变成**按 `{session, shareGroup}` 分桶**的表。这是「两个 nonshared context 的同名 handle」剩下的那一半——applier 的记录已经分开了(S5),但记录指向的 twin 还是一条。
**键结构(`MG_Backend/DirectGLES/DirectGLES.h`)**。`TwinKey{ SessionKey, ShareGroupKey }`,与 applier 的 `MGPipeApplierKey` 是**同一个二元组的后一半**:`SessionKey` 是 `ServerSession*` 的地址(S5 注册时用的也是同一个值,两侧按构造一致),`ShareGroupKey` 走 `MGPipeApplierShareGroupKeyFor(session, currentToken)`——**共享一个函数而不是各写一遍规则**,因为记录与它描述的 twin 必须永远落在同一个桶里,而「0 表示这个 context 自己起了一个组」的第二处拼写正是它们会分开的原因。`{0,0}` 保留给「不属于任何 session 的线程」,也就是被替换掉的进程单例。
**解析链(`MG_Remote/Server/SessionRuntime.cpp`)**。第三层同构探针:`ResolveThreadTwinKey` 读 `ThreadSessionScope` 装好的 `t_runtime` → `runtime->Session().CurrentContextToken()` → `MGPipeApplierShareGroupKeyFor`;由 `InstallSessionRuntimeHooks` 用 `SetTwinKeyResolver` 装一次(与 S2 的 PipeInputs 探针、S4 的 native tuple 键、S5 的 applier 键并列)。没有探针的线程(monolith、纯客户端、inproc 单进程形状、单元用例)答案恒为 `{0,0}`。**探针只读 server session 侧的状态,不碰 `MG_State`**:`CurrentContextToken()` 是 apply 线程自己维护的原子,`MGPipeApplierShareGroupKeyFor` 读的是 applier 的注册表,所以 apply 线程那条「禁止前端键控查找」的约束(`Managers.h:341/406/430-436`)原样成立。
**表形状(`SlotTables.h` 的 `SlotTableRegistry`)**。桶是 `SharedPtr<BackendSlotTable<…, kKind>>`,按 key 存在一张 unordered_map 里,带一个「上次的 key → 桶」的线程本地 memo(热路径是一次比较)。**~150 个调用点一行没动**:`SlotTableRegistry` 把表上每一个 handle-keyed 入口(`GetOrCreate` / `FindByHandle` / `LiveGenAt` / `HandleOf` / `StateForHandle` / `NoteStateForHandle` / `ReleaseByHandle` / `ForEachLive` / `LiveCount` / 两个 capacity 观测)按**调用线程的桶**转发一遍,所以 `g_backendTextureObjects.FindByHandle(h)` 还是那行字,答的却是本 context 的名字空间;`StateBackendObjectRegistry` 内同样把 `m_slotTable` 换成 `m_slotTables` 并转发。两处刻意的取舍:
- **holder 列表仍是进程级**。notice 是关于**对象**的,不是关于桶的:`OnFrontendObjectDestroyed` / `ReleaseTwinByHandle` 必须走到 fixture 或 context reset 持有的 by-value 拷贝,无论它在哪个桶里——「哪个桶持有它」正是当初造成双 holder 泄漏的那个问题。每个共享 holder 就是一个桶,所以死亡通知天然覆盖全部桶。
- **`ForEachLive` 走调用线程的桶**,不是全部桶。调用者问的是「我能看见的对象里哪些需要这个」,而它能看见的就是它自己的 share group;遍历全部桶会走到邻居 session 的对象。需要全进程视野的两处(死亡、诊断)走新的 `ForEachBucket(fn)`(回调带 key)。
**新增的控制面**:`Drop(key)`(一个组)、`DropSession(sessionKey)`(一个 session 的全部组)、`ResetBuckets()`(全部,即 `= {}` 在旧形状下的意思)、`BucketCount()` / `BucketSnapshot()` / `BucketForCallingThread()`(测试与 fixture 的观测面)。`BackendSlotTable::Reset()` 是「把两个空间先移出去再析构」的写法,理由与 `ReleaseTwinAt` 相同:twin 的析构是驱动调用,可能重入 `GetOrCreate`。
**`DropEveryTwinForEndedServerSession` 变成 per-session**(`Managers.cpp`)。它跑在**正在结束的那个 session 的 apply 线程**上,而 S2 之后同一个进程里可能有别的 session 还在渲染,所以现在是「按调用线程的 session 删它全部桶」;没有 session 的线程(单元用例、`SanityTest` 的 `Transport=Spawn` 形状、fork worker)执行全量清理,与旧形状逐字相同。`g_boundTexturesCache` / `g_boundSamplersCache` / `g_activeTextureUnit` 这三个单元影子仍是进程级、仍在这里整体清空(见下面的欠债)。
**`Managers.h:2190` 的注释一并改正**:那里过去写着「a texture is owned by exactly one context (share groups are not implemented)」。真正成立的说法是**texture 属于一个 share group**(S3 的 `ShareGroupState` 持有对象表,S6 之后 twin 表也按组键控),同组两个 context 都会合法地改它;`m_syncedShapeContextId` 因此只是廉价门禁的一半——同组的另一个 context 改了 shape 会 stamp 一个新 id 并(多余地、但安全地)重推一次,而真正兜住变化的是那几个 per-mutation version,它们与 context 无关。
**验收证据**(本次新增):
- `MG_Test/Wire/TwinOwnershipTest.cpp`,5 个用例,直接驱动**真实** twin 表(无 EGL、无 wire):同 session 两个不共享的组、同名 handle 是两个 twin 且互不覆盖;同组两个 context 解析**同一个** twin(驱动级共享在 twin 层的等价物——twin 就是持有 driver id 的那个对象);一个 session 的 `DropBucketsOfSession` 只带走自己的 twin,邻居 session 的不受影响;不属于任何 session 的线程仍拿进程级那张表且与 session 的互不串;第六个用例在 `TwinRegistry` 模板(六个注册表同型)上重复前三条并顺带钉住 per-bucket 的世代门禁。
**负控**(必做):把 `SlotTableRegistry::ForCallingThread` 改成恒答 `{0,0}`(即恢复 S6 前的进程级表),5 个用例里**4 个变红**;`TwoContextsOfOneGroupShareTheGroupsTwins` 保持绿——一个键时两种形状答案相同,这正是该用例断言的是「共享必须发生」而不是「存在两个桶」的原因。改回即全绿。
- 既有集合零回归:本切片点名的 103 个用例(`EmbeddedServerTest`、`ServerDisplayTest`、`UnixSupervisor`、`InProcessServer`、`SupervisorChildren`、`ServerLoopTest`、`ServerLoopEglTest`、`MultiSessionFixture`、`PeerLatch`、`SurfaceControlFrameTest`、`ContextStateOwnershipTest`、`ApplierOwnershipTest`)全绿,其中 `MultiSessionFixture` 的两个真渲染用例现在跑在按 session 隔离的 twin 表上。
- **全量 host ctest 与基线的逐名对比**(同 S5 的做法:`git stash push -u -- MobileGL docs` 掉本切片、重建、跑全量留失败名单,恢复后再跑一遍,两份名单按**用例名**`comm` 而不是按行号——本切片多了一个测试文件,行号会整体错位):
| | 数量 |
|---|---|
| 基线失败 | 277 |
| 本切片失败 | 278 |
| **本切片新增失败** | **0** |
| 基线失败而本切片不失败 | 0 |
两份名单的差集只有一个名字:`LogForwardChannel.APeerThatStopsReadingCostsWarnLinesNotTheCallersTime`,它**基线也抖**——同一台机器上单独重复跑,基线 4/60 失败、本切片 2/40 失败,两边的失败断言逐字相同(`LogForwardTest.cpp:446` 的 `Dropped` 行数与 `counters.lossyDropped` 不等、`:448` 的 `counters.notices == 2` 而非 1),是那个用例自己的日志通道竞态:它让对端停止读取然后断言「掉线通知只发一条」,而通知本身由后台线程按队列水位发。该用例不建 backend、不碰 twin,与本切片无关,按既有基线口径记为抖动。其余失败名单全部落在 glslang / 着色器编译族(`ProgramTest` 80、`ProgramInterfaceTest` 32、`AsyncLinkTest` 17、`GlslangCaptureProbeTest` 15、`ShaderCompileAdoptionTest` 14…),与 `3rdparty/glslang` 这个 dirty submodule 的既有基线一致。
**S6 明确没做(下一层的账)**:
- **单元影子仍是进程级**:`g_activeTextureUnit`、`g_boundTexturesCache`、`g_boundSamplersCache` 与 sampler cache(`Managers.cpp:11031-11032` 等)是 §3 第 3 条留给 P14b/c 的另一半。它们与 twin 键控**不冲突但也不互补**:缓存做的是指针同一性比较,所以按组分开的 twin 只会让「另一组的 twin」比较不相等并重新绑定(安全方向);真正的残留问题是「同一个 twin 在一次原生 context 切换之后被缓存命中而跳过绑定」,那在 S4 把原生 context 变成 per-context 时就已经存在,属于同一笔账。
- **驱动级的 `glIsBuffer` 探针仍未弄清**(S4/S5 都记录的同一件事)。S6 因此没有走「真驱动上问一句」那条路,而是按 S5 报告的允许口径在 twin 层断言身份:twin 就是持有 driver id 的对象,两个 context 拿到不同的 twin 就是两个 driver 对象。要做成驱动级证据,需要先把 S4 报告里「同 context 内 `glGenBuffers` 后 `glIsBuffer` 即返回 false」这个探针问题弄清楚。
- **`g_backendContextGeneration`/`g_syncContextGeneration` 仍是进程级**,同 S4/S5 一节所列。
- **`MGPipeApplierState` 的两种拼法(`Objects.X` 与表名)仍未收敛**;S5 说「改名的机会在 twin registry 一起做的那一刀」,S6 选择了不动它(那一刀的价值是整洁,而 S6 的 diff 已经在改 8 张表的行为,把重命名捆进来只增风险)。
- **wire 第 1 条的逐记录 `ContextSlot`** 仍未做。
## 分期与验收