diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 91ec2e78..415ce7d6 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -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 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; diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h index 648e0668..a259f416 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h @@ -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(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 diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index 7a426bad..e1ad0370 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -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 diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 463829f5..4d092db9 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -332,6 +332,11 @@ namespace MobileGL::MG_Backend::DirectGLES { using const_iterator = typename BackendMap::const_iterator; #if MOBILEGL_PIPE_PUSH using SlotTable = BackendSlotTable; + // 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; + 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 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> 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 + 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 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; + SlotTableRegistry; 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; extern BackendSamplerViewTable g_backendSamplerViews; diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h index 2290c68b..c146aa95 100644 --- a/MobileGL/MG_Backend/DirectGLES/SlotTables.h +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -11,6 +11,12 @@ #include +// 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 #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 + class BackendSlotTable; + + template + class SlotTableRegistry { + public: + using Table = BackendSlotTable; + using Key = TwinKey; + using StatePtr = SharedPtr; + using BackendPtr = SharedPtr; + // 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 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
fresh = MakeShared
(); + 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> Snapshot() const { + Vector> 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
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 = 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> all = Snapshot(); + m_buckets.clear(); + m_memoKey = Key{}; + m_memoTable = nullptr; + for (const SharedPtr
& 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 + 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 + 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, 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
m_memoTable; + mutable Key m_memoKey; + }; + template 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 slots = std::move(m_slots); + Vector 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) { diff --git a/MobileGL/MG_Pipe/PipeApply.cpp b/MobileGL/MG_Pipe/PipeApply.cpp index 45efb2ca..24cb8723 100644 --- a/MobileGL/MG_Pipe/PipeApply.cpp +++ b/MobileGL/MG_Pipe/PipeApply.cpp @@ -643,6 +643,18 @@ namespace MobileGL::MG_Pipe { return sessionIt == g_applierSessions.end() ? 0 : static_cast(sessionIt->second.Groups.size()); } + Uint64 MGPipeApplierShareGroupKeyFor(Uint64 sessionKey, Uint64 contextToken) { + std::lock_guard 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, diff --git a/MobileGL/MG_Pipe/PipeApply.h b/MobileGL/MG_Pipe/PipeApply.h index 109fa397..49d60087 100644 --- a/MobileGL/MG_Pipe/PipeApply.h +++ b/MobileGL/MG_Pipe/PipeApply.h @@ -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) // --------------------------------------------------------------------------------- diff --git a/MobileGL/MG_Remote/Server/SessionRuntime.cpp b/MobileGL/MG_Remote/Server/SessionRuntime.cpp index 72301170..5777a9e9 100644 --- a/MobileGL/MG_Remote/Server/SessionRuntime.cpp +++ b/MobileGL/MG_Remote/Server/SessionRuntime.cpp @@ -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(&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; } diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index 3d5040cf..e239ec81 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -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) diff --git a/MobileGL/MG_Test/Wire/TwinOwnershipTest.cpp b/MobileGL/MG_Test/Wire/TwinOwnershipTest.cpp new file mode 100644 index 00000000..34855e60 --- /dev/null +++ b/MobileGL/MG_Test/Wire/TwinOwnershipTest.cpp @@ -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 +#include + +// 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 + +#include + +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; + + 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& slot = table.GetOrCreate(handle); + slot = MobileGL::MakeShared(); + 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* 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* 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* 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* 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* slot = table.GetOrCreateByHandle(handle); + ASSERT_NE(slot, nullptr); + *slot = MobileGL::MakeShared(); + (*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* const other = table.GetOrCreateByHandle(handle); + ASSERT_NE(other, nullptr); + *other = MobileGL::MakeShared(); + (*other)->SyncedSerial = 99u; + EXPECT_NE(other->get(), first); + EXPECT_EQ(table.BucketCount(), 2u); + + BindKey(kSessionA, kGroupOne); + MobileGL::SharedPtr* 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 diff --git a/docs/Disaggregated/design/11-state-ownership.md b/docs/Disaggregated/design/11-state-ownership.md index 7e59f5f1..3782a862 100644 --- a/docs/Disaggregated/design/11-state-ownership.md +++ b/docs/Disaggregated/design/11-state-ownership.md @@ -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`)。**这是 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`)。**这是 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>`,按 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`** 仍未做。 + ## 分期与验收