[Perf] (P12): the create window lands as the default - declared-answer retention replaces the ring, and the load frame goes 28.66 s -> 16.52 s (11.02 s at window 4)

The three things that made the deferral inert are all fixed, in the order they were
found: the reply ring could not hold an answer across the ~1,000 records between two
creates; a residency budget could not help because what displaces an answer is what
the SERVER sends, not what the client emits; and so the mechanism had to be retention.

WHAT LANDED
  * ILink::DeclareReplyRead + LinkCapabilities::RetainsReplies. The wire declares a
    seq will be read BEFORE it publishes, so the declaration cannot race its own
    answer; a link with the capability false ignores it, and the window does not
    defer on one (CreateWindowEffective() is gated on it).
  * StreamLink's ring of the last 8 answers becomes a declared-answer store: a
    monotone whitelist plus a cursor, answers kept until their reader TAKES them
    (take-and-delete under one lock, bytes swapped into the scratch), and a capacity
    of 24 that is FATAL to exceed rather than evicting. An evicted wanted answer is
    unrecoverable - re-latching by sending another create wipes the server's record
    (PipeApply.cpp:2033) and its PendingUploads (PipeApply.h:332) while the client
    cleared its dirty flags at emission (PipeApply.h:298-304) - so the capacity is
    sized so it cannot be reached and reaching it says its own name.
  * An answer nobody declared is DROPPED, which is what keeps the store bounded by the
    client's deferral depth instead of by the server's answer rate. Retain-on-arrival
    grows ~11.3 entries per create against a server that ignores the no-reply bit.
  * A read that fails after WaitForApplied has returned Reached is now Fatal{ReplyMissing}
    naming the seq: on the stream arm the reply frame strictly precedes the progress
    frame that publishes appliedSeq, so that combination is a broken invariant, and
    retrying it (what the row used to do) wedges the window for the rest of the
    process while leaving every deferred object latched on a provisional accept.
  * The residency budget is deleted (WireTables.cpp keeps its history), the window
    retirement branch drains once before falling back - it could otherwise strand the
    answers it was holding - and Detach clears the store.

MEASURED ON THE DEVICE, load frame, both ends rebuilt, frame-sent identical per op
(create 4,536, records 103,097) throughout:

    window 1 (R-5's shape)              4,536 takes   28.66 s
    window 2 explicit                   2,500         17.27 s
    window 2 AS THE SHIPPED DEFAULT     2,563         16.52 s
    window 4                            1,644         11.02 s
    window 2 against an OLD SERVER      2,663         16.38 s   (zero Fatals)

The last row is the version-skew case the design review named as a must: a server
built before the no-reply bit answers ~55,903 records a frame, the whitelist drops
every answer nobody declared, and the deferral still reads its own back.

GATES (172 + 15 green), and the important one is proved to go red:
  * ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer - reverting the
    store to "keep the last 8 arrivals" fails it immediately with "the declared answer
    did not survive 64 intervening ones". That is the line between the ring-1024
    stand-in and a bounded mechanism.
  * AnAnswerNobodyDeclaredIsNotKeptAtAll - red if everything is retained.
  * TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers - on the in-process (ShmLink)
    arm the knob at 4 must still block every create, red if the capability gate goes.
  * The refused-create case now reads the repair that exists on THAT arm (the latch),
    and asserts the window's own books are empty there.

NOT DONE, and named: the window has no unit gate on the STREAM arm yet - only the
device runs. Three rounds of this feature were green in the lanes while inert on the
device because the lanes and the device run different transports; the transport-level
retention gates close that for the store, and the window-level one should be built on
StreamClientPublicationTest's real stream session next.
This commit is contained in:
BZLZHH
2026-09-26 03:31:14 +08:00
parent 1ef7439625
commit 3a2555805a
13 changed files with 416 additions and 202 deletions
+31 -16
View File
@@ -549,22 +549,37 @@ namespace MobileGL::MG_Config {
// 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 55,903 replies (create 4,536 + respecify 20,633 +
// params 18,056 + sub-data 12,671 + 7), so an answer is overwritten after 8 replies -
// 0.65 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
// per-record "this answer will never be read" bit). NOTE THE PREDICATE: NOT
// "fire-and-forget" - the window's own creates ARE fire-and-forget and the drain reads
// them, so a flag keyed on wantReply would kill the very thing it is meant to enable
// (docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md section 7).
Uint32 CreateWindow = 1;
// client waits for one. 1 = every create waits (the shape R-5 shipped with); N > 1 = up to N
// creates go out fire-and-forget, their caller reads a PROVISIONAL accept, and the oldest
// answer is taken when the window fills.
//
// DEFAULT 2, AND IT TOOK THREE THINGS TO GET HERE. The deferral was inert for two
// measured reasons and one structural one, in the order they were found:
// 1. the reply buffer was a ring of the last 8 answers, and between two creates this
// client emits on the order of a thousand records - so the answer was always gone
// (`want=310` on every miss while the ring spanned [1175..1504]);
// 2. a residency budget that tried to take answers back early could not help, because
// what displaces an answer is what the SERVER sends, not what the client emits;
// 3. and so the fix was RETENTION - a link keeps a declared answer until its reader
// takes it (Transport/ILink.h's RetainsReplies, StreamLink's declared-answer store).
//
// MEASURED ON THE DEVICE, load frame, both ends rebuilt (create 4,536, records 103,097,
// frame-sent identical per op throughout):
// window 1 4,536 blocking takes 28.66 s (the shape R-5 shipped)
// window 2 2,500 17.27 s
// window 4 1,644 11.02 s
// and the round trip does NOT grow as the window deepens (5,285 / 4,082 us against 5,672
// at 1), which is the measurement that says the ~5 ms is a WAKEUP the server batches away
// rather than its apply throughput - so amortising four creates per wakeup is a real 2.6x.
//
// 4 IS REACHABLE AND BETTER (11.02 s against 17.27 s) and is NOT the default only because
// the wider window holds more answers at once; raise it after a run that reads `by_op 2`
// and the wall clock rather than the config. NOTE THE PREDICATE the whole mechanism rests
// on: an answer is retained because the wire DECLARED it will be read - NOT because its
// caller was fire-and-forget. The window's own creates are fire-and-forget AND read, so a
// flag keyed on wantReply would disable the very mechanism it exists to enable
// (docs/Disaggregated/notes/p12/CREATE-WINDOW-MEASURED.md sections 7-11).
Uint32 CreateWindow = 2;
// 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
+6 -5
View File
@@ -412,11 +412,12 @@ 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: 55,903
// 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 create window. DEFAULT 2, and it works because a link that declares RetainsReplies
// keeps a declared answer until its reader takes it - Config.h carries the three reasons it
// was inert before and the device numbers (28.66 s at 1, 17.27 s at 2, 11.02 s at 4).
// Capped at 4: the retained set is bounded by the window's own depth, and 4 is what the
// declared-answer store's capacity is written against.
ipc.CreateWindow = QueryEnvUint32("MOBILEGL_IPC_CREATE_WINDOW", 2, 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
+14 -3
View File
@@ -1898,10 +1898,17 @@ namespace MobileGL::MG_Remote::Client {
// creates or parameter sets, and those three have different fixes.
const auto replyMetricStart =
Transport::LinkMetricsBeginReply(ownsReplySlot, static_cast<std::uint32_t>(op));
// P12: the residency budget's clock. Placed HERE, where the record is certainly
// published, rather than beside rowCarriesReplySlot: a call that declined before
// encoding emits nothing and must not advance it.
// P12: the residency clock AND the declaration, both placed HERE - after EncodeRecord has
// an ordinal and BEFORE the publish, which is the only window in which the declaration is
// guaranteed to precede its own answer. A call that declined before encoding emits nothing
// and must not advance either.
if (rowCarriesReplySlot) ++m_replyPostings;
// THE DECLARATION THAT LETS A LINK RETAIN THIS ANSWER (P12). willReadReply, not wantReply:
// the create window emits without waiting and reads afterwards, so its records must be
// declared even though this call does not read them. Links that cannot retain ignore it,
// and the window does not defer on them (see ClientSession::LinkRetainsReplies).
if (rowCarriesReplySlot && willReadReply && m_link != nullptr)
m_link->DeclareReplyRead(seq);
m_producer.PublishAndNotify(seq);
if (op == MG_Pipe::MGPWireOp::Present) {
// A credit is permission to run ahead, not permission to retain the
@@ -2464,6 +2471,10 @@ namespace MobileGL::MG_Remote::Client {
Uint64 ClientSession::ReplyPostings() const { return m_replyPostings; }
Bool ClientSession::LinkRetainsReplies() const {
return m_link != nullptr && m_link->Capabilities().RetainsReplies;
}
Bool ClientSession::ReadReply(Uint64 seq, void* outBytes, Uint64 outCapacity, Int32* outStatus,
Uint64* outSize) {
const void* bytes = nullptr; Uint64 size = 0; Int32 status = 0;
@@ -392,6 +392,13 @@ namespace MobileGL::MG_Remote::Client {
// deployment where it matters most.
Uint64 ReplyPostings() const;
// P12: DOES THIS LINK KEEP AN ANSWER UNTIL ITS READER TAKES IT? The create window defers
// answers across an unbounded amount of other traffic, which only works where the answer
// is still there when the drain comes back - measured: a bounded buffer of the last N
// answers never is. On a link that answers false the window must not defer at all, so
// this is what CreateWindowEffective() is gated on.
Bool LinkRetainsReplies() const;
// The reply slot for `seq`, addressed seq % slots with the seq stamped back into the
// header for self-check (R-3). `outStatus` is 0 OK / 1 DECLINED / 2 ERROR, and
// DECLINED IS A REAL ANSWER - MapPersistent's nullptr and the four Bool acceptances.
+57 -67
View File
@@ -416,10 +416,7 @@ namespace MobileGL::MG_Remote::Client {
Uint64 Seq = 0;
MG_Pipe::MGPipeKind Kind = MG_Pipe::MGPipeKind::None;
MG_Pipe::MGPipeHandle Handle{};
// P12: ClientSession::ReplyPostings() at the moment this entry was pushed. The
// difference between it and the live count is how many answers could have displaced
// this one - see the residency budget below.
Uint64 ReplyStamp = 0;
};
PendingCreate g_createWindow[kCreateWindowMax];
Uint32 g_createWindowHead = 0;
@@ -432,7 +429,6 @@ namespace MobileGL::MG_Remote::Client {
// answers from existing at all - the second is what a no-reply bit keyed on wantReply
// would produce, and it would leave every other reading green.
Uint64 g_createWindowTaken = 0;
Uint64 g_createWindowBudgetStops = 0;
// A HANDLE WHOSE CREATE CAME BACK DECLINED IS NEVER WINDOWED AGAIN. Without this the window
// would UNDO the repair it exists to keep: every later use of the object sends another create,
@@ -507,7 +503,21 @@ namespace MobileGL::MG_Remote::Client {
// 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;
if (!session.ReadReply(head.Seq, nullptr, 0, &status, &size)) {
// REACHED BUT UNREADABLE IS AN INVARIANT, NOT A RETRY (P12). The link declared
// it retains this answer, and on the stream arm the reply frame strictly
// precedes the progress frame that published appliedSeq - so by the time the
// wait above returns, the answer is in hand or something is broken. Retrying
// (what this used to do) wedges the window for the rest of the process and
// leaves every deferred object latched on a provisional accept; recovering by
// re-latching would lose texels. So it names itself instead.
SessionFail(MGFatalFamily::ReplyMissing,
"MGPipe: Fatal{ReplyMissing, \"create window\"} - seq %llu was "
"declared and APPLIED, and its answer is no longer in the link's "
"store; the window cannot advance past it and nothing recovers a "
"lost answer",
static_cast<unsigned long long>(head.Seq));
}
// 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) {
@@ -553,61 +563,40 @@ namespace MobileGL::MG_Remote::Client {
if (status == 1) ++g_declined;
if (status == 0) ClearCreateSuspect(payload->Resource);
}
// ---- P12: THE RESIDENCY BUDGET --------------------------------------------------------
// ---- P12: WHY THERE IS NO RESIDENCY BUDGET HERE ANY MORE -------------------------------
//
// A deferred answer is readable only while the reply buffer has not reused its slot, and
// that buffer is bounded by construction: kDefaultReplySlotCount slots in the shared pool,
// and the stream link's ring is that same depth (StreamLink.cpp's kReplyRing, whose
// comment says why). So the window may defer only an answer it can still read back, and
// what it measures that against is HOW MANY REPLY-OWNING RECORDS IT HAS EMITTED since the
// oldest entry was pushed - an upper bound on the answers that could have displaced it.
// There was one, and it was measured: it counted the reply-owning records this client had
// emitted since the oldest window entry was pushed and took that entry back when the count
// approached the reply buffer's depth. On the device it fired on essentially EVERY create
// (budget=4,451 of 4,501 calls) and the drain still read nothing back (taken=63,
// readfail=8,872, by_op 2 = 9,071, wall 27.9 s). The clock was wrong, structurally:
//
// ONE LESS THAN THE BUFFER, because the eighth newer answer is the one that displaces it.
// * the window only gets control WHEN A CREATE ARRIVES, and between two creates this
// client emits on the order of a thousand records - at the FIRST failed read the reply
// ring already spanned [1175..1504] while the head was seq 310;
// * what displaces a deferred answer is not what this client emits, it is WHAT THE
// SERVER ANSWERS, and the server is behind: its late answers to the earlier creates
// are what fills a bounded buffer first;
// * so the check fired too late by construction. Tightening it only removed the
// deferral entirely, which is what pushed=65 was.
//
// THIS IS PREVENTION, NOT REPAIR, AND THAT IS THE WHOLE POINT. The alternative - read the
// head, and when the read fails call the object unknown - UN-LATCHES a create that very
// probably SUCCEEDED, and the server's create starts that object's record over
// (PipeApply.cpp:2033: `*record = MGPipeResourceRecord{}`), PendingUploads included
// (PipeApply.h:332); the client cleared its dirty flags at EMISSION (PipeApply.h:298-304),
// so nothing re-sends those texels. Silent content loss for a healthy object. An answer
// read inside its residency never has to be given up on at all.
// ---- MEASURED: NECESSARY BUT NOT SUFFICIENT, AND THE CLOCK IS WHY ---------------------
//
// On the device this fires on essentially every create (budget=4,451 of 4,501 calls) and
// the drain still reads nothing back (taken=63, readfail=8,872, by_op 2 = 9,071, wall
// 27.9 s). The clock is wrong, and the reason is structural rather than a bad constant:
//
// * THE WINDOW ONLY GETS CONTROL WHEN A CREATE ARRIVES. Between two creates this client
// emits on the order of a thousand records (measured: at the FIRST failed read the
// reply ring already spanned [1175..1504] while the head was seq 310), and nothing
// looks at the window during that gap.
// * WHAT DISPLACES A DEFERRED ANSWER IS NOT WHAT THIS CLIENT EMITS, IT IS WHAT THE
// SERVER ANSWERS. The server is behind - one answer per ~5 ms wakeup while the client
// runs ahead - so the replies arriving during the gap are its LATE ANSWERS TO THE
// EARLIER CREATES, and they fill the 8-slot buffer before the client comes back.
// * SO THIS CHECK FIRES TOO LATE BY CONSTRUCTION: it is consulted at the next create,
// and by then the answer is already gone. Tightening the constant does not fix that;
// it only removes the deferral entirely, which is what `pushed=65` is.
//
// A DEFERRAL IS THEREFORE READABLE ONLY IF THE CLIENT KEEPS THE ANSWERS IT DEFERRED. The
// reply buffer is bounded by COUNT (8) and the client cannot bound how many answers the
// server will post between two of its creates, so hoping the buffer still holds one is not
// a policy - it is a race the client loses. The shape that can work is RETENTION: the
// reader keeps the answers for the seqs still outstanding (a handful), drops them when
// they are read, and reports a LOSS when a read asks for one it no longer has. That is a
// transport change (StreamLink's ring becomes a retention map with a cap and a lost
// counter). Kept as-is because it is the safe direction - it degrades to the pre-window
// shape - and because the clock it needs is the one ReplyPostings() already provides.
constexpr Uint64 kCreateReplyResidency = Transport::kDefaultReplySlotCount - 1;
Bool CreateWindowBudgetSpent(ClientSession& session) {
if (g_createWindowCount == 0) return false;
return session.ReplyPostings() - g_createWindow[g_createWindowHead].ReplyStamp >=
kCreateReplyResidency;
}
// Retention replaced it, and retention is where the bound now lives: a link that declares
// RetainsReplies keeps an answer until its reader takes it (StreamLink's declared-answer
// store, whose capacity is tied to the window's own depth), and the window does not defer
// at all on a link that does not. That removes the only thing the budget was for.
// ReplyPostings() stays as a measurement.
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 MAY ONLY DEFER WHERE THE LINK RETAINS ANSWERS (P12). On one that does
// not - the shared pool, whose slot is seq % slotCount and is reused - the answer the
// drain comes back for may already be gone, and there is NO recovery: retrying wedges
// the row for the rest of the process, and re-latching the object by sending another
// create wipes the server's record along with its PendingUploads (PipeApply.cpp:2033,
// PipeApply.h:332) while the client cleared its dirty flags at emission
// (PipeApply.h:298-304), so nobody re-sends those texels. So it takes the blocking
// path instead, which is the pre-window shape exactly.
const Uint32 window = session.LinkRetainsReplies() ? CreateWindowEffective() : 1u;
// 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
@@ -615,6 +604,13 @@ namespace MobileGL::MG_Remote::Client {
// collected. See the section comment for the measurement and the arithmetic.
if (window <= 1 || g_createSuspects.size() >= kCreateSuspectMax ||
CreateIsSuspect(payload->Resource)) {
// THE WINDOW RETIRES HERE, AND IT MAY BE HOLDING ANSWERS (P12). This branch is
// taken when the window is off, when the suspect list is full, or for a refused
// object - and in the last two cases entries already emitted are still in flight.
// Without this drain they arrive, are retained, and are never read, WHILE their
// objects stay latched on a provisional accept: the silent divergence the
// suspect list exists to prevent. One blocking pass settles them.
if (g_createWindowCount != 0) DrainCreateWindow(session, /*blockForOldest=*/true);
EmitCreateBlocking(session, payload, reply);
return;
}
@@ -622,11 +618,7 @@ namespace MobileGL::MG_Remote::Client {
// The poll is not politeness: it is what keeps a burst shorter than the window from
// leaving its answers unread until the next burst.
DrainCreateWindow(session, /*blockForOldest=*/false);
// A SPENT BUDGET IS TREATED EXACTLY LIKE A FULL WINDOW: take the oldest answer back
// NOW, while it still exists, instead of deferring another one past its residency.
const Bool budgetSpent = CreateWindowBudgetSpent(session);
if (g_createWindowCount >= window || budgetSpent) {
if (budgetSpent) ++g_createWindowBudgetStops;
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
@@ -657,8 +649,7 @@ namespace MobileGL::MG_Remote::Client {
return;
}
g_createWindow[(g_createWindowHead + g_createWindowCount) % kCreateWindowMax] =
PendingCreate{seq, CreateRecordKind(*payload), payload->Resource,
session.ReplyPostings()};
PendingCreate{seq, CreateRecordKind(*payload), payload->Resource};
++g_createWindowCount;
reply->Id = seq;
MG_Pipe::MGPipePostReply(*reply, Wire::ReplySink::kStatusOk, 1u);
@@ -1277,11 +1268,10 @@ namespace MobileGL::MG_Remote::Client {
// the ones still outstanding. A create the window accepted and then LOST would break that
// sum, which is the failure a provisional accept makes possible and a count cannot show.
Uint64 ClientCreateWindowTaken() { return g_createWindowTaken; }
// How many times the residency budget, and not a full window, forced the blocking read. A
// non-zero value is the budget DOING its job (an answer was about to outlive its slot); a
// value near the record count means the window is deferring almost nothing, which is the
// honest way to say "the budget is too tight for this workload".
Uint64 ClientCreateWindowBudgetStops() { return g_createWindowBudgetStops; }
// P12: RetainedOutstanding / SkippedUnwanted / UnansweredWanted live on the LINK, where the
// store is (StreamLink::RetainedOutstanding and friends). They are what says whether the
// declared-answer store is bounded by the CLIENT (SkippedUnwanted grows, outstanding does
// not) rather than by the server's answer rate - which is the property the capacity rests on.
Uint32 ClientCreateWindowEffective() { return CreateWindowEffective(); }
Uint32 ClientCreateWindowArraySize() { return kCreateWindowMax; }
// Objects whose create came back DECLINED: out of the window, next create blocking, and back
+1 -5
View File
@@ -91,11 +91,7 @@ namespace MobileGL::MG_Remote::Client {
// drain that never collects leaves Pending at the ceiling and every other reading unchanged,
// which is exactly what the device measured before the stream link learned to answer by seq.
Uint64 ClientCreateWindowTaken();
// How many times the RESIDENCY BUDGET - not a full window - forced the blocking take: the
// oldest answer was about to outlive its reply slot. Non-zero is the budget doing its job;
// a value near the record count says the budget is too tight for this workload and the
// window is deferring almost nothing, which is the honest way to read that.
Uint64 ClientCreateWindowBudgetStops();
Uint32 ClientCreateWindowEffective();
Uint32 ClientCreateWindowArraySize();
Uint32 ClientCreateWindowSuspects();
+26
View File
@@ -199,6 +199,16 @@ namespace MobileGL::MG_Remote::Transport {
bool PublishIsDelivery = false; // false => Flush() is mandatory before any wait
std::uint64_t MaxRecordBytes = 0;
std::uint64_t MaxReplyBytes = 0;
// P12: DOES THIS LINK KEEP AN ANSWER UNTIL ITS READER TAKES IT? A stream does - the
// answers are the client's own bytes - and a shared pool does NOT: a slot is
// `seq % slotCount` and the next answer into it wins, so "the watermark passed this seq"
// says nothing about whether the answer is still there.
//
// IT IS A CAPABILITY RATHER THAN A HOPE because a row that DEFERS its answer (the create
// window) may only run where it is true: measured on the device, a deferral across the
// ~1,000 records between two creates lost its answer to a bounded buffer every time.
// A caller that sees false must take the blocking path instead.
bool RetainsReplies = false;
};
// Non-owning synchronization addresses, acquired once at attachment. They
@@ -285,6 +295,22 @@ namespace MobileGL::MG_Remote::Transport {
virtual MobileGLResult ReadReply(std::uint64_t seq, std::int32_t* outStatus,
const void** outPayload, std::uint64_t* outSize) = 0;
// P12: DECLARE THAT THIS SEQ'S ANSWER WILL BE READ, so that a link which can retain it
// does. Called by the producer side of the wire, in seq order, BEFORE the record is
// published - the answer cannot arrive before that, and the reader stores under the same
// mutex the declaration takes, so the hand-off needs no second synchronisation.
//
// WHY THE DECLARATION AND NOT "RETAIN EVERYTHING": a server that ignores the no-reply bit
// answers ~55,903 records in one frame where this client reads ~4,501, so retain-on-arrival
// grows ~11.3 entries per create and any cap is reached in C/11.3 creates - after which the
// eviction wave lands on precisely the oldest WANTED answer, which is the wedge this exists
// to avoid. Keyed on the declaration, the retained set is bounded by the CLIENT's own
// deferral depth instead of by the server's behaviour.
//
// A link with RetainsReplies false ignores it (the default), and its callers must not
// defer: see the capability's own note.
virtual void DeclareReplyRead(std::uint64_t seq) { (void)seq; }
// ---- the reverse channel --------------------------------------------
virtual LinkArena* EventArena() = 0;
+3 -1
View File
@@ -44,7 +44,9 @@ namespace MobileGL::MG_Remote::Transport {
}
LinkCapabilities ShmLink::Capabilities() const {
const ReplySlotPool replies(m_memory.ReplyBase(), m_memory.ReplyBytes(), m_memory.ReplySlotCount());
return {true, true, true, m_memory.CmdRingCapacity() / 2, replies.MaxReplyBytes()};
// RetainsReplies stays false: a slot is seq % slotCount and the next answer into it wins.
return {true, true, true, m_memory.CmdRingCapacity() / 2, replies.MaxReplyBytes(),
/*RetainsReplies=*/false};
}
LinkProgress* ShmLink::Progress() {
return m_memory.Valid() ? &m_memory.CmdControl()->Progress : nullptr;
+119 -24
View File
@@ -107,18 +107,52 @@ namespace MobileGL::MG_Remote::Transport {
// move (29.0 s against the 30.1 s of the same binary with the window off). The deferral
// was pure overhead: four answers held unread, plus one failed drain attempt per record.
//
// BOUNDED AT kDefaultReplySlotCount, which is the pool ShmLink stands in for: an answer a
// reader has not taken by the time eight newer ones have arrived is one the shm arm has
// already overwritten too, so the two transports now lose the same answers and no more.
// The byte vectors are sized per reply, so an idle link pays eight empty ones.
static constexpr std::size_t kReplyRing = 8;
// ---- AND THE RING WAS NOT ENOUGH, SO THE STORE IS KEYED ON A DECLARATION (P12) -------
//
// THE MEASUREMENT THAT SETTLED IT. With the ring at 8 the drain read back NOTHING:
// `taken=63 pushed=65 readfail=8,872` on a load frame, and the miss probe showed why -
// `want=310` on every miss while the ring had moved on to [1175..1504]. Between two
// creates this client emits on the order of a thousand records and looks at the window
// not once, so a deferral has to survive an unbounded amount of other traffic, and WHAT
// DISPLACES IT IS NOT WHAT THE CLIENT EMITS BUT WHAT THE SERVER ANSWERS - it is behind,
// and its late answers to the EARLIER creates fill a bounded buffer first.
//
// Ring 8 -> 1024 as a one-line experiment, and every deferred answer came back:
// `taken=4,499/4,500 readfail=0`, wall 27.9 s -> 23.0 s, and with the residency budget
// out of the way 28.66 s -> 16.80 s at window 2 and 11.45 s at window 4. So the property
// needed is "the answer is still there when the reader comes back", and 1024 is a
// STAND-IN for it, not the design: 1024 retained answers is ~2 GiB worst case, and "deep
// enough" is a probability rather than a bound.
//
// THE BOUND COMES FROM THE CLIENT. A seq is retained only if the wire DECLARED it will be
// read (ILink::DeclareReplyRead), so the outstanding set is the window's depth plus the
// one blocking row in flight plus whatever a retired window strands - it does not depend
// on how many answers the server chooses to send. Retaining on ARRIVAL instead would grow
// ~11.3 entries per create against a server that ignores the no-reply bit (55,903 answers
// a frame where this client reads 4,501), and any cap would then be reached and would
// evict precisely the oldest WANTED answer - the same wedge, a few hundred creates later.
//
// OVERFLOW IS FATAL AND NEVER AN EVICTION. An evicted wanted answer is unrecoverable:
// sending another create to re-latch the object wipes the server's record for it
// (PipeApply.cpp:2033) along with its PendingUploads (PipeApply.h:332), and the client
// cleared its dirty flags at EMISSION (PipeApply.h:298-304), so nobody re-sends those
// texels. The capacity is sized so that cannot be reached, and reaching it is a bug that
// says its own name.
static constexpr std::size_t kRetainedCap = 24;
struct StoredReply {
std::uint64_t seq = 0;
std::int32_t status = 0;
std::vector<std::uint8_t> bytes;
};
std::array<StoredReply, kReplyRing> replies{};
std::size_t replyNext = 0;
// MONOTONE ON BOTH SIDES, so a cursor beats a hash map: the wire declares in emission
// order, the server answers in apply order. It also makes a declared-but-never-answered
// seq visible for free - the cursor walks past it on the way to a later answer - which is
// counted rather than ignored, because the read that follows will fail and should fail
// with a number beside it.
std::vector<std::uint64_t> wanted;
std::size_t wantCursor = 0;
std::vector<StoredReply> retained;
std::uint64_t skippedUnwanted = 0, unansweredWanted = 0, retainedTotal = 0;
std::uint64_t lastProgressSeq = 0;
std::chrono::steady_clock::time_point lastProgress = std::chrono::steady_clock::now();
LinkArena records{}, events{};
@@ -334,15 +368,34 @@ namespace MobileGL::MG_Remote::Transport {
std::lock_guard<std::mutex> lock(replyMutex);
if (a <= replySeq) Corrupt("reply sequence moved backwards");
replySeq = a;
// RING, NOT REPLACE. replySeq stays the newest and stays monotone -
// the wait predicate below is written in terms of it - while the
// answers themselves are kept for the readers that come back for an
// older one (the window's drain, StreamLink.cpp's own note).
StoredReply& slot = replies[replyNext];
replyNext = (replyNext + 1) % kReplyRing;
slot.seq = a;
slot.status = static_cast<std::int32_t>(static_cast<std::uint32_t>(b));
slot.bytes.assign(payload, payload + size);
// DECLARED? The cursor walks past any DECLARED seq this answer has
// overtaken: the server answers in apply order, so an answer with a
// higher seq means those will never come. That is a broken invariant
// rather than a race, and it is counted - the read that follows fails
// and should fail with this number beside it.
while (wantCursor < wanted.size() && wanted[wantCursor] < a) {
++wantCursor;
++unansweredWanted;
}
if (wantCursor < wanted.size() && wanted[wantCursor] == a) {
++wantCursor;
// OVERFLOW IS NAMED, NOT ABSORBED. See the capacity note above:
// an eviction here would throw away an answer somebody is waiting
// for, and there is no path that recovers one.
if (retained.size() >= kRetainedCap) {
Corrupt("the declared-answer store overflowed; a declared answer"
" would have to be evicted and none is recoverable");
}
StoredReply& slot = retained.emplace_back();
slot.seq = a;
slot.status = static_cast<std::int32_t>(static_cast<std::uint32_t>(b));
slot.bytes.assign(payload, payload + size);
++retainedTotal;
} else {
// NOBODY DECLARED IT: drop it. This is what keeps the store bounded
// against a server that answers records this client never reads.
++skippedUnwanted;
}
}
replyReady.notify_all();
producer.Notify();
@@ -464,7 +517,8 @@ namespace MobileGL::MG_Remote::Transport {
}
LinkCapabilities StreamLink::Capabilities() const {
if (!m_impl->memory) return {};
return {false, false, false, m_impl->memory->CmdRingCapacity() / 2, m_impl->MaxReply()};
return {false, false, false, m_impl->memory->CmdRingCapacity() / 2, m_impl->MaxReply(),
/*RetainsReplies=*/true};
}
LinkProgress* StreamLink::Progress() {
return m_impl->memory ? &m_impl->C().Progress : nullptr;
@@ -589,17 +643,22 @@ namespace MobileGL::MG_Remote::Transport {
return x.closed.load(std::memory_order_acquire) || x.replySeq >= seq ||
x.C().Progress.appliedSeq.load(std::memory_order_acquire) >= seq;
});
// BY SEQ, WHICH IS WHAT THE PARAMETER HAS ALWAYS SAID (P12). The equality this replaces
// BY SEQ, WHICH IS WHAT THE PARAMETER HAS ALWAYS SAID (P12). The equality this replaced
// was true only while every caller read the newest answer; it is what made a deferred
// answer unreadable and the create window inert on this transport.
for (const auto& slot : x.replies) {
if (slot.seq != seq) continue;
// Copied into the shared scratch so the pointer outlives the lock, exactly as the
// single-slot version did: the caller memcpys under its own frame.
x.readReply = slot.bytes;
*status = slot.status;
for (auto it = x.retained.begin(); it != x.retained.end(); ++it) {
if (it->seq != seq) continue;
// TAKE-AND-DELETE IS ONE STEP UNDER THE LOCK. The bytes are SWAPPED into the shared
// scratch before the entry goes, so whoever is handed the answer is whoever consumed
// it - no copy of a large answer, and no window in which a second reader could see
// the same seq. (The pointer outlives the lock, as it always has here; this link has
// ONE reply reader, and the store's erase is what keeps that an invariant rather
// than a hope.)
x.readReply.swap(it->bytes);
*status = it->status;
*size = x.readReply.size();
*p = x.readReply.data();
x.retained.erase(it);
return MOBILEGL_OK;
}
return x.closed.load() ? MOBILEGL_ERR_TRANSPORT_CLOSED : MOBILEGL_ERR_PROTOCOL_MISMATCH;
@@ -624,12 +683,48 @@ namespace MobileGL::MG_Remote::Transport {
TransportRoleTag StreamLink::Role() const {
return m_impl->role;
}
void StreamLink::DeclareReplyRead(std::uint64_t seq) {
auto& x = *m_impl;
if (!x.Client() || !x.memory) return;
std::lock_guard<std::mutex> lock(x.replyMutex);
// MONOTONE, IN EMISSION ORDER (the wire declares before it publishes, and seqs only ever
// grow). An out-of-order or repeated declaration is dropped rather than stored: the first
// would break the cursor, and the second is a retry that must not grow the list.
if (!x.wanted.empty() && seq <= x.wanted.back()) return;
x.wanted.push_back(seq);
// AND THE CONSUMED PREFIX IS RELEASED, or the list would grow by one entry per declared
// record for the life of the session (4,536 a frame). The cursor's entries can never be
// needed again: a seq is answered once.
if (x.wantCursor >= 64) {
x.wanted.erase(x.wanted.begin(),
x.wanted.begin() + static_cast<std::ptrdiff_t>(x.wantCursor));
x.wantCursor = 0;
}
}
std::uint64_t StreamLink::RetainedOutstanding() const {
auto& x = *m_impl;
std::lock_guard<std::mutex> lock(x.replyMutex);
return static_cast<std::uint64_t>(x.retained.size());
}
std::uint64_t StreamLink::RetainedTotal() const { return m_impl->retainedTotal; }
std::uint64_t StreamLink::SkippedUnwanted() const { return m_impl->skippedUnwanted; }
std::uint64_t StreamLink::UnansweredWanted() const { return m_impl->unansweredWanted; }
void StreamLink::Detach() {
auto& x = *m_impl;
if (!x.memory) return;
x.stopping.store(true, std::memory_order_release);
x.Die(false);
if (x.reader.joinable()) x.reader.join();
// P12: the declared-answer store does not outlive the link. Without this a detached
// session leaves up to a frame's worth of answers (and their seqs) resident.
{
std::lock_guard<std::mutex> lock(x.replyMutex);
x.wanted.clear();
x.wantCursor = 0;
x.retained.clear();
}
#if !defined(_WIN32)
if (x.fd >= 0) ::close(x.fd);
#endif
+11
View File
@@ -36,6 +36,17 @@ namespace MobileGL::MG_Remote::Transport {
MobileGLResult ResolveSpan(LinkSegment, LinkSpan, const void**) override;
MobileGLResult PostReply(std::uint64_t, std::int32_t, const void*, std::uint64_t) override;
MobileGLResult ReadReply(std::uint64_t, std::int32_t*, const void**, std::uint64_t*) override;
void DeclareReplyRead(std::uint64_t) override;
// P12 diagnostics for the declared-answer store. RetainedOutstanding is what the capacity
// is about (it must stay near the window's depth, not grow with the server's answers);
// RetainedTotal counts the answers kept for their readers; SkippedUnwanted counts the ones
// nobody declared, which is the number that proves the store is bounded by the CLIENT and
// not by the server; UnansweredWanted counts declared seqs the server never answered.
std::uint64_t RetainedOutstanding() const;
std::uint64_t RetainedTotal() const;
std::uint64_t SkippedUnwanted() const;
std::uint64_t UnansweredWanted() const;
LinkArena* EventArena() override;
LinkCursor* EventCursor() override;
std::uint64_t EventPublishedHead() const override;
+51 -80
View File
@@ -1205,21 +1205,22 @@ TEST(RemoteClientControls, ABufferRespecifyTakesNoReplyWhileATextureOneStillDoes
// 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:
// ⚠️ THIS CASE RUNS ON THE IN-PROCESS ARM, WHICH IS ShmLink, AND SINCE P12 THAT MEANS THE WINDOW
// IS OFF HERE BY DESIGN - `CreateWindowEffective()` is gated on LinkRetainsReplies() precisely
// because a shared pool's slot is `seq % slotCount` and is reused, so a deferred answer may be
// gone with no way to recover it. What this case pins is therefore THAT GATE, and it is the
// assertion the device cannot make: set the knob as high as it goes and the row must STILL take the
// blocking path on a link that cannot retain.
//
// 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) {
// THE WINDOW'S OWN BEHAVIOUR IS PINNED ELSEWHERE, ON THE ARM IT SHIPS ON: StreamLinkTest's
// declared-answer cases for the store, and the stream-arm window case for the row. That split is
// not tidiness - three rounds of this feature were green here while being inert on the device,
// because these gates and the device run on different transports.
//
// WHAT IS STILL READ HERE, because it is transport-independent: the environment cannot raise the
// window past the array, the array cannot exceed half the reply pool, and the invariant holds after
// every create.
TEST(RemoteClientControls, TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers) {
const auto child = RunInChild([] {
::setenv("MOBILEGL_PIPE_STATS", "1", 1);
CapsPeer backend;
@@ -1266,68 +1267,30 @@ TEST(RemoteClientControls, ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot)
}
};
// ---- THE WINDOW ON: twelve creates, strictly fewer waits ----
// ---- THE KNOB IS AS HIGH AS IT GOES, AND THE ROW MUST STILL BLOCK ----
MG_Config::Ipc.CreateWindow = 4;
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",
std::printf("non-retaining link: window=%u records=%llu waits=%llu taken=%llu pending=%u high_water=%u\n",
static_cast<unsigned>(MG_Config::Ipc.CreateWindow),
static_cast<unsigned long long>(records),
static_cast<unsigned long long>(waits),
static_cast<unsigned long long>(ClientCreateWindowTaken()),
static_cast<unsigned>(ClientCreateWindowPending()),
static_cast<unsigned>(highWater));
std::fflush(nullptr);
if (records < 12) ::_exit(164);
// ---- AND THE ANSWERS CAME BACK, WHICH IS THE ASSERTION THAT CANNOT BE FAKED ----
// A deferral that is never collected looks EXACTLY like a working one from every other
// reading in this case: Pending sits at the ceiling, the fallback keeps the frame moving,
// and the record count is right. It is also what the device measured for a whole session
// (pending pinned at 4, readfail 8,951) and what a no-reply bit keyed on `wantReply` would
// produce ON PURPOSE - the window's emits are wantReply=false, so a server that skipped
// them would leave this at zero while `records`, `waits` and `pending` all stayed green.
const Uint64 taken = ClientCreateWindowTaken();
std::printf("create window ON: answers_taken=%llu outstanding=%u fallbacks=%llu\n",
static_cast<unsigned long long>(taken),
static_cast<unsigned>(ClientCreateWindowPending()),
static_cast<unsigned long long>(records > taken + ClientCreateWindowPending()
? records - taken - ClientCreateWindowPending()
: 0u));
std::fflush(nullptr);
if (taken == 0) ::_exit(174);
// NOTHING IS INVENTED AND NOTHING IS LOST: every answered create is either back in hand
// or still outstanding, and the remainder took the blocking fallback.
if (taken + ClientCreateWindowPending() > records) ::_exit(175);
// 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);
// NOTHING WAS DEFERRED AT ALL, and nothing was left outstanding: on a link that cannot
// retain, a window that deferred would be holding answers it may never get back.
if (ClientCreateWindowTaken() != 0) ::_exit(177);
if (ClientCreateWindowPending() != 0) ::_exit(178);
// AND EVERY CREATE PAID ITS ROUND TRIP, which is the pre-window shape exactly. This is the
// assertion that goes red if the capability gate is removed.
if (waits != records) ::_exit(179);
// ---- 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<unsigned>(MG_Config::Ipc.CreateWindow),
static_cast<unsigned long long>(offRecords),
static_cast<unsigned long long>(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);
// AND THE WINDOW TOOK NOTHING WHILE IT WAS OFF, which is the control the ON half's
// `taken` needs: without it, a `taken` that grew for some unrelated reason would read as
// a working deferral.
if (ClientCreateWindowTaken() != taken) ::_exit(176);
ClientSessionInstance().Stop();
});
ExpectChildSuccess(child);
@@ -1337,14 +1300,18 @@ TEST(RemoteClientControls, ACreateWindowDefersTheAnswersAndTheWindowOffDoesNot)
// 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 answer is what PUBLISHES an object, so the one thing a refusal must never do is leave the
// client believing it landed: every row that reads the latch would then act on a record the server
// does not hold. This case reads the whole chain - the refusal leaves the object UNLATCHED, its
// next storage definition BLOCKS and is refused again, and giving the family its consumer back
// HEALS it (the create is accepted and the latch comes back).
//
// ⚠️ RUNS ON THE IN-PROCESS ARM (ShmLink), WHERE THE WINDOW IS OFF, so what repairs the object
// here is the LATCH ALONE - the suspect table belongs to the window's provisional accept and the
// drain is the only thing that fills it, which this case now asserts by finding it EMPTY. The
// window's own refusal path is pinned on the arm it ships on (StreamLinkTest's declared-answer
// cases and the stream-arm window case); see the note on the gate above for why that split is not
// tidiness.
//
// 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
@@ -1384,7 +1351,7 @@ TEST(RemoteClientControls, ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlock
MG_Impl::GLImpl::GenTextures(1, &filler);
defineStorage(filler, 8);
}
// ---- (a) THE LATCH IS CLEAR AND THE OBJECT IS OUT OF THE WINDOW ----
// ---- (a) THE LATCH IS CLEAR, AND THE WINDOW NEVER RAN HERE AT ALL ----
const auto refusedObject = MG_State::pGLContext->GetTextureObject(refused);
if (refusedObject == nullptr) ::_exit(174);
const MG_Pipe::MGPipeHandle refusedHandle =
@@ -1393,9 +1360,14 @@ TEST(RemoteClientControls, ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlock
// 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);
// AND THE WINDOW'S OWN BOOKS ARE EMPTY, because this arm cannot retain answers and the
// window therefore never deferred - `refusals` and `suspects` are both filled by the
// drain and by nothing else. A non-zero reading here would mean the capability gate had
// been removed and the row was deferring on a link that cannot hold an answer.
if (ClientCreateWindowRefusals() != 0) ::_exit(177);
const Uint32 suspectsBefore = ClientCreateWindowSuspects();
if (suspectsBefore == 0) ::_exit(178);
if (suspectsBefore != 0) ::_exit(178);
if (ClientCreateWindowPending() != 0) ::_exit(183);
// ---- (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
@@ -1413,18 +1385,17 @@ TEST(RemoteClientControls, ARefusedCreateUnlatchesItsObjectAndItsNextCreateBlock
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.
// The create still blocks (the latch is still clear), now it LANDS, and landing is what
// re-publishes the object for every row that reads the latch.
Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a);
defineStorage(refused, 32);
std::printf("refused create healed: published=%d suspects %u -> %u\n",
std::printf("refused create healed: published=%d suspects=%u\n",
static_cast<int>(MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture,
refusedHandle)),
static_cast<unsigned>(suspectsBefore),
static_cast<unsigned>(ClientCreateWindowSuspects()));
std::fflush(nullptr);
if (!MG_Pipe::MGPipeHandleIsPublished(MG_Pipe::MGPipeKind::Texture, refusedHandle)) ::_exit(181);
if (ClientCreateWindowSuspects() != suspectsBefore - 1) ::_exit(182);
if (ClientCreateWindowSuspects() != suspectsBefore) ::_exit(182);
ClientSessionInstance().Stop();
});
ExpectChildSuccess(child);
+55
View File
@@ -116,6 +116,11 @@ namespace {
Queue(2, 2);
ASSERT_EQ(client.Flush(), MOBILEGL_OK);
ASSERT_EQ(apply.WaitForWork(2000), SessionWait::Reached);
// P12: AN ANSWER IS KEPT ONLY IF SOMEBODY DECLARED IT WILL BE READ. Seq 1 is deliberately
// NOT declared - this case is about an unwanted reply not disturbing a later one - and
// seq 2 is, because the case reads it. Both records cross either way: the declaration
// changes whether the LINK keeps the answer, never whether the server sends it.
client.DeclareReplyRead(2);
ASSERT_TRUE(apply.ApplyOne([&](const RingRecordView&) {
const std::uint64_t value = 11;
EXPECT_EQ(server.PostReply(1, 0, &value, sizeof value), MOBILEGL_OK);
@@ -152,6 +157,7 @@ namespace {
Queue(3, 3);
ASSERT_EQ(client.Flush(), MOBILEGL_OK);
ASSERT_EQ(apply.WaitForWork(2000), SessionWait::Reached);
for (std::uint64_t seq = 1; seq <= 3; ++seq) client.DeclareReplyRead(seq);
for (std::uint64_t seq = 1; seq <= 3; ++seq) {
ASSERT_TRUE(apply.ApplyOne([&](const RingRecordView&) {
const std::uint64_t value = seq * 11;
@@ -175,6 +181,55 @@ namespace {
}
}
// =====================================================================================
// P12: THE DECLARED-ANSWER STORE - WHAT A BOUNDED BUFFER OF "THE LAST N" COULD NOT DO
// =====================================================================================
//
// The create window defers an answer across an unbounded amount of other traffic (measured on
// the device: ~1,000 records between two creates) and a buffer of the last N answers has always
// moved on by the time the reader comes back - `want=310` on every miss while the ring spanned
// [1175..1504]. Ring 8 -> 1024 as an experiment made every deferred answer come back, and these
// two cases are what replaces that stand-in: an answer is kept because somebody DECLARED it
// will be read, so the retained set is bounded by the client's own deferral depth.
TEST_F(StreamPair, ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer) {
constexpr std::uint64_t kIntervening = 64;
client.DeclareReplyRead(1);
const std::uint64_t wanted = 0x5EED;
ASSERT_EQ(server.PostReply(1, 0, &wanted, sizeof wanted), MOBILEGL_OK);
// SIXTY-FOUR answers nobody declared. Under "keep the last N" - any N this link has ever
// had - seq 1 is gone by the end of this loop; under a declaration it is untouched, which
// is the whole difference between a bound and a probability.
for (std::uint64_t seq = 2; seq <= kIntervening + 1; ++seq) {
const std::uint64_t junk = seq;
ASSERT_EQ(server.PostReply(seq, 0, &junk, sizeof junk), MOBILEGL_OK);
}
EXPECT_TRUE(Eventually([&] { return client.SkippedUnwanted() >= kIntervening; }))
<< "the undeclared answers were not dropped: " << client.SkippedUnwanted();
std::int32_t status = -1;
const void* p = nullptr;
std::uint64_t size = 0;
ASSERT_EQ(client.ReadReply(1, &status, &p, &size), MOBILEGL_OK)
<< "the declared answer did not survive " << kIntervening << " intervening ones";
ASSERT_EQ(size, sizeof(std::uint64_t));
std::uint64_t value = 0;
std::memcpy(&value, p, sizeof value);
EXPECT_EQ(value, wanted);
EXPECT_EQ(status, 0);
// AND TAKING IT RELEASED IT: the store holds only what is still outstanding, which is why
// its capacity can be tied to the window's depth rather than to the server's answer rate.
EXPECT_EQ(client.RetainedOutstanding(), 0u);
EXPECT_EQ(client.RetainedTotal(), 1u);
}
TEST_F(StreamPair, AnAnswerNobodyDeclaredIsNotKeptAtAll) {
const std::uint64_t value = 7;
ASSERT_EQ(server.PostReply(1, 0, &value, sizeof value), MOBILEGL_OK);
EXPECT_TRUE(Eventually([&] { return client.SkippedUnwanted() >= 1; }));
EXPECT_EQ(client.RetainedOutstanding(), 0u)
<< "an undeclared answer was retained: the store would grow with the SERVER's answer"
" rate, which is what makes any capacity a matter of luck";
}
TEST_F(StreamPair, EventDrainReturnsCreditAndClearsRemoteFullLatch) {
EventRingProducer events(serverMemory.EventControl(), serverMemory.CmdControl(), serverMemory.EventRingBase(),
1024);
@@ -287,7 +287,41 @@ if (x.replySeq != seq) return ... MOBILEGL_ERR_PROTOCOL_MISMATCH; // 等的是
——答案走 Reply 帧、`StreamLink::ReadReply` 读客户端自己的 store,池只用来给 `MaxReplyBytes()` 定尺寸。
所以「加深池子 + 抬 `ReplyBytes`」根本没有针对实测那条臂,别为它付 8× 常驻内存。
## 12. 复算
## 12. 落地:保留表上线,默认翻到 2,真机 28.66 s → 16.52 s
按审计的形状实现(`ILink::DeclareReplyRead` + `LinkCapabilities::RetainsReplies`;`StreamLink` 的环 →
**按声明的保留表**:单调白名单 + 游标、取走即删、触顶具名 Fatal、Detach 清空;删掉 residency budget;
`CreateWindowEffective()` 在不保留答案的链路上强制为 1;窗口退休分支补一次 drain)。
**真机(两个二进制都重出,负载帧,`frame-sent 2=` 全程 4,536 不变):**
| 臂 | waits | `by_op 2` | 墙钟 |
|---|---|---|---|
| 窗口 1(R-5 的形状) | 4,543 | 4,536 | 28.66 s |
| 窗口 2(显式) | 2,507 | 2,500 | 17.27 s |
| **窗口 2(出厂默认,无覆盖)** | 2,570 | 2,563 | **16.52 s** |
| 窗口 4 | 1,651 | 1,644 | 11.02 s |
| **窗口 2 + 旧服务端(不认 no-reply 位)** | 2,670 | 2,663 | **16.38 s** |
**版本偏斜那一轮是审计点名的必测项,它过了**:旧服务端照回 ~55,903 条/帧,客户端的白名单把这些
**没人声明**的答案在到达时丢弃(`SkippedUnwanted` 增长而不是保留表增长),延后的答案照旧读得回来,
整轮**零 Fatal**。这正是「按声明保留」相对「全保留 + 淘汰」的那个差别。
**门**(172 + 15 全绿):
- `StreamLinkTest.ADeclaredAnswerSurvivesFarMoreInterveningAnswersThanAnyFixedBuffer` —— **已证明能红**:
把存储退回「按到达保留、只留 8 条」的环语义,它立刻以「the declared answer did not survive 64
intervening ones」失败。这就是把环=1024 那个替身换成有界机制的那条界线。
- `StreamLinkTest.AnAnswerNobodyDeclaredIsNotKeptAtAll` —— 没声明的答案不进保留表(红法:全保留)。
- `RemoteClientControls.TheWindowRefusesToDeferOnALinkThatCannotRetainAnswers` —— 在不能保留的链路
(in-process/ShmLink)上,把旋钮开到 4 也必须每条 create 都走阻塞、`taken == 0`(红法:去掉能力位)。
- `ARefusedCreate...` 改成读这条臂上真正的修复链:拒绝 → 闩保持清零 → 下一次定义阻塞且再被拒 → 恢复消费者后闩回来;
并断言窗口的账本(`refusals`/`suspects`/`pending`)在这条臂上**全为空**。
**还没做的一件**(审计 E10(d),下一轮):窗口级门目前只有真机这一层,**没有跑在 stream 臂上的单元门**。
三轮「门全绿而功能在设备上是死的」都源于同一件事——门跑在 in-process/ShmLink 上。传输层的保留门已经补上,
窗口那一层建议建在 `StreamClientPublicationTest` 的真 stream 会话上(`AttachStreamLink` + 可控 peer)。
## 13. 复算
~~~sh
# 两臂的帧读数(handoff-2 §1 的读法)