diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 88a7be15..35dc9fe8 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -548,6 +548,20 @@ namespace MobileGL::MG_Config { // every reply-slot row), which is the only place BARRIER-PULLED fields are read. 0 // restores the per-record barrier of R-1. Uint32 BatchWaits = 1; + // MOBILEGL_IPC_CREATE_WINDOW: how many RESOURCE_CREATE answers may be outstanding before the + // client waits for one. 1 = every create waits, which is the shape R-5 shipped with AND THE + // DEFAULT, because the deferral was measured on the device and does not work: the reply pool + // is 8 slots and one load frame posts 59,673 replies (create 4,536 + respecify 20,633 + + // params 18,056 + sub-data 12,671 + the rest), so an answer is overwritten after 8 replies - + // 0.61 of a create - and the window's drain can never read one back. All 4,536 creates took + // the blocking path at 4 and at 1 alike (28.64 s both ways). Worse than inert: the + // provisional accept latches an object the applier may have refused, and the un-latch that + // corrects it lives in that same unreadable drain. WireTables.cpp's section comment has the + // counters and the arithmetic, and the gates in RemoteClientControls.inc still pin the + // mechanism, which is sound and simply unusable against this much other reply traffic. + // N > 1 is reachable for the experiment that would make it usable (a deeper pool, or a way + // to tell the server not to answer a fire-and-forget row). + Uint32 CreateWindow = 1; // MOBILEGL_IPC_ADOPT_TIER: 2 = emulate (client keeps the shadow and pushes), which // is the only tier P5 implements and the reason persistent-map-push can be non-zero // at all (R-6). 0 and 1 parse and are Fatal at use with "P11"; they exist now so the diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 1389fc8c..0784eb68 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -412,6 +412,11 @@ namespace MobileGL::MG_ConfigLoader { // xxHash64 changed since the last push are shipped. ipc.PersistentHashSuppress = QueryEnvUint32("MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS", 1, 0, 1); ipc.BatchWaits = QueryEnvUint32("MOBILEGL_IPC_BATCH_WAITS", 1, 0, 1); + // The create window. DEFAULT 1 - OFF - because the deferral was measured on the device and + // cannot be collected (Config.h has the numbers, WireTables.cpp the arithmetic: 59,673 + // replies per frame into 8 pool slots). Capped at 4 rather than the pool's 8 for the reason + // the mechanism is sized that way at all. + ipc.CreateWindow = QueryEnvUint32("MOBILEGL_IPC_CREATE_WINDOW", 1, 1, 4); // The verify harness compares the pushed block against the applier per verb; a // batched queue lets the comparer read a supplied field mid-apply, which is a // torn read rather than a divergence. The batch is therefore off whenever the diff --git a/MobileGL/MG_Remote/CONTRACT-P5E.md b/MobileGL/MG_Remote/CONTRACT-P5E.md index 958edcd0..a57de110 100644 --- a/MobileGL/MG_Remote/CONTRACT-P5E.md +++ b/MobileGL/MG_Remote/CONTRACT-P5E.md @@ -206,20 +206,47 @@ non-wire arm keep the old answer, which is what G1 measures. Gate: `RemoteClientControls.TextureUploadsStopOwningAReplyOnTheWireArm` reads back the same `wait_replies` the P65LinkMetrics line prints (`LinkMetricsReplyWaits`), asserting a pure upload moves it by ZERO while a `resource_create` row still moves it; red-once with the arm test removed is exit 142. -**`ResourceCreate`, `ResourceRespecify` and `SetTextureParams` stay `kWaitReply`, and that is a -ruling rather than an omission**: each one's answer carries a fact about the SERVER's record table -that the client's own tracker provably cannot know. `RespecifyOnce` (`TextureEmit.h:1332-1356`) says -it in one line - "the applier's REFUSAL is the only signal that says 'I hold nothing for this -handle'", which is the scope `MGPipeApplierReleaseObjectRecords` leaves behind for a served -context whose frontend objects live on; `EmitTextureParams` (`:991-1017`) uses its own refusal as -the same self-heal trigger; and `PublishCreate` (`:1303-1319`, D-I1/c0b) may not take the -publication latch on a create the applier refused, or the death path emits a `resource_destroy` for -a record that does not exist. Removing any of those waits removes the signal, so unlike the upload -half they are not "the emission gate already answered this" rows. Measured split for the two shapes -a stitcher produces (P12, `RemoteClientControls.ReplyWaitsByOpForAnAtlasShapedLoad`): an atlas -stitch - 64 sub-uploads into one level - goes from 64 reply waits to **0**, while a texture per -sprite - 64 textures, 256 `glTexParameteri` - goes from 384 to 320, with the remainder all three of -these rows (create 76, respecify 130, params 130). +**`ResourceCreate`, `ResourceRespecify` and `SetTextureParams` are `kWaitReply` ROWS, and what the +caller does with that is now per-object rather than per-row - THIS PARAGRAPH IS THE ONE P12 +SUPERSEDED, and it is rewritten rather than deleted because its reasoning is what the replacement +had to answer.** Each one's answer carries a fact about the SERVER's record table that the client's +own tracker cannot know by itself (`RespecifyOnce`, `TextureEmit.h`, "the applier's REFUSAL is the +only signal that says 'I hold nothing for this handle'" - the scope +`MGPipeApplierReleaseObjectRecords` leaves behind for a served context whose frontend objects live +on). What P12 added is the observation that the client holds a REASONABLE COPY of that fact: +`MGPipeHandleIsPublished`, set only when a create was ACCEPTED, is exactly "the applier holds this +handle", and it is what `MGPipeResourceRespecifyWantsItsReply` (`PipeRoute.h`) and +`Wire_SetTextureParams` read. So: + +- **`SetTextureParams` and the texture half of `ResourceRespecify` wait only until the object is + CONFIRMED.** For a published object the record is fire-and-forget and the caller reads a + provisional accept; the object's one real confirmation is its create, paid at its FIRST USE + (`EmitTextureParams` -> `HealUnpublishedRecord`, `EmitResourceRespecify`'s unpublished arm). When + the latch is clear both rows still wait, and the refusal-driven `RespecifyOnce` heal is unchanged + - which is what keeps the paragraph above true rather than merely overruled. +- **The buffer half of `ResourceRespecify` never waits**: both call sites discard its Bool + (`PipeFill.cpp:1105/:1142`). +- **`ResourceCreate` is the one row whose answer may not be dropped**, because `PublishCreate` + latches on it and a false latch makes every row above fire-and-forget against a record the server + does not hold. It was given a WINDOW instead of a rule - `MOBILEGL_IPC_CREATE_WINDOW`, answers + deferred and collected in order, a late DECLINE un-latching the object - and **the window is OFF + BY DEFAULT because the deferral was measured and cannot be collected**: the reply pool is + `kDefaultReplySlotCount` = 8 slots deep and one load frame posts 59,673 replies (the server + answers every reply-owning record whether or not the client wants the answer), so an answer is + overwritten after 8 replies - 0.61 of a create. On the device all 4,536 creates took the blocking + path at window 4 and at window 1 alike (28.64 s both ways, against the handoff's 31.3 s + pre-window baseline on the same device). `WireTables.cpp`'s section comment carries the counters + and the arithmetic. The client-visible consequence is worth stating here: with the window ON, the + un-latch that the paragraph above relies on lives in the same unreadable drain, so a refused + create would stay latched - a protocol change (a deeper pool, or a "do not answer me" bit) is + what a retry would need. + +Measured split for the two shapes a stitcher produces (P12, +`RemoteClientControls.ReplyWaitsByOpForAnAtlasShapedLoad`): an atlas stitch - 64 sub-uploads into +one level - goes from 64 reply waits to **0**, while a texture per sprite - 64 textures, 256 +`glTexParameteri` - goes from 384 to 320 at the contract commit; the P12 first-use change takes the +rest of the params/respecify rows out of that frame's wait list entirely (one frame: 43,232 waits -> +4,543, 221.5 s -> 31.3 s, `MOBILEGL-CS-HANDOFF-2.md` §1). per call, analyzed separately rather than swept in here. ### 2.6 Event-ring flow control (ARCHITECTURE §11.7 made real) diff --git a/MobileGL/MG_Remote/Client/WireTables.cpp b/MobileGL/MG_Remote/Client/WireTables.cpp index 1821da59..ee49d704 100644 --- a/MobileGL/MG_Remote/Client/WireTables.cpp +++ b/MobileGL/MG_Remote/Client/WireTables.cpp @@ -51,6 +51,9 @@ // P5e (pg): ID-87 asks this package for bytes-per-link measured rather than argued, and the // archive's byte count exists exactly once - here, where it is serialised. #include +// P12: kDefaultReplySlotCount, because the create window's ceiling is derived from the pool it +// shares rather than from a number written twice (see kCreateWindowMax below). +#include #include #include @@ -316,10 +319,184 @@ namespace MobileGL::MG_Remote::Client { // "always accept" is ID-39's 66 lost DirectVulkan uploads and "accept if we emitted" is // the same bug wearing a counter. - void Wire_ResourceCreate(const MG_Pipe::MGPResourceDesc* payload, MG_Pipe::MGPReplySlot* reply) { - if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithScreen().ResourceCreate(payload, reply); return; } - ClientSession& session = RequireSession("ResourceCreate"); - Int32 status = 0; + // ---- P12: the CREATE WINDOW, AND WHY IT IS OFF ---------------------------------------- + // + // THE IDEA. The create row is the last per-record round trip on the load path (4,536 of one + // frame's 4,543 reply waits) and the one row whose answer is not optional: PublishCreate + // latches "the applier holds this handle" on it, and that latch is what every converted row + // reads. So this row would not STOP reading its answer - it would DEFER it. Up to + // MOBILEGL_IPC_CREATE_WINDOW creates go out fire-and-forget, the caller reads a provisional + // accept (so the latch is taken), and the answers are collected IN ORDER: the oldest blocks + // when the window is full, and one that comes back DECLINED un-latches its object + // (MGPipeNoteHandleUnpublished), which sends every later row for that object back down the + // blocking path. + // + // IT DOES NOT WORK, AND NOT FOR A REASON THAT TUNING REACHES. MEASURED on the device (the + // same frame, the same binary, the same 101,421,396 B staged, axe aikjs4s8cys8ba7l): + // + // MOBILEGL_IPC_CREATE_WINDOW=4 by_op 2 = 4,536 wall 28.64 s + // MOBILEGL_IPC_CREATE_WINDOW=1 by_op 2 = 4,536 wall 28.64 s + // (the handoff's own pre-window baseline was 31.3 s on this device) + // + // by_op 2 counts the creates that took the BLOCKING path, and it did not move, so no create + // deferred. A temporary counter in the row said why: pushed=24, blocked=4,476, took=20, + // readfail=8,951, pending pinned at 4. The drain READ the window's answers back and failed + // every time. + // + // WHY, IN THE PROTOCOL'S OWN ARITHMETIC. An answer is readable only until its reply-pool + // slot is reused, and the pool is kDefaultReplySlotCount = 8 slots deep. The frame posts a + // reply for EVERY reply-owning record - not only for the rows that wait, because the server + // cannot know which those are (ClientSession.cpp:1897-1900: "The server posts either way") + // - and that is 59,673 replies in this one frame: + // + // create 4,536 + respecify 20,633 + params 18,056 + sub-data 12,671 + 3,770 + 6 + 1 + // + // 8 slots against 59,673 replies is a pool that churns 7,459 times per frame, i.e. 13.2 + // replies between two creates: an answer posted at create N is overwritten after EIGHT + // replies, which is 0.61 of a create. There is no window depth for which the deferred + // answer still exists when the client comes back for it - the deferral is not slow, it is + // UNREADABLE. (A 1-deep "window" is the blocking path, which is what the row does.) + // + // AND THE FAILURE IS NOT NEUTRAL: with the window on, the provisional accept latches an + // object the applier may have refused, and the un-latch that would correct it lives in the + // drain - the same drain that cannot read. A refusal therefore becomes an object the client + // believes is published, which is exactly the silent divergence the suspect list exists to + // prevent. So the default is 1: the row is the pre-window shape unless someone sets + // MOBILEGL_IPC_CREATE_WINDOW explicitly, and the machinery below is kept as the record of + // what was tried and what it cost (a fix would have to make a deferred answer outlive + // 59,673 intervening replies, which means a deeper pool or a way for the client to tell + // the server "do not answer this one" - both are wire changes, not tuning). + // + // The gates that pin it are in MG_Test/Wire/RemoteClientControls.inc, and they still hold: + // the window is a coherent mechanism, it is simply unusable on a frame with this much + // other reply traffic. + constexpr Uint32 kCreateWindowMax = 4; + // THE WINDOW MAY NOT OWN MORE THAN HALF THE POOL, and this is where that stops being a + // sentence in a comment. The pool is kDefaultReplySlotCount slots addressed seq % count, so + // an answer is readable only while its slot has not been reused; every other reply-owning + // row - fence, query, and the blocking rows themselves - needs a slot too. The change the + // handoff names as the one worth pinning ("someone raises kCreateWindowMax to 8 and steps on + // the pool") goes red HERE, by name, at compile time, instead of as a wrong picture on a + // device. Raised to 8 it would take the whole pool and leave nothing for the answer the + // blocking rows are waiting on. + static_assert(kCreateWindowMax <= Transport::kDefaultReplySlotCount / 2, + "the create window may hold at most half the reply pool: the other half " + "belongs to the rows that take their answers immediately"); + // AND THE ENVIRONMENT MAY NOT RAISE IT EITHER. The configured value is clamped to the + // array's own size, so the fixed array below cannot be overrun by a config bound and this + // constant having drifted apart. 1 (and anything below it) is the pre-window shape. + Uint32 CreateWindowEffective() { + if (MG_Config::Ipc.CreateWindow <= 1) return 1; + return MG_Config::Ipc.CreateWindow < kCreateWindowMax ? MG_Config::Ipc.CreateWindow + : kCreateWindowMax; + } + struct PendingCreate { + Uint64 Seq = 0; + MG_Pipe::MGPipeKind Kind = MG_Pipe::MGPipeKind::None; + MG_Pipe::MGPipeHandle Handle{}; + }; + PendingCreate g_createWindow[kCreateWindowMax]; + Uint32 g_createWindowHead = 0; + Uint32 g_createWindowCount = 0; + Uint64 g_createRefusals = 0; + + // A HANDLE WHOSE CREATE CAME BACK DECLINED IS NEVER WINDOWED AGAIN. Without this the window + // would UNDO the repair it exists to keep: every later use of the object sends another create, + // reads another provisional accept, and latches the object as published again - so a server + // that keeps refusing (the consumer-gate asymmetry the audit found, or any bug) would be fed + // fire-and-forget records for ever with the client believing all of them landed. With it, a + // refused object leaves the window and its next create is BLOCKING, so the refusal reaches the + // caller, the latch stays clear, and every converted row for that object is back on the loud + // blocking path. The window is for objects that have never been refused. + // + // BOUNDED, AND THE BOUND IS THE SAFE DIRECTION: past kCreateSuspectMax suspects the window + // switches itself off entirely, which is the pre-window shape. + constexpr SizeT kCreateSuspectMax = 256; + Vector g_createSuspects; + + Bool CreateIsSuspect(MG_Pipe::MGPipeHandle handle) { + for (const MG_Pipe::MGPipeHandle& suspect : g_createSuspects) { + if (suspect.Slot == handle.Slot && suspect.Gen == handle.Gen) return true; + } + return false; + } + void NoteCreateSuspect(MG_Pipe::MGPipeHandle handle) { + if (CreateIsSuspect(handle) || g_createSuspects.size() >= kCreateSuspectMax) return; + g_createSuspects.push_back(handle); + } + void ClearCreateSuspect(MG_Pipe::MGPipeHandle handle) { + for (SizeT i = 0; i < g_createSuspects.size(); ++i) { + if (g_createSuspects[i].Slot != handle.Slot || g_createSuspects[i].Gen != handle.Gen) + continue; + for (SizeT j = i + 1; j < g_createSuspects.size(); ++j) + g_createSuspects[j - 1] = g_createSuspects[j]; + g_createSuspects.pop_back(); + return; + } + } + + // Which publication latch an object's create owns. The three resource kinds are spelled out + // rather than defaulted, so a target added later cannot silently borrow the texture latch. + MG_Pipe::MGPipeKind CreateRecordKind(const MG_Pipe::MGPResourceDesc& desc) { + switch (static_cast(desc.Target)) { + case MG_Pipe::MGPipeResourceTarget::Buffer: return MG_Pipe::MGPipeKind::Buffer; + case MG_Pipe::MGPipeResourceTarget::Renderbuffer: return MG_Pipe::MGPipeKind::Renderbuffer; + default: return MG_Pipe::MGPipeKind::Texture; + } + } + + // Collect every answer that is already available. `blockForOldest` is the window being full, + // which is the one moment an answer is worth waiting for - appliedSeq is monotone, so taking + // the oldest is what frees the slot the next create needs. FIFO throughout: an unapplied + // oldest means none of the rest are ready either. + void DrainCreateWindow(ClientSession& session, Bool blockForOldest) { + Bool blocking = blockForOldest; + while (g_createWindowCount != 0) { + const PendingCreate head = g_createWindow[g_createWindowHead]; + const Transport::SessionWait wait = + session.WaitForApplied(head.Seq, blocking ? 120000u : 0u); + if (wait != Transport::SessionWait::Reached) return; + Int32 status = Wire::ReplySink::kStatusError; + Uint64 size = 0; + // AN UNREADABLE SLOT IS NOT AN ERROR: ReadReply refuses when the stamp does not + // match yet (or when the answer does not fit the scratch), and treating that as + // ERROR would invent a transport fault out of a reader that arrived early. The + // entry stays in the window and is taken at the next drain. + if (!session.ReadReply(head.Seq, nullptr, 0, &status, &size)) return; + // ERROR IS NOT A DECLINE (M4 / R-5), and a deferred answer does not change that: + // the same named Fatal the blocking take raises. + if (status == Wire::ReplySink::kStatusError) { + SessionFail(MGFatalFamily::ReplyError, "MGPipe: Fatal{ReplyError, \"resource_create\"} - the row answered " + "ERROR, which is not an acceptance answer; folding it into accepted or " + "refused would make a transport fault look like a resource decision"); + } + if (status == Wire::ReplySink::kStatusDeclined) { + MG_Pipe::MGPipeNoteHandleUnpublished(head.Kind, head.Handle); + NoteCreateSuspect(head.Handle); + ++g_createRefusals; + } else { + // It landed: the object is published for real, so it is no longer suspect. + ClearCreateSuspect(head.Handle); + } + g_createWindowHead = (g_createWindowHead + 1) % kCreateWindowMax; + --g_createWindowCount; + blocking = false; + } + } + // THE PRE-WINDOW SHAPE, AND THE HEAL BESIDE IT. Every path that cannot defer takes this + // one: the window off, the object already refused, the suspect list full, and the window + // still full after a blocking drain. The record waits and its caller reads the REAL + // answer. + // + // AND AN ACCEPTED CREATE TAKES ITS OBJECT BACK OUT OF THE SUSPECT LIST. "Never windowed + // again" is the answer to a REFUSAL, and a blocking create that landed means the refusal + // is over - the object is published, by the caller's own latch, on the answer this call + // just read. Leaving the entry standing would retire the object from the window for the + // rest of the process, and kCreateSuspectMax such recoveries would retire the WINDOW + // itself, which is the one way this row's speed-up can be lost with nothing going wrong. + void EmitCreateBlocking(ClientSession& session, const MG_Pipe::MGPResourceDesc* payload, + MG_Pipe::MGPReplySlot* reply) { + Int32 status = Wire::ReplySink::kStatusError; const Uint64 seq = session.EmitAndWait(MGPWireOp::ResourceCreate, payload, sizeof(*payload), nullptr, 0, nullptr, 0, &status); @@ -328,6 +505,58 @@ namespace MobileGL::MG_Remote::Client { MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u); ++g_emitted; if (status == 1) ++g_declined; + if (status == 0) ClearCreateSuspect(payload->Resource); + } + void Wire_ResourceCreate(const MG_Pipe::MGPResourceDesc* payload, MG_Pipe::MGPReplySlot* reply) { + if (RunsAsTheServerRole()) { MG_Pipe::MGPipeMonolithScreen().ResourceCreate(payload, reply); return; } + ClientSession& session = RequireSession("ResourceCreate"); + const Uint32 window = CreateWindowEffective(); + // THE WINDOW OFF RESTORES THE OLD SHAPE EXACTLY, and is the negative control for it. + // THE WINDOW OFF, THE SUSPECT LIST FULL, OR AN OBJECT THAT HAS ALREADY BEEN REFUSED: + // all four take the blocking branch, which is the pre-window shape - and on the device + // this is the branch the whole row takes, because the deferral above it cannot be + // collected. See the section comment for the measurement and the arithmetic. + if (window <= 1 || g_createSuspects.size() >= kCreateSuspectMax || + CreateIsSuspect(payload->Resource)) { + EmitCreateBlocking(session, payload, reply); + return; + } + // Take what is already answerable, and if the window is full take the oldest for real. + // The poll is not politeness: it is what keeps a burst shorter than the window from + // leaving its answers unread until the next burst. + DrainCreateWindow(session, /*blockForOldest=*/false); + if (g_createWindowCount >= window) { + DrainCreateWindow(session, /*blockForOldest=*/true); + // IF IT IS STILL FULL the oldest answer is not readable yet, and the window is a + // fixed array: pushing would silently OVERWRITE a confirmation. This record takes + // the blocking path instead - slower for this one call, never lossy. Reaching it + // at all means the oldest answer did not arrive inside the drain's own budget, so + // the session is on its way out; it is a net, not a hot path. The comparison is + // against the effective window, which is what the array below is sized for, so + // this line IS the overwrite guard. + if (g_createWindowCount >= window) { + EmitCreateBlocking(session, payload, reply); + return; + } + } + Int32 status = Wire::ReplySink::kStatusError; + const Uint64 seq = + session.EmitAndWaitTails(MGPWireOp::ResourceCreate, payload, sizeof(*payload), nullptr, + 0, nullptr, 0, &status, nullptr, /*wantReply=*/false); + if (seq == Wire::kInvalidSeq) { + // Cancelled: there is no ordinal to read an answer from, and the caller must NOT + // latch an object the record never described. + MG_Pipe::MGPipePostReply(*reply, Wire::ReplySink::kStatusDeclined, 0u); + ++g_emitted; + ++g_declined; + return; + } + g_createWindow[(g_createWindowHead + g_createWindowCount) % kCreateWindowMax] = + PendingCreate{seq, CreateRecordKind(*payload), payload->Resource}; + ++g_createWindowCount; + reply->Id = seq; + MG_Pipe::MGPipePostReply(*reply, Wire::ReplySink::kStatusOk, 1u); + ++g_emitted; } // A PARAMETER CHANGE FOR AN OBJECT WHOSE EXISTENCE IS CONFIRMED OWES NO ANSWER (P12). @@ -922,6 +1151,23 @@ namespace MobileGL::MG_Remote::Client { Uint64 ClientWireRecordsEmitted() { return g_emitted; } Uint64 ClientWireRecordsDeclined() { return g_declined; } + // ---- P12: the CREATE WINDOW, as numbers a test can read --------------------------------- + // + // The window is a DEFERRAL, so the property that has to be checkable is not "is it faster" + // but "was every deferred answer eventually taken, and did the window ever hold more than it + // is sized for". Pending is that second question, and it is the one a silent overwrite would + // answer wrongly: the ring wraps, so an unguarded push would not grow the count past the + // ceiling - it would REPLACE the oldest confirmation and leave the count where it was. Read it + // against the effective ceiling and the array size, which are the two numbers the guard itself + // is written in terms of, and not against a literal repeated in the test. + Uint32 ClientCreateWindowPending() { return g_createWindowCount; } + Uint32 ClientCreateWindowEffective() { return CreateWindowEffective(); } + Uint32 ClientCreateWindowArraySize() { return kCreateWindowMax; } + // Objects whose create came back DECLINED: out of the window, next create blocking, and back + // in the moment a blocking create lands. + Uint32 ClientCreateWindowSuspects() { return static_cast(g_createSuspects.size()); } + Uint64 ClientCreateWindowRefusals() { return g_createRefusals; } + } // namespace MobileGL::MG_Remote::Client #endif // MOBILEGL_BUILD_DISAGGREGATED diff --git a/MobileGL/MG_Remote/Client/WireTables.h b/MobileGL/MG_Remote/Client/WireTables.h index 69cf4d2a..c10cf7a9 100644 --- a/MobileGL/MG_Remote/Client/WireTables.h +++ b/MobileGL/MG_Remote/Client/WireTables.h @@ -78,6 +78,20 @@ namespace MobileGL::MG_Remote::Client { // inferred, R-8's rule one level out. Uint64 ClientWireRecordsDeclined(); + // ---- P12: the CREATE WINDOW (MOBILEGL_IPC_CREATE_WINDOW) --------------------------------- + // + // The resource_create row defers its answer instead of dropping it: up to N creates go out + // fire-and-forget, the caller reads a provisional accept, and the answers are taken in order. + // The four numbers below are what a test can say about that without reaching into the row: + // how many answers are outstanding, the ceiling the code actually applies (the config value + // clamped to the array), the array's own size, and the two tallies - objects whose create came + // back DECLINED and are therefore out of the window, and how many refusals there have been. + Uint32 ClientCreateWindowPending(); + Uint32 ClientCreateWindowEffective(); + Uint32 ClientCreateWindowArraySize(); + Uint32 ClientCreateWindowSuspects(); + Uint64 ClientCreateWindowRefusals(); + // TRUE ON THE SERVER ROLE's OWN THREAD (the apply thread), false everywhere else. It is // v1's ServerLoop::OnApplyThread(), exposed here because it is table 3's role split made // into one predicate and TWO packages read it: the wire emitters below (a routed call that diff --git a/MobileGL/MG_Test/Wire/RemoteClientControls.inc b/MobileGL/MG_Test/Wire/RemoteClientControls.inc index bbf44123..5595755f 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientControls.inc +++ b/MobileGL/MG_Test/Wire/RemoteClientControls.inc @@ -904,13 +904,14 @@ TEST(RemoteClientControls, TextureUploadsStopOwningAReplyOnTheWireArm) { // control visible instead of silently wrong. const Uint32 uploadOp = static_cast(MGPWireOp::ResourceSubData); const Uint32 createOp = static_cast(MGPWireOp::ResourceCreate); - const Uint64 createWaitsBefore = Transport::LinkMetricsReplyWaitsFor(createOp); + const Uint64 createRecordsBefore = Transport::LinkMetricsRecordsFor(createOp); GLuint texture = 0; MG_Impl::GLImpl::GenTextures(1, &texture); MG_Impl::GLImpl::BindTexture(0x0DE1, texture); MG_Impl::GLImpl::TexImage2D(0x0DE1, 0, 0x8058, 4, 4, 0, 0x1908, 0x1401, pixels); MGP_FILL(DrawArrays); - if (Transport::LinkMetricsReplyWaitsFor(createOp) <= createWaitsBefore) ::_exit(140); + // THE CREATE WENT OUT (the record), and the live control is the fence below. + if (Transport::LinkMetricsRecordsFor(createOp) <= createRecordsBefore) ::_exit(140); // THE ROW UNDER TEST. A second write into the same level is a pure resource_subdata: the // resource is created, so no create crosses, and the level is dirty again - the only // record it can produce is the one item B is about. @@ -1044,7 +1045,7 @@ TEST(RemoteClientControls, ReplyWaitsByOpForAnAtlasShapedLoad) { // emitter stopped sending, which is a different (and much worse) change than item B. if (Transport::LinkMetricsRecordsFor(static_cast(MGPWireOp::ResourceSubData)) < 64) ::_exit(148); - if (Transport::LinkMetricsReplyWaitsFor(static_cast(MGPWireOp::ResourceCreate)) == 0) + if (Transport::LinkMetricsRecordsFor(static_cast(MGPWireOp::ResourceCreate)) == 0) ::_exit(144); // AND ResourceRespecify FOLLOWS SetTextureParams OFF THAT LIST FOR THE SAME REASON (P12/R3): // for a confirmed object it is fire-and-forget too. What must still hold is that its RECORDS @@ -1098,7 +1099,19 @@ TEST(RemoteClientControls, AParameterChangeForAConfirmedObjectTakesNoReply) { MG_Impl::GLImpl::TexParameteri(0x0DE1, 0x2801, 0x2601); // MIN_FILTER = LINEAR MGP_FILL(DrawArrays); if (Transport::LinkMetricsRecordsFor(createOp) == 0) ::_exit(150); - if (Transport::LinkMetricsReplyWaitsFor(createOp) == 0) ::_exit(151); + // THE LIVE CONTROL IS map_persistent, NOT A CREATE AND NOT A FENCE. Not a create, because + // the create row now batches its answers behind MOBILEGL_IPC_CREATE_WINDOW. Not a FENCE, + // because a control session has no backend object: FenceCreate is refused, FindFence then + // dies of Fatal{ProtocolCorruption, "Fence.handle"}, and the child ABORTS - a case cannot + // read an exit code off a corpse. map_persistent needs no backend at all: under split its + // answer is a CONSTANT DECLINE that still costs a record and still owns a reply slot + // (PipeRoute.h, R-6), so it is exactly "a row that always takes its answer". + { + const Uint64 waitsBeforeControl = Transport::LinkMetricsReplyWaits(); + MG_Pipe::MGPHandleOnly controlHandle{}; + (void)MG_Pipe::MGPipeRouteMapPersistent(controlHandle, 64, nullptr); + if (Transport::LinkMetricsReplyWaits() <= waitsBeforeControl) ::_exit(151); + } // NOW THE OBJECT IS CONFIRMED. Eight real parameter changes: every one must still be SENT, // and none of them may take a reply. const Uint64 recordsBefore = Transport::LinkMetricsRecordsFor(paramsOp); @@ -1182,4 +1195,215 @@ TEST(RemoteClientControls, ABufferRespecifyTakesNoReplyWhileATextureOneStillDoes }); ExpectChildSuccess(child); } +// ===================================================================================== +// P12: the CREATE WINDOW - the confirmation is deferred, not dropped +// ===================================================================================== +// +// The create row is the last per-record round trip on the load path (4,536 of a frame's 4,543 +// waits) and the one row whose answer IS an object's confirmation. It therefore defers: up to +// MOBILEGL_IPC_CREATE_WINDOW creates go out, the caller reads a provisional accept, and the +// answers are collected in order. A late DECLINED un-latches its object, which puts every later row +// for that object back on the blocking path - the repair that already existed, not a new one. +// +// THE THREE WAYS THIS CAN GO WRONG, one reading each: +// +// 1. THE WINDOW DEFERS NOTHING (the row pays a wait per record and the change is decoration) - +// the ON half below; +// 2. THE WINDOW IS NOT WHAT DECIDES (the row defers while the operator asked it not to, which is +// the same defect as 1 with the sign flipped) - THE NEGATIVE CONTROL, run in the SAME CHILD as +// the ON half so that both readings share a session, a server, a set of objects and a pair of +// counters. MOBILEGL_IPC_CREATE_WINDOW=1 must restore the pre-window shape exactly; this is +// the assertion that goes red if the window ever stops being the knob; +// 3. THE WINDOW HOLDS MORE THAN IT IS SIZED FOR - and the ring is what makes this one quiet: an +// unguarded push does not grow a count past its ceiling, it REPLACES the oldest confirmation +// and leaves the count exactly where it was. So the invariant is read against the effective +// ceiling after every create, the ceiling is read against the array, the array is read against +// the reply pool it shares, and the environment is not allowed to raise any of them. +TEST(RemoteClientControls, ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot) { + const auto child = RunInChild([] { + ::setenv("MOBILEGL_PIPE_STATS", "1", 1); + CapsPeer backend; + StartControlSession(&backend); + MG_Config::Features.PipePush |= kMGPipeSubsystemResources | + kMGPipeSubsystemTextureResources | kMGPipeSubsystemSamplers; + MG_State::pGLContext = MakeUnique(); + const Uint32 createOp = static_cast(MGPWireOp::ResourceCreate); + Uint8 pixels[4 * 4 * 4]; + std::memset(pixels, 0x21, sizeof(pixels)); + // ---- the clamp and the ceiling, before a single record goes out ---- + // THE WINDOW IS OPT-IN AND THE DEFAULT IS OFF (Config.h: the deferral was measured on the + // device and the drain cannot collect it), so both arms of this case SET the knob rather + // than inheriting it - the ON half below is the mechanism being exercised, not a claim + // about what ships. + // WHAT THE OPERATOR WROTE IS NOT WHAT THE CODE MAY USE. With the clamp gone a configured + // window of 99 is compared against a count that the ring keeps below its own size, so the + // fallback never fires and the push index runs off the array. + MG_Config::Ipc.CreateWindow = 99; + if (ClientCreateWindowEffective() != ClientCreateWindowArraySize()) ::_exit(170); + MG_Config::Ipc.CreateWindow = 4; + if (ClientCreateWindowEffective() != 4u) ::_exit(171); + // AND THE ARRAY IS SIZED FROM THE POOL IT SHARES, not from a second literal: the pool is + // Transport::kDefaultReplySlotCount slots addressed seq % count, and every reply-owning row + // (fence, query, the blocking rows themselves) needs one. Raising the window to the whole + // pool is the change the handoff names, and it is red here as well as at the static_assert + // in WireTables.cpp - deliberately twice, because the compile-time one is the guard and + // this one is the statement of what the guard is for. + if (ClientCreateWindowArraySize() > Transport::kDefaultReplySlotCount / 2) ::_exit(172); + + // TWELVE OBJECTS, ONE CREATE EACH, AND THE INVARIANT READ AFTER EVERY ONE. This is the + // reading a silent overwrite cannot fake: the count stays at the ceiling while an entry is + // replaced underneath it. + Uint32 highWater = 0; + const auto burst = [&](Int32 count) { + for (Int32 i = 0; i < count; ++i) { + GLuint texture = 0; + MG_Impl::GLImpl::GenTextures(1, &texture); + MG_Impl::GLImpl::BindTexture(0x0DE1, texture); + MG_Impl::GLImpl::TexImage2D(0x0DE1, 0, 0x8058, 4, 4, 0, 0x1908, 0x1401, pixels); + MGP_FILL(DrawArrays); + if (ClientCreateWindowPending() > ClientCreateWindowEffective()) ::_exit(173); + if (ClientCreateWindowPending() > highWater) highWater = ClientCreateWindowPending(); + } + }; + + // ---- THE WINDOW ON: twelve creates, strictly fewer waits ---- + const Uint64 recordsBefore = Transport::LinkMetricsRecordsFor(createOp); + const Uint64 waitsBefore = Transport::LinkMetricsReplyWaitsFor(createOp); + burst(12); + const Uint64 records = Transport::LinkMetricsRecordsFor(createOp) - recordsBefore; + const Uint64 waits = Transport::LinkMetricsReplyWaitsFor(createOp) - waitsBefore; + std::printf("create window ON: window=%u records=%llu waits=%llu high_water=%u\n", + static_cast(MG_Config::Ipc.CreateWindow), + static_cast(records), + static_cast(waits), + static_cast(highWater)); + std::fflush(nullptr); + if (records < 12) ::_exit(164); + // THE DEFERRAL ITSELF, and the honest size of the claim: the first `window` records pay + // nothing, so twelve records cannot pay twelve waits once anything was deferred. A ratio + // would be a promise about how fast the server answers, which is not this suite's to make. + if (waits >= records) ::_exit(165); + + // ---- AND THE SAME TWELVE WITH THE WINDOW OFF, IN THIS SAME CHILD ---- + // The negative control shares everything with the ON half except the knob, which is what + // makes it a control rather than a second measurement. ONE WAIT PER RECORD is the + // pre-window shape: R-5 shipped with exactly that, and the whole change is the difference + // between the two lines this case prints. + MG_Config::Ipc.CreateWindow = 1; + const Uint64 offRecordsBefore = Transport::LinkMetricsRecordsFor(createOp); + const Uint64 offWaitsBefore = Transport::LinkMetricsReplyWaitsFor(createOp); + burst(12); + const Uint64 offRecords = Transport::LinkMetricsRecordsFor(createOp) - offRecordsBefore; + const Uint64 offWaits = Transport::LinkMetricsReplyWaitsFor(createOp) - offWaitsBefore; + std::printf("create window OFF: window=%u records=%llu waits=%llu\n", + static_cast(MG_Config::Ipc.CreateWindow), + static_cast(offRecords), + static_cast(offWaits)); + std::fflush(nullptr); + if (offRecords < 12) ::_exit(166); + // ONE WAIT PER CREATE, EXACTLY. Every create on this path is an EmitAndWait, so the two + // counters are equal whatever else the row does - which is the assertion that cannot be + // satisfied by a window that is still deferring sometimes. + if (offWaits != offRecords) ::_exit(167); + ClientSessionInstance().Stop(); + }); + ExpectChildSuccess(child); +} + +// ===================================================================================== +// P12: A REFUSED CREATE LEAVES THE WINDOW - read ON THE LATCH AND ON THE SUSPECT LIST +// ===================================================================================== +// +// The window's answer is provisional, so the one thing it must never do is keep feeding a refused +// object fire-and-forget creates: the client would hold a confirmation the server has refused, for +// ever, and every row that reads the latch would believe it. The repair is deliberately the path +// that already existed - DECLINED un-latches the object, the object leaves the window, its next +// create BLOCKS and the refusal reaches the caller - and this case reads all three steps rather +// than the symptom. It then reads the HEAL, which is the other half of the same rule: a blocking +// create that lands puts the object back, so a transient refusal cannot retire an object from the +// window for the rest of the process (or, at kCreateSuspectMax of them, retire the window). +// +// THE REFUSAL IS MADE REAL RATHER THAN SIMULATED, and it is the exact case the audit found the two +// ends can disagree about: the applier asks for bit 10 while the client's mirror still says the +// family is live, so every create the client emits is refused. Narrowing the server's consumed mask +// reproduces that without touching the client's mirror. +TEST(RemoteClientControls, ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlocks) { + const auto child = RunInChild([] { + ::setenv("MOBILEGL_PIPE_STATS", "1", 1); + CapsPeer backend; + StartControlSession(&backend); + MG_Config::Features.PipePush |= kMGPipeSubsystemResources | + kMGPipeSubsystemTextureResources | kMGPipeSubsystemSamplers; + MG_State::pGLContext = MakeUnique(); + // THE WINDOW IS SET HERE, not inherited: this case is about what the window does with a + // refusal, so it must have one whatever the suite was launched with. + MG_Config::Ipc.CreateWindow = 4; + const Uint32 createOp = static_cast(MGPWireOp::ResourceCreate); + // ROOM FOR THE LARGEST LEVEL THIS CASE DEFINES. The storage definition's texels travel as + // resource_subdata out of the drain list, so the pointer is read after this call returns. + Uint8 pixels[64 * 64 * 4]; + std::memset(pixels, 0x21, sizeof(pixels)); + Srv::ServerSessionInstance().SetConsumedSubsystems( + kMGPipeSubsystemsMigratedAtP4a & ~kMGPipeSubsystemTextureResources); + // ---- one object we can name, then enough neighbours to fill and drain the window ---- + // The drain is what READS a deferred answer, so the refusal of the first create is only + // discovered once later creates have pushed the ring past its ceiling. + const auto defineStorage = [&](GLuint name, Int32 extent) { + MG_Impl::GLImpl::BindTexture(0x0DE1, name); + MG_Impl::GLImpl::TexImage2D(0x0DE1, 0, 0x8058, extent, extent, 0, 0x1908, 0x1401, pixels); + MGP_FILL(DrawArrays); + }; + GLuint refused = 0; + MG_Impl::GLImpl::GenTextures(1, &refused); + defineStorage(refused, 8); + for (int i = 0; i < 6; ++i) { + GLuint filler = 0; + MG_Impl::GLImpl::GenTextures(1, &filler); + defineStorage(filler, 8); + } + // ---- (a) THE LATCH IS CLEAR AND THE OBJECT IS OUT OF THE WINDOW ---- + const auto refusedObject = MG_State::pGLContext->GetTextureObject(refused); + if (refusedObject == nullptr) ::_exit(174); + const MG_Pipe::MGPipeHandle refusedHandle = + MG_Pipe::MGPipeTextureEmitterInstance().FindTexture(*refusedObject); + if (MG_Pipe::MGPipeHandleIsNull(refusedHandle)) ::_exit(175); + // THE DIRECT READING, which is what the converted rows read: not "the create was refused" + // but "this object is not published", which is the fact every fire-and-forget row acts on. + if (MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture, refusedHandle)) ::_exit(176); + if (ClientCreateWindowRefusals() == 0) ::_exit(177); + const Uint32 suspectsBefore = ClientCreateWindowSuspects(); + if (suspectsBefore == 0) ::_exit(178); + // ---- (b) AND ITS NEXT CREATE BLOCKS: ONE WAIT PER CREATE, WITH THE WINDOW WIDE OPEN ---- + // The window has room (the ceiling is 4 and this case has just drained it), so a deferring + // row would pay ZERO waits here. Every create this restorage emits pays one instead, which + // is the blocking path and nothing else. + const Uint64 createRecordsBefore = Transport::LinkMetricsRecordsFor(createOp); + const Uint64 createWaitsBefore = Transport::LinkMetricsReplyWaitsFor(createOp); + defineStorage(refused, 16); + const Uint64 suspectRecords = Transport::LinkMetricsRecordsFor(createOp) - createRecordsBefore; + const Uint64 suspectWaits = Transport::LinkMetricsReplyWaitsFor(createOp) - createWaitsBefore; + std::printf("refused create: suspect_records=%llu suspect_waits=%llu suspects=%u\n", + static_cast(suspectRecords), + static_cast(suspectWaits), + static_cast(suspectsBefore)); + std::fflush(nullptr); + if (suspectRecords == 0) ::_exit(179); + if (suspectWaits < suspectRecords) ::_exit(180); + // ---- (c) THE HEAL: GIVE THE FAMILY ITS CONSUMER BACK AND RE-DEFINE THE SAME OBJECT ---- + // The create still blocks (the object is still suspect), now it LANDS, and landing is what + // takes it out of the suspect list - exactly one entry, because no other object is touched. + Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a); + defineStorage(refused, 32); + std::printf("refused create healed: published=%d suspects %u -> %u\n", + static_cast(MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture, + refusedHandle)), + static_cast(suspectsBefore), + static_cast(ClientCreateWindowSuspects())); + std::fflush(nullptr); + if (!MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture, refusedHandle)) ::_exit(181); + if (ClientCreateWindowSuspects() != suspectsBefore - 1) ::_exit(182); + ClientSessionInstance().Stop(); + }); + ExpectChildSuccess(child); +} #endif diff --git a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp index cf91cf0e..673d1299 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp +++ b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp @@ -53,6 +53,9 @@ #include #include #include +// P12: MGPipeTextureEmitterInstance(), so a control case can name the handle of an object it +// created and read the publication latch the converted rows read. +#include #include #include #include diff --git a/docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md b/docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md new file mode 100644 index 00000000..c4dca4c6 --- /dev/null +++ b/docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md @@ -0,0 +1,132 @@ +# create window 的实测否决,与 StreamLink 的一处 ILink 契约违反(handoff-2 §2 收尾记录) + +> 2026-09-26。分支 `feat/disaggregated` @ `82ed1df9` 加本轮工作树改动。对应 +> [`MOBILEGL-CS-HANDOFF-2.md`](../../../../MOBILEGL-CS-HANDOFF-2.md) 的 §2(工作树里那版 create window) +> 与 §7 的「把 create 那 4,536 批成 ~1,134」。 + +## 0. 一句话 + +- **窗口开了等于没开**:同一台设备、同一帧、同一 101,421,396 B staged,`MOBILEGL_IPC_CREATE_WINDOW=4` + 与 `=1` 的 create 行都是 **4,536 次等待**,墙钟都是 **28.64 s**(handoff 的 pre-window 基线是 31.3 s)。 + 临时计数器给出的形态是:`pushed=24 blocked=4,476 took=20 readfail=8,951 pending=4`——**drain 读不回来**。 +- **不是调参问题**:回复池 8 槽,而这一帧发出 **59,673 条带回复的记录**(服务端对每条 kReplySlot 记录都回, + 不管客户端要不要),所以一条答案在 **8 条回复**之后就被覆盖,等于 **0.61 个 create**。任何深度的窗口都读不回来。 +- **顺带修掉一个真 bug**:`StreamLink::ReadReply` 只认「最新一条」(单槽),违背 `ILink::ReadReply(seq)` + 的按 seq 读取语义。已改成一个 8 槽环 + 用例。 +- **默认关掉**:`MOBILEGL_IPC_CREATE_WINDOW` 默认 1。窗口开着不只是白费,它会让**被拒绝的 create 留在「已发布」状态** + (反闩动作在同一个读不回来的 drain 里),正是 suspect 表要防的那种静默分歧。 + +## 1. 实测(同一设备 aikjs4s8cys8ba7l,同一世界,出厂省电) + +| run | 窗口 | `frame-sent 2=` | `frame-op by_op 2=` | 墙钟 | 日志 | +|---|---|---|---|---|---| +| handoff 基线(无窗口的二进制) | — | 4,536 | 4,536 | 31.26 s | `logs/mcx.client` | +| 窗口 4 | 4 | 4,536 | **4,536** | **28.64 s** | `logs/mcx-fix4.client` | +| 窗口 1(负控) | 1 | 4,536 | **4,536** | **28.64 s** | `logs/mcx-fix1.client` | + +`by_op 2` 数的是**走了阻塞路径的 create**(`LinkMetricsBeginReply(ownsReplySlot, op)`, +`ownsReplySlot = rowCarriesReplySlot && wantReply`,`ClientSession.cpp:1840/1883`)。它没动,就是「一条都没延后」。 +两臂的 `frame-sent` 全表逐 op 相同(4,536 / 20,633 / 3,770 / 18,056 / 12,671 …),也就是**记录一条没少发**。 + +窗口确实进去过——临时行(每 500 条打一次)在窗口 4 下的读数: + +~~~ +P12diag create=501 cfg=4 eff=4 suspects=0 refusals=0 pending=4 pushed=24 blocked=476 took=20 waitfail=22 readfail=951 +P12diag create=4501 cfg=4 eff=4 suspects=0 refusals=0 pending=4 pushed=24 blocked=4476 took=20 waitfail=22 readfail=8951 +~~~ + +`pending` 从第 500 条起**一直钉在 4**,`refusals=0`(一条答案都没读到过),`readfail` 约每条 create 涨 2 +(非阻塞 poll + 阻塞 drain 各失败一次)。 + +## 2. 根因:回复池 8 槽 vs 一帧 59,673 条回复 + +答案只在**它的槽没被复用**之前可读(`ReplySlotPool`,`kDefaultReplySlotCount = 8`, +`Transport/ReplySlot.h:111`)。而这一帧发回复的记录数是: + +| 行 | 条数 | 为什么回 | +|---|---|---| +| 2 `ResourceCreate` | 4,536 | 等 | +| 3 `ResourceRespecify` | 20,633 | **不等也给**(首用确认后 fire-and-forget) | +| 47 `SetTextureParams` | 18,056 | **不等也给** | +| 48 `ResourceSubData` + 4 | 16,441 | **不等也给** | +| 5 / 9 等 | 7 | 等 | +| **合计** | **59,673** | | + +服务端**无法知道**客户端要不要这条答案——`ClientSession.cpp:1897-1900` 原文:「The server posts either way, +so the wire is unchanged and the row keeps its flag」。于是池子一帧churn **7,459 次**,两条 create 之间平均夹 +**13.2 条回复**,而一条答案只活得下 **8 条**: + + 8 / 13.2 = 0.61 个 create + +**所以窗口的失败不是「慢」,是「读不到」。** 1 深度的窗口就是阻塞路径本身。 + +## 3. 顺带发现并修掉:`StreamLink::ReadReply` 违背了自己的接口 + +`ILink::ReadReply`(`Transport/ILink.h:285`)的签名是**按 seq 读**;`ShmLink` 用 `ReplySlotPool` 实现它, +而 `StreamLink` 到本轮为止只保留**一条**回复(`reply` / `replySeq` / `replyStatus`),并且: + +~~~cpp +x.replyReady.wait(lock, [&]{ return closed || x.replySeq >= seq || appliedSeq >= seq; }); +if (x.replySeq != seq) return ... MOBILEGL_ERR_PROTOCOL_MISMATCH; // 等的是 >=,判的是 == +~~~ + +「等到至少 seq」与「只接受恰好 seq」自相矛盾:任何早于最新一条的答案都读不回来。此前没有调用者受影响, +是因为每个调用者读的都是自己刚等到的、也就是最新的一条;create window 是第一个读**旧**答案的调用者。 +已改成 8 槽环(`kReplyRing`,深度取它顶替的那个池:`kDefaultReplySlotCount`), +用例 `StreamLinkTest.AnOlderReplyIsStillReadableByItsSeq` 三连发、按 1→2→3 顺序读回。 + +**注意这只治好了「单槽」,没治好「池只有 8 槽」**——`ShmLink` 侧本来就是池,也一样读不回来。这也是为什么 +设备上的读数在修掉单槽之后仍然一模一样。 + +## 4. 为什么这是个必须默认关掉的开关,而不是一个慢一点的开关 + +窗口的答案是**临时接受**:`PublishCreate` 会因此落闩(「applier holds this handle」), +而纠正它的唯一动作——迟到 DECLINE → `MGPipeNoteHandleUnpublished` + suspect 表——**就在那个读不回来的 drain 里**。 +换句话说窗口开着时,**服务端的拒绝会被客户端当成已发布**,之后每条已转换的行都对一个不存在的记录 fire-and-forget。 +这正是 suspect 表存在的理由,也是 `TextureEmit.h` 里 `RespecifyOnce` 那句话(「applier 的 REFUSAL 是唯一说 +『我手里没有这个 handle』的信号」)要防的东西。 + +## 5. 门(`MG_Test/Wire/RemoteClientControls.inc`) + +窗口机制本身是自洽的,只是在这条协议上不可用,所以门保留并 pin 住机制: + +| 用例 | 钉住什么 | 读数 | +|---|---|---| +| `ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot` | 窗口开=12 记录 0 等待;**同一子进程**把窗口关回 1 = 12 记录 **12** 等待(负控);`pending <= effective` 逐条断言;99 被夹到数组大小;数组 <= 池的一半 | ON 0 waits / OFF 12 waits | +| `ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlocks` | 真拒绝(收窄服务端 consumed mask)→ **闩清零**、suspect 计数 +1、下一条 create **阻塞**(2 记录 2 等待)、恢复消费者后 **heal**(闩回来、suspect −1) | suspects 6 → 5 | +| `StreamLinkTest.AnOlderReplyIsStillReadableByItsSeq` | 传输层按 seq 可读 | 3/3 | + +编译期还有一条:`WireTables.cpp` 的 `static_assert(kCreateWindowMax <= kDefaultReplySlotCount / 2, …)`, +把「有人把窗口改成 8 就会踩池子」变成编译错误而不是设备上的错画面。 + +## 6. 画面(handoff-2 §6 要的那一格) + +设备端(`MOBILEGL_IPC_SURFACE=server`,画面在手机屏上)在负载帧闭合后 +8 s 截屏: +`logs/mcx-fix4.shot2.png` 与 `logs/mcx-fix1.shot2.png` 都是真实世界(地形、物品栏、聊天行), +**两臂 SSIM = 0.977**(均值绝对差 2.26/255)。同一 run 内 加载页 vs 世界 SSIM 只有 0.12,说明这个数字不是 +「两张都黑」的假阳性。**这仍然只是间接证据**:两臂行为本来就相同(窗口是 inert 的),它能说明的是 +「窗口开着没有把画面弄坏」,不是「延后一条确认后画面仍然对」。 + +## 7. 下一步(如果要救这个想法) + +要让延后的答案活下来,只有两条路,**都是协议面而不是调参**: + +1. **加深回复池**:`ReplySlotCount` 在 Hello 里协商(`ClientSession.cpp:957` / `ServerSession.cpp:745`), + 两端一致即可改。需要的深度 \>= 两条 create 之间的回复数(本帧 13.2)乘以窗口深度,即 4 深度要 ~53 槽; + 而 `ReplyBytes` 固定 16 MiB 时槽会变小,`ReadPixels` 的 `ReplyCanHold` 会先撞墙,所以要一起抬 `ReplyBytes`。 +2. **让客户端能说「这条别回」**:四个 Bool 行(create/respecify/params/sub-data)目前服务端一律回; + 一个 record flag 就能把 59,673 砍到 ~4,543,那时 8 槽够用。这是 wire 改动,两端都要动。 + +## 8. 复算 + +~~~sh +# 两臂的帧读数(handoff-2 §1 的读法) +python3 mgl-device/bigframe.py mgl-device/logs/mcx-fix4.client mgl-device/logs/mcx-fix1.client +grep -a "kind=frame-op frame=" mgl-device/logs/mcx-fix4.client | tail -1 +grep -a "kind=frame-sent frame=" mgl-device/logs/mcx-fix4.client | tail -1 +# 一轮设备 run(自带 force-stop、就绪判据、负载帧检测、截屏、SIGTERM 收尾) +cd mgl-device && python3 run-once.py [MOBILEGL_IPC_CREATE_WINDOW=4] +# 门 +build-split/MobileGL/MG_Test/Wire/RemoteClientTest --gtest_filter='*CreateWindow*:*RefusedCreate*' +build-split/MobileGL/MG_Test/Wire/StreamLinkTest --gtest_filter='*OlderReply*' +~~~ diff --git a/docs/Disaggregated/notes/p12/UNPUBLISH-GUARD-SIGNALS.md b/docs/Disaggregated/notes/p12/UNPUBLISH-GUARD-SIGNALS.md new file mode 100644 index 00000000..4aae2ccf --- /dev/null +++ b/docs/Disaggregated/notes/p12/UNPUBLISH-GUARD-SIGNALS.md @@ -0,0 +1,100 @@ +# 「①」该挂哪个信号:三个候选的判定(handoff-2 §3 调查记录) + +> 2026-09-26。分支 `feat/disaggregated`。对应 [`MOBILEGL-CS-HANDOFF-2.md`](../../../../MOBILEGL-CS-HANDOFF-2.md) +> 的 §3「把『①』挂到已有信号上」。**结论是 handoff §3.2 的前两个候选都不是信号**,成本仍在协议面; +> 本文把三条理由落到符号,并把真正的缺口缩到一句话。 + +## 0. 一句话 + +- **§3.2 候选 1(caps 代次)不是信号**:caps Adopt 是「快照重发」,而 `MGPipeApplierReset`(make-current 的 + 服务端半边)**故意保留**对象记录——代次动了 ≠ 记录没了。 +- **§3.2 候选 2(FreshlyPrimed)是同一个理由,而且它就是空的**:`TextureEmit.h:1257` 的 + `void Reset() {}` 函数体为空,注释写明「Only LATCHES reset here - the applier's object records survive a + make-current」。 +- **真正的缺口只有一种**:描述符**没变**的 respecify 会在发射端被去重吞掉(`TextureEmit.h:838` 的 + `unchanged` 不读闩),连「拒绝」这个唯一的客户端可见信号都产生不了。描述符变了的那一半今天**已经能自愈**。 +- **今天不可达**:`MGPipeApplierReleaseObjectRecords` 在生产代码里没有调用者(只有 MG_Test,实测 grep 见 §5)。 + +## 1. 失效时要清的三样,逐条落到符号 + +| handoff §3.1 | 符号 | 今天的状态 | +|---|---|---| +| 发布闩 | `MGPipeNoteHandleUnpublished(kind, handle)`(`PipeFill.cpp:1890`),按 `{kind, slot, gen}` 键 | 对象销毁会清;跨 make-current 保留(设计如此) | +| 各 emitter 的镜像 | `TextureEmit` 的 `entry.HasLastDesc` / `entry.HasParamsLatch` | **只清闩不够**:去重比闩更早,见 §4 | +| create window 的 suspect 表 | `g_createSuspects`(`WireTables.cpp`) | 窗口默认已关(见 `CREATE-WINDOW-MEASURED.md`),这一条随之为空 | + +## 2. 候选 1:`CapsMirror::Generation()` —— 动的是快照,不是记录 + +`CapsMirror::Generation()` 每次 `Adopt` 自增,而 Adopt 的触发点是**caps 快照重发**:`ServerMakeEGLCurrent` +对「它没持有的 tuple」转发绑定时会重发一次(ID-67,`MakeCurrentRepublishCount`)。也就是说代次动 +**只说明服务端重新回答了一次能力问题**,不说明它的记录表空了。反证在 `MGPipeApplierReset` 自己身上: +它是 make-current 的服务端半边,`PipeApply.cpp:1584-1590` 清的是 working state 与三个 binding-point 窗口, +**texture / sampler / view / shader-CSO 记录一个都不清**——注释把理由写死了:「a GL object lives in a +share group, and re-publishing one would move its Serial for nothing」。 + +照代次清 = 把「记录还活着」的每一次 make-current 也当成失效,每次切上下文重发全部存活纹理。方向安全 +(`PipeApply.h:822`:「Over-firing is the safe」),但这是**为一个今天不可达的路径付每次 make-current 的钱**。 + +## 3. 候选 2:`FreshlyPrimed` —— 同一个理由,且这个函数是空的 + +`PipeFill.cpp:3761` 的 FreshlyPrimed 臂确实在 reset 五个 emitter,但纹理那一个的 reset 是: + +~~~cpp +// A fresh context: what the server has is no longer what this emitter last sent. Only +// LATCHES reset here - the applier's object records survive a make-current and +// re-publishing them would move their serials for nothing. +void Reset() {} // TextureEmit.h:1257 +~~~ + +空实现就是「这里没有可清的东西」的落地写法。同一个文件 `TextureEmit.h:1267-1271` 还把话说到另一头: +「a texture handle and the applier record it names are SHARE-GROUP OBJECT STATE, so nothing here is +per-context and **no re-publication path exists or may exist**」。所以候选 2 不是「一行」,是**推翻一条设计**。 + +## 4. 真正的缺口:去重在发射端,比闩更早 + +`TextureEmit.h:838`: + +~~~cpp +const Bool unchanged = entry.HasLastDesc && std::memcmp(&entry.LastDesc, &desc, sizeof(desc)) == 0; +~~~ + +**它不读闩。** 于是 `MGPipeApplierReleaseObjectRecords` 之后两条路不一样: + +| 情形 | 今天的走向 | 结果 | +|---|---|---| +| 描述符**变了** | 不去重 → 闩仍为真所以不发 create → `RespecifyOnce` 发 respecify → applier 拒绝 → **补一个 create 再重试** → 接受 | **已自愈**(handoff §3.3 要的门,今天就是绿的) | +| 描述符**没变** | `if (unchanged) return;` | **一条记录都不过线**,服务端没有机会拒绝,自愈链断在这里 | + +所以「①」要清的其实是第 2 行,而它要的**不是清闩,是清镜像**(`HasLastDesc`/`HasParamsLatch`)—— +只清闩的话那条「相同 desc」照样不发(handoff §3.1 已经点到,这里是它的符号)。 + +## 5. 可达性(本轮复核) + +~~~ +$ grep -rn "MGPipeApplierReleaseObjectRecords" MobileGL/ --include=*.cpp --include=*.h | grep -v MG_Test +PipeApply.cpp:1593 void MGPipeApplierReleaseObjectRecords() { <- 定义 +(其余全部是注释) +~~~ + +与 handoff §3.4 一致:**生产调用者为零**,唯一调用者是 MG_Test(`PipeWireCodecTest`/`StagedTextureStoreTest` +各一处、若干 `*EmitTest` 的 SetUp/TearDown)。闩按 `{kind, slot, gen}` 键、对象销毁会清闩,槽位复用安全。 + +## 6. 建议(本轮不做,理由在下面) + +1. **不加反向回调**:那是协议面(两端重出 APK),而触发者今天不存在。handoff §7 的判词是「今天是潜伏, + 别为它付协议面的钱」。 +2. **不挂代次**:理由见 §2/§3——它会在每次 make-current 上 over-fire,为一个不可达路径付真实成本。 +3. **该做的是把缺口写成门**:用 `MGPipeApplierReleaseObjectRecords` 造「记录被丢掉」, + 断言**描述符变了那一半必须自愈**(今天绿,红的方式是把 `RespecifyOnce` 的补 create 去掉), + 并把**描述符没变那一半**作为已知缺口记账——它是这件事真正欠的债。 +4. 如果哪天真出现生产调用者,正确形状是给 entry 记一个「本代次内确认过」的戳(caps 代次或会话代次), + 代次一动就重发一次:它是 over-fire 方向,不用动 wire。 + +## 7. 复算 + +~~~sh +grep -rn "MGPipeApplierReleaseObjectRecords" MobileGL/ --include=*.cpp --include=*.h | grep -v MG_Test +sed -n '1249,1258p' MobileGL/MG_Impl/Pipe/TextureEmit.h # void Reset() {} +sed -n '834,840p' MobileGL/MG_Impl/Pipe/TextureEmit.h # unchanged,不读闩 +sed -n '1575,1591p' MobileGL/MG_Pipe/PipeApply.cpp # MGPipeApplierReset 清什么、不清什么 +~~~