mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-17 08:38:30 +09:00
[Merge] (MGPipe, P5): integrate package s1, round 3 - ID-47 and ID-48 findings 5, 6, 7, 10, ID-51
This commit is contained in:
@@ -144,7 +144,7 @@ These three are the reason `kHasBlob` had to be given an exact meaning (table 0)
|
||||
|---|---|---|---|---|
|
||||
| 21 | `MapPersistent` (5) | `kReplySlot\|kOptional` | **Returns `nullptr` under split, always** (R-6/R-2.4). Its `const void* seedBytes` companion (`PipeApply.h:917`) therefore never crosses in P5 and needs no carrier. The three frontend sites already tolerate a decline (`BufferObject.cpp:238`, `:603-606`, `:657-660`). Answer travels as `Status = DECLINED` with a zero-length payload. | `map_persistent.decline` |
|
||||
| 22 | `ResourceReadback` (52) | `kReplySlot` | Bytes go **server → client** in `SEG_EVENT` via `OnBufferWriteback` (#3), not in the reply slot: the destination is the client's shadow and the size is the resource's, not a fixed slot's. The reply slot carries only completion. **The ordering rule is load-bearing:** the writeback is applied **before** the mutation epoch bumps, never after (`ARCHITECTURE.md:292-294`, `Managers.cpp:2120-2136`). | `resource_readback.done` |
|
||||
| 23 | `ReadPixels` (58) / `GetTextureImage` (55) | `kReplySlot` | **`ReadPixels` blocks in P5** and its pixels come back in the reply slot, which is why `ReplyPool::SlotBytes()` is sized from the scenario's largest read rather than guessed. `MGPReadbackInfo` has `DstOffset`/`DstSize` but **no `Seg`** (`MGPipeTypes.h:1197-1206`): ruling — the destination is **always `SEG_REPLY`** in P5, so no `Seg` field is added; the PBO destination (fire-and-forget plus a client-side `MarkGpuWritten`) is b1's and also needs none, because a PBO destination is a resource handle rather than a segment. `GetTextureImage` is **not on P5's reduced path** and its slot stays `Fatal{UnmigratedVerb}`. | `read_pixels.pixels` |
|
||||
| 23 | `ReadPixels` (58) / `GetTextureImage` (55) | `kReplySlot` | **`ReadPixels` blocks in P5** and its pixels come back in the reply slot, which is why `ReplyPool::SlotBytes()` is sized from the scenario's largest read rather than guessed. **ID-47: `SEG_REPLY` is 16 MiB, eight slots of 2 MiB, `MaxReplyBytes = 2 MiB − 16 = 2,097,136`** — the canonical sizes are 8/32/16 MiB + 256 KiB (`ProtocolSmokeTest` pins them on the wire, `SessionTest` on the mapping); the largest P5 read is E2's full-surface 640×480 RGBA8 snapshot, 1,228,800 bytes, which the previous 8 MiB pool refused. **A read whose answer would exceed `MaxReplyBytes` is refused at the CLIENT before emission** with `Fatal{ReplyTooLarge, "ReadPixels <w>x<h> <format> <bytes> > <cap>"}` (`ReplySlotPool::RequireReadPixelsFits`, forwarded by `ClientSession::RequireReadPixelsReplyFits`, called once by c1's `OnReadPixels` emitter) — never truncated, never a server-side abort the client cannot name; `Post`'s own refusal stays as the last line of defence. Reads larger than 2 MiB (a 2400×1080 RGBA8 device surface) are a P6 debt, not a bigger pool; §5 has no knob for this segment by design. `MGPReadbackInfo` has `DstOffset`/`DstSize` but **no `Seg`** (`MGPipeTypes.h:1197-1206`): ruling — the destination is **always `SEG_REPLY`** in P5, so no `Seg` field is added; the PBO destination (fire-and-forget plus a client-side `MarkGpuWritten`) is b1's and also needs none, because a PBO destination is a resource handle rather than a segment. `GetTextureImage` is **not on P5's reduced path** and its slot stays `Fatal{UnmigratedVerb}`. | `read_pixels.pixels` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -175,21 +175,6 @@ namespace MobileGL::MG_Remote {
|
||||
constexpr Uint64 kFormatCells = static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount) *
|
||||
static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount);
|
||||
|
||||
// FNV-1a, the same mixer the tree already uses for build-stamp style fingerprints.
|
||||
constexpr Uint64 kFnvOffset = 1469598103934665603ull;
|
||||
constexpr Uint64 kFnvPrime = 1099511628211ull;
|
||||
|
||||
Uint64 FnvBytes(Uint64 hash, const void* bytes, SizeT size) {
|
||||
const auto* p = static_cast<const Uint8*>(bytes);
|
||||
for (SizeT i = 0; i < size; ++i) {
|
||||
hash ^= static_cast<Uint64>(p[i]);
|
||||
hash *= kFnvPrime;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
Uint64 FnvU64(Uint64 hash, Uint64 value) { return FnvBytes(hash, &value, sizeof(value)); }
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------------
|
||||
@@ -469,7 +454,7 @@ namespace MobileGL::MG_Remote {
|
||||
// The ABI assertion the handshake carries
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
Uint64 CapsAbiFingerprint() {
|
||||
Transport::AbiFingerprintInputs CapsAbiFingerprintInputs() {
|
||||
// MGPCaps has only a COMPOSITIONAL size assertion (MGPipeTypes.h:145-146) because
|
||||
// DynamicBackendParameters still carries SizeT and GLenum members - P0.5's fixed-width
|
||||
// rewrite did not happen and P5 does not do it either (table 0's ABI row; the rewrite
|
||||
@@ -479,23 +464,28 @@ namespace MobileGL::MG_Remote {
|
||||
// The git stamp is in it because two builds of the same sizes can still disagree about
|
||||
// a FIELD ORDER, which no sizeof can see; P6's spawn is same-machine and same-binary,
|
||||
// so it inherits this unchanged rather than needing a looser rule.
|
||||
Uint64 hash = kFnvOffset;
|
||||
hash = FnvU64(hash, sizeof(MG_Backend::DynamicBackendParameters));
|
||||
hash = FnvU64(hash, sizeof(MG_Pipe::MGPCaps));
|
||||
hash = FnvU64(hash, sizeof(MG_Backend::GLFunctionsTable));
|
||||
hash = FnvU64(hash, static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount));
|
||||
hash = FnvU64(hash, static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount));
|
||||
hash = FnvU64(hash, kFormatCapabilitiesCodecVersion);
|
||||
hash = FnvU64(hash, kRendererInfoCodecVersion);
|
||||
hash = FnvU64(hash, static_cast<Uint64>(MG_Pipe::MGPWireOp::kOpCount));
|
||||
Transport::AbiFingerprintInputs inputs;
|
||||
inputs.DynamicParamsSize = sizeof(MG_Backend::DynamicBackendParameters);
|
||||
inputs.CapsSize = sizeof(MG_Pipe::MGPCaps);
|
||||
inputs.FunctionTableSize = sizeof(MG_Backend::GLFunctionsTable);
|
||||
inputs.FormatCapabilityTargets = static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount);
|
||||
inputs.FormatCapabilityFormats = static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount);
|
||||
inputs.FormatCapabilitiesCodecVersion = kFormatCapabilitiesCodecVersion;
|
||||
inputs.RendererInfoCodecVersion = kRendererInfoCodecVersion;
|
||||
inputs.OpCount = static_cast<Uint64>(MG_Pipe::MGPWireOp::kOpCount);
|
||||
// The declared protocol ABI, carried over from s1's version of this function at
|
||||
// integration (ID-33). The sizeofs above catch a struct that changed shape; this
|
||||
// catches a peer that changed the PROTOCOL while every struct stayed the same size,
|
||||
// which is the one break the rest of the mix is blind to.
|
||||
hash = FnvU64(hash,
|
||||
static_cast<Uint64>(MOBILEGL_ABI_VERSION(MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR)));
|
||||
hash = FnvBytes(hash, GIT_COMMIT_HASH_SHORT, std::strlen(GIT_COMMIT_HASH_SHORT));
|
||||
return hash;
|
||||
inputs.AbiVersion =
|
||||
static_cast<Uint32>(MOBILEGL_ABI_VERSION(MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR));
|
||||
inputs.BuildStamp = GIT_COMMIT_HASH_SHORT;
|
||||
return inputs;
|
||||
}
|
||||
|
||||
// ONE implementation, deliberately a one-liner: the wave-1 review (ID-46 finding 6) found a
|
||||
// second hand-rolled FNV loop here while the mixer under Transport/ had no production caller,
|
||||
// so the sensitivity test could not see this function change. Now it starts from here.
|
||||
Uint64 CapsAbiFingerprint() { return Transport::MixAbiFingerprint(CapsAbiFingerprintInputs()); }
|
||||
|
||||
} // namespace MobileGL::MG_Remote
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
#include <MG_Backend/BackendObject.h>
|
||||
#include <MG_Pipe/MGPipe.h>
|
||||
|
||||
#include "Transport/SessionRings.h" // AbiFingerprintInputs / MixAbiFingerprint
|
||||
|
||||
namespace MobileGL::MG_Remote {
|
||||
|
||||
// ---- CallMask's layout (c0's ruling, extending R-8) ---------------------------------
|
||||
@@ -90,10 +92,17 @@ namespace MobileGL::MG_Remote {
|
||||
|
||||
// ---- the ABI assertion the handshake carries ----------------------------------------
|
||||
//
|
||||
// Mixes sizeof(DynamicBackendParameters), sizeof(MGPCaps), sizeof(GLFunctionsTable) and
|
||||
// the compile-time build fingerprint. Compared in Hello/Welcome; a mismatch is
|
||||
// Fatal{AbiMismatch} and never a downgrade, because every alternative silently reads one
|
||||
// struct as another.
|
||||
// The inputs, as this build sees them: sizeof(DynamicBackendParameters), sizeof(MGPCaps),
|
||||
// sizeof(GLFunctionsTable), the format-capability table's extents, the two caps-blob codec
|
||||
// versions, MGPWireOp::kOpCount, the protocol ABI version and the compile-time git stamp.
|
||||
// Public so that the sensitivity control can pin every one of them to the real value AND
|
||||
// perturb them one at a time through the same mixer the handshake uses.
|
||||
Transport::AbiFingerprintInputs CapsAbiFingerprintInputs();
|
||||
|
||||
// What Hello/Welcome carry and compare: EXACTLY Transport::MixAbiFingerprint(
|
||||
// CapsAbiFingerprintInputs()) - one implementation, no second hash (ID-46 finding 6). A
|
||||
// mismatch is Fatal{AbiMismatch} and never a downgrade, because every alternative silently
|
||||
// reads one struct as another.
|
||||
Uint64 CapsAbiFingerprint();
|
||||
|
||||
} // namespace MobileGL::MG_Remote
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <MG_Util/Debug/Log.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace MobileGL::MG_Remote::Client {
|
||||
@@ -150,7 +151,25 @@ namespace MobileGL::MG_Remote::Client {
|
||||
|
||||
// ---- 1. the control plane and the two bells. The transport owns the bells; THE
|
||||
// SESSION owns the rings, and the accessors stay off ITransport (contract §3.9).
|
||||
Transport::InProcessTransport::CreatePair(m_clientTransport, m_serverTransport);
|
||||
std::unique_ptr<Transport::InProcessTransport> clientEnd;
|
||||
std::unique_ptr<Transport::InProcessTransport> serverEnd;
|
||||
Transport::InProcessTransport::CreatePair(clientEnd, serverEnd);
|
||||
return StartOverTransportPair(std::move(clientEnd), std::move(serverEnd));
|
||||
}
|
||||
|
||||
MobileGLResult ClientSession::StartOverTransportPair(
|
||||
std::unique_ptr<Transport::InProcessTransport> clientEnd,
|
||||
std::unique_ptr<Transport::InProcessTransport> serverEnd) {
|
||||
if (m_started) {
|
||||
return MOBILEGL_ERR_INVALID_ARGUMENT;
|
||||
}
|
||||
if (clientEnd == nullptr || serverEnd == nullptr) {
|
||||
MGLOG_E("MG_Remote client: StartOverTransportPair needs both ends of one "
|
||||
"InProcessTransport::CreatePair");
|
||||
return MOBILEGL_ERR_INVALID_ARGUMENT;
|
||||
}
|
||||
m_clientTransport = std::move(clientEnd);
|
||||
m_serverTransport = std::move(serverEnd);
|
||||
m_transport = m_clientTransport.get();
|
||||
|
||||
// ---- 2. Hello. Sent before the server accepts: InProcessTransport queues whole
|
||||
@@ -451,6 +470,15 @@ namespace MobileGL::MG_Remote::Client {
|
||||
|
||||
Uint32 ClientSession::MaxReplyBytes() const { return m_replies.MaxReplyBytes(); }
|
||||
|
||||
Bool ClientSession::ReplyCanHold(Uint64 bytes) const { return m_replies.CanHold(bytes); }
|
||||
|
||||
// ID-47. Forwarded verbatim so that the message, the boundary and the abort are the pool's
|
||||
// and are pinned once, in SessionTest, rather than re-derived per caller.
|
||||
void ClientSession::RequireReadPixelsReplyFits(Uint32 width, Uint32 height, Uint32 format,
|
||||
Uint32 type, Uint64 bytes) const {
|
||||
m_replies.RequireReadPixelsFits(width, height, format, type, bytes);
|
||||
}
|
||||
|
||||
Transport::EventRingConsumer& ClientSession::Events() { return m_events; }
|
||||
|
||||
Transport::RingControl* ClientSession::Control() { return m_shm.CmdControl(); }
|
||||
|
||||
@@ -69,6 +69,19 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// ran monolith and went green" failure, and it must be loud.
|
||||
MobileGLResult Start(MG_Config::TransportMode mode, const String& endpoint);
|
||||
|
||||
// Start()'s second half: the handshake and everything after it, over a transport pair
|
||||
// the CALLER made with InProcessTransport::CreatePair. Start() refuses every mode but
|
||||
// `inproc`, makes the pair, and calls this; it is public for exactly one reason. The
|
||||
// Welcome guard below (envelope->msg_as_Welcome() == nullptr, ID-46 finding 7) can only
|
||||
// be reached by a frame that arrives on the server->client direction BEFORE the
|
||||
// server's own Welcome, and Start() builds that pair itself, so no control could put
|
||||
// one there. SessionHandshakeTest does it through here, and the null-union Welcome must
|
||||
// come back as MOBILEGL_ERR_PROTOCOL_MISMATCH with the guard's own line. Not a second
|
||||
// way to start a session: MG_Backend::Init() calls Start(), and nothing else may call
|
||||
// this with a pair it did not just create.
|
||||
MobileGLResult StartOverTransportPair(std::unique_ptr<Transport::InProcessTransport> clientEnd,
|
||||
std::unique_ptr<Transport::InProcessTransport> serverEnd);
|
||||
|
||||
// Teardown order matters and is table 3's fourth column: publish and let the server
|
||||
// drain, Doorbell::Kill() (the ONLY thing that wakes an apply thread parked on
|
||||
// kWaitForever, Doorbell.h:211-221), then join, and only then release anything an
|
||||
@@ -127,6 +140,18 @@ namespace MobileGL::MG_Remote::Client {
|
||||
// What one answer may carry. A ReadPixels bigger than this is Fatal rather than
|
||||
// chunked, so the client checks BEFORE it emits.
|
||||
Uint32 MaxReplyBytes() const;
|
||||
// ID-47, the fifth primitive: true exactly when an answer of `bytes` can be posted.
|
||||
Bool ReplyCanHold(Uint64 bytes) const;
|
||||
// ID-47's named refusal, forwarded verbatim to ReplySlotPool::RequireReadPixelsFits.
|
||||
// Returns when the answer fits; otherwise
|
||||
// Fatal{ReplyTooLarge, "ReadPixels <w>x<h> <format> <bytes> > <cap>"}
|
||||
// and abort - AT THE CLIENT, BEFORE EMISSION. Package c1's OnReadPixels emitter calls
|
||||
// this once, immediately before EmitAndWait(MGPWireOp::ReadPixels, ...), with the
|
||||
// record's box, its Format/Type enums and the DstSize it computed; the server's Post
|
||||
// keeps its own refusal as the last line of defence, but that one fires on the apply
|
||||
// thread with the record already on the wire, where all the client sees is a hang.
|
||||
void RequireReadPixelsReplyFits(Uint32 width, Uint32 height, Uint32 format, Uint32 type,
|
||||
Uint64 bytes) const;
|
||||
|
||||
// The reverse channel's reading end: OnBufferWriteback / OnGpuWritten /
|
||||
// OnSurfaceChanged. Drained by the GL thread between verbs.
|
||||
|
||||
@@ -28,10 +28,13 @@
|
||||
// uploads from the other side, and a client that folds it into OK accepts a
|
||||
// pointer the server never handed out.
|
||||
//
|
||||
// A REPLY LARGER THAN ONE SLOT IS FATAL, NOT CHUNKED. P5's only large answer is
|
||||
// ReadPixels, and the client knows its size before it emits the record, so an
|
||||
// overflow means the two sides disagree about the frame rather than that the
|
||||
// pool is too small. Chunking is P8's; growing the pool is an operator's.
|
||||
// A REPLY LARGER THAN ONE SLOT IS FATAL, NOT CHUNKED, AND THE CLIENT SAYS SO
|
||||
// FIRST (ID-47). P5's only large answer is ReadPixels, and the client knows its
|
||||
// size before it emits the record, so it refuses an oversize read BY NAME before
|
||||
// emission (RequireReadPixelsFits: Fatal{ReplyTooLarge, "ReadPixels <w>x<h>
|
||||
// <format> <bytes> > <cap>"}); Post's own refusal is the server's last line of
|
||||
// defence, and reaching it means the two sides disagree about the frame rather
|
||||
// than that the pool is too small. Chunking is a P6 debt; the pool has no knob.
|
||||
//
|
||||
// ORDERING. The client only looks at a slot after it has seen
|
||||
// RingControl::appliedSeq >= its own seq with an ACQUIRE load, and the server
|
||||
@@ -77,15 +80,28 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
kReplyStatusError = 2,
|
||||
};
|
||||
|
||||
// Eight slots of a 8 MiB SEG_REPLY is 1 MiB per answer.
|
||||
// Eight slots of a 16 MiB SEG_REPLY is 2 MiB per slot, and MaxReplyBytes() is
|
||||
// 2 MiB minus the 16-byte header = 2,097,136 bytes per answer (ID-47).
|
||||
//
|
||||
// Why eight and not sixty-four: while the verb barrier holds (R-1) the client
|
||||
// blocks at every verb boundary, so the in-flight depth is exactly ONE and
|
||||
// every extra slot buys nothing but a smaller maximum answer. The trade is
|
||||
// the other way round - fewer slots, bigger replies - and P5's only large
|
||||
// answer is a blocking ReadPixels. 1 MiB covers a 512x512 RGBA8 read. P9,
|
||||
// which is what makes the pool asynchronous, re-chooses this geometry with
|
||||
// real depth to size it against.
|
||||
// answer is a blocking ReadPixels. P9, which is what makes the pool
|
||||
// asynchronous, re-chooses this geometry with real depth to size it against.
|
||||
//
|
||||
// WHY 2 MiB AND NOT 1. The first version of this file said "1 MiB covers a
|
||||
// 512x512 RGBA8 read", and it did not: 512*512*4 is exactly 1 MiB and the
|
||||
// slot header takes 16 of those bytes, so Post refused it by sixteen. Worse,
|
||||
// the E2 retrace harness's snapshot is a full-surface GL_RGBA/GL_UNSIGNED_BYTE
|
||||
// read of OpenRA's 640x480 surface = 1,228,800 bytes through the interposer,
|
||||
// 17% over the old cap - Post would have aborted on the first snapshot the
|
||||
// day the client's ReadPixels emitter landed. Contract §2 row 23 says the
|
||||
// slot is "sized from the scenario's largest read rather than guessed";
|
||||
// 2 MiB is that size for every P5 exit-gate read (E2 is the largest at
|
||||
// 640x480). A 2400x1080 RGBA8 device surface is ~10.4 MB and is a P6 debt
|
||||
// (chunked readback or a dedicated readback carrier), recorded in the
|
||||
// ROADMAP by the integrator - the geometry here is not the place it is paid.
|
||||
inline constexpr std::uint32_t kDefaultReplySlotCount = 8;
|
||||
|
||||
// Slot 0 exists and is used: seq is 1-based, so seq % slotCount hits slot 0
|
||||
@@ -152,6 +168,44 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
: m_slotBytes - static_cast<std::uint32_t>(sizeof(ReplySlotHeader));
|
||||
}
|
||||
|
||||
// ID-47: THE CLIENT'S HALF of "a reply larger than one slot is fatal". True
|
||||
// exactly when an answer of `bytes` can be posted into this pool: the pool
|
||||
// is configured and `bytes <= MaxReplyBytes()`. The boundary is inclusive
|
||||
// and SessionTest pins it from both sides.
|
||||
bool CanHold(std::uint64_t bytes) const { return m_base != nullptr && bytes <= MaxReplyBytes(); }
|
||||
|
||||
// ID-47's named refusal, AT THE CLIENT, BEFORE EMISSION. Returns when the
|
||||
// answer fits; otherwise
|
||||
// Fatal{ReplyTooLarge, "ReadPixels <w>x<h> <format> <bytes> > <cap>"}
|
||||
// and abort. Never truncated, never chunked, and never a server-side
|
||||
// abort the client cannot name: Post's own refusal below stays as the
|
||||
// last line of defence, but it fires on the apply thread with the record
|
||||
// already on the wire, where the only thing the client sees is a hang.
|
||||
//
|
||||
// `format`/`type` are the GL enums the record carries (MGPReadbackInfo::
|
||||
// Format/Type), printed as the hex pair every pipe diagnostic uses; the
|
||||
// caller passes the byte count it computed for the record's DstSize, so
|
||||
// what is refused is exactly what would have been posted. This is the one
|
||||
// call c1's OnReadPixels emitter makes before EmitAndWait; the
|
||||
// ClientSession forwards it verbatim (RequireReadPixelsReplyFits).
|
||||
void RequireReadPixelsFits(std::uint32_t width, std::uint32_t height, std::uint32_t format,
|
||||
std::uint32_t type, std::uint64_t bytes) const {
|
||||
if (CanHold(bytes)) {
|
||||
return;
|
||||
}
|
||||
WireLogFatal("MGPipe: Fatal{ReplyTooLarge, \"ReadPixels %ux%u 0x%04X/0x%04X %llu > %u\"} - "
|
||||
"the answer does not fit one SEG_REPLY slot (%u slots of %u bytes, payload "
|
||||
"cap %u; a cap of 0 means no reply pool is configured). Refused at the "
|
||||
"client before emission (ID-47): P5 neither truncates nor chunks a reply, "
|
||||
"and a read larger than the cap is a P6 debt (chunked readback), not a "
|
||||
"bigger pool",
|
||||
static_cast<unsigned>(width), static_cast<unsigned>(height),
|
||||
static_cast<unsigned>(format), static_cast<unsigned>(type),
|
||||
static_cast<unsigned long long>(bytes),
|
||||
static_cast<unsigned>(MaxReplyBytes()), static_cast<unsigned>(m_slots),
|
||||
static_cast<unsigned>(m_slotBytes), static_cast<unsigned>(MaxReplyBytes()));
|
||||
}
|
||||
|
||||
// Zeroes every header, so a stale seq from a previous session cannot be
|
||||
// mistaken for this session's answer. Called on the server side at Accept.
|
||||
void Clear() {
|
||||
@@ -170,24 +224,25 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
// chunked - see the file header.
|
||||
void Post(std::uint64_t seq, std::int32_t status, const void* bytes, std::uint64_t size) {
|
||||
if (m_base == nullptr) {
|
||||
WireLogError("MG_Remote reply pool: Post(seq=%llu) on an unconfigured pool",
|
||||
WireLogFatal("MG_Remote reply pool: Fatal{ProtocolCorruption} - Post(seq=%llu) on an "
|
||||
"unconfigured pool",
|
||||
static_cast<unsigned long long>(seq));
|
||||
std::abort();
|
||||
}
|
||||
if (seq == 0) {
|
||||
WireLogError("MG_Remote reply pool: seq 0 is \"no record\" and can never name a "
|
||||
"slot (R-3: seq is 1-based)");
|
||||
std::abort();
|
||||
WireLogFatal("MG_Remote reply pool: Fatal{ProtocolCorruption} - seq 0 is \"no "
|
||||
"record\" and can never name a slot (R-3: seq is 1-based)");
|
||||
}
|
||||
if (size > MaxReplyBytes()) {
|
||||
WireLogError("MG_Remote reply pool: Fatal{ProtocolCorruption} - a %llu byte answer "
|
||||
// The server's LAST line of defence, not the first: the client refuses an
|
||||
// oversize ReadPixels by name before it emits (RequireReadPixelsFits, ID-47),
|
||||
// so reaching this means the two sides disagree about the frame.
|
||||
WireLogFatal("MG_Remote reply pool: Fatal{ProtocolCorruption} - a %llu byte answer "
|
||||
"for seq %llu does not fit a %u byte slot (payload cap %u). P5 does "
|
||||
"not chunk replies: the client knows an answer's size before it emits "
|
||||
"the record, so this means the two sides disagree about the frame",
|
||||
static_cast<unsigned long long>(size),
|
||||
static_cast<unsigned long long>(seq), static_cast<unsigned>(m_slotBytes),
|
||||
static_cast<unsigned>(MaxReplyBytes()));
|
||||
std::abort();
|
||||
}
|
||||
std::uint8_t* slot = SlotAt(seq);
|
||||
if (size != 0 && bytes != nullptr) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "Ring.h"
|
||||
|
||||
#include "SessionRings.h"
|
||||
#include "WireLog.h"
|
||||
|
||||
#include <MG_Util/Debug/Log.h>
|
||||
|
||||
@@ -364,13 +365,15 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
const char* name) {
|
||||
const std::uint64_t current = watermark.load(std::memory_order_relaxed);
|
||||
if (to < current) {
|
||||
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"watermark\"} %s moved backwards, "
|
||||
"%llu -> %llu. A waiter that already resumed on the higher value cannot "
|
||||
"be un-resumed, and every later advance of this watermark would be a "
|
||||
"no-op, so the verb barrier and every reply wait would block for ever",
|
||||
// WireLogFatal, not MGLOG_F + abort: the line has to reach stderr
|
||||
// for SessionTestDeath's control to name it (WireLog.h).
|
||||
WireLogFatal("MGPipe: Fatal{ProtocolCorruption, \"watermark\"} %s moved backwards, "
|
||||
"%llu -> %llu. A waiter that already resumed on the higher value "
|
||||
"cannot be un-resumed, and every later advance of this watermark "
|
||||
"would be a no-op, so the verb barrier and every reply wait would "
|
||||
"block for ever",
|
||||
name, static_cast<unsigned long long>(current),
|
||||
static_cast<unsigned long long>(to));
|
||||
std::abort();
|
||||
}
|
||||
if (to == current) {
|
||||
return;
|
||||
@@ -611,13 +614,12 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
// The ABI fingerprint's mixer
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
std::uint64_t MixAbiFingerprint(std::uint64_t dynamicParamsSize, std::uint64_t capsSize,
|
||||
std::uint64_t functionTableSize, std::uint32_t abiVersion,
|
||||
const char* buildStamp) {
|
||||
// FNV-1a over the four numbers and the stamp. Not a hash with any
|
||||
// security property and not meant to be one: it has to (a) change when
|
||||
// ANY input changes and (b) be computable identically in two processes
|
||||
// built from one source tree, which rules out anything seeded at runtime.
|
||||
std::uint64_t MixAbiFingerprint(const AbiFingerprintInputs& inputs) {
|
||||
// FNV-1a over every field, in declaration order, then the stamp. Not a
|
||||
// hash with any security property and not meant to be one: it has to
|
||||
// (a) change when ANY input changes and (b) be computable identically in
|
||||
// two processes built from one source tree, which rules out anything
|
||||
// seeded at runtime.
|
||||
std::uint64_t hash = 1469598103934665603ull;
|
||||
const auto mix = [&hash](std::uint64_t value) {
|
||||
for (int byte = 0; byte < 8; ++byte) {
|
||||
@@ -625,12 +627,21 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
hash *= 1099511628211ull;
|
||||
}
|
||||
};
|
||||
mix(dynamicParamsSize);
|
||||
mix(capsSize);
|
||||
mix(functionTableSize);
|
||||
mix(abiVersion);
|
||||
if (buildStamp != nullptr) {
|
||||
for (const char* c = buildStamp; *c != '\0'; ++c) {
|
||||
mix(inputs.DynamicParamsSize);
|
||||
mix(inputs.CapsSize);
|
||||
mix(inputs.FunctionTableSize);
|
||||
mix(inputs.FormatCapabilityTargets);
|
||||
mix(inputs.FormatCapabilityFormats);
|
||||
mix(inputs.FormatCapabilitiesCodecVersion);
|
||||
mix(inputs.RendererInfoCodecVersion);
|
||||
mix(inputs.OpCount);
|
||||
mix(inputs.AbiVersion);
|
||||
// A presence marker before the bytes, so that "no stamp" (nullptr) and
|
||||
// "an empty stamp" ("") are different inputs rather than the same
|
||||
// absence of bytes.
|
||||
mix(inputs.BuildStamp != nullptr ? 1u : 0u);
|
||||
if (inputs.BuildStamp != nullptr) {
|
||||
for (const char* c = inputs.BuildStamp; *c != '\0'; ++c) {
|
||||
hash ^= static_cast<std::uint64_t>(static_cast<unsigned char>(*c));
|
||||
hash *= 1099511628211ull;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
namespace MobileGL::MG_Remote::Transport {
|
||||
@@ -55,6 +56,19 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
// from one that was not sampled.
|
||||
std::uint64_t ProcessCurrentRssBytes();
|
||||
|
||||
// BOTH, FROM ONE PASS OVER /proc/self/status. The kernel does not keep
|
||||
// hiwater_rss up to date on growth - it stores it only when RSS is about to
|
||||
// DROP, and task_mem() reports max(stored hiwater, rss-at-this-read) - so
|
||||
// two separate reads are not comparable: the fopen of the second read can
|
||||
// itself grow RSS past the VmHWM the first read reported. That is exactly
|
||||
// what GitHub run 35079459114 caught (VmHWM 4,784,128 < VmRSS 4,849,664 on
|
||||
// ubuntu-24.04) and what ~/w7/p5-s1-probe reproduced locally: two reads
|
||||
// disagree by 128 KiB with nothing allocated between them, one read never
|
||||
// does (0/1000 with 64 KiB of growth per iteration). One pass is therefore
|
||||
// the only way to read a pair, and even then the pair is a snapshot, not a
|
||||
// bound - see SampleRoleMemoryInto.
|
||||
void ProcessRssBytes(std::uint64_t* outPeakBytes, std::uint64_t* outCurrentBytes);
|
||||
|
||||
// The ledger. ShmSegment does NOT update it itself: a segment is also created
|
||||
// by tests and by P6's adopt path, and a ledger that counted those would stop
|
||||
// meaning "this session's footprint". The SESSION books its own segments.
|
||||
@@ -68,6 +82,17 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
std::uint64_t LedgerMappedBytesAllRoles();
|
||||
|
||||
// One sample, both halves, for one role.
|
||||
//
|
||||
// PeakRssBytes IS THE LEDGER'S OWN RUNNING MAXIMUM, not the kernel's VmHWM.
|
||||
// It is the largest of every kernel peak and every kernel current this
|
||||
// process has folded in through SampleRoleMemoryInto, so it is >= this
|
||||
// sample's CurrentRssBytes BY CONSTRUCTION and never decreases. The kernel's
|
||||
// VmHWM feeds it (it is a lower bound on the true peak that this process's
|
||||
// own samples may have missed) but is never reported on its own: as
|
||||
// ProcessRssBytes explains, the kernel's peak is not a monotone bound on the
|
||||
// kernel's current at read time, so a sample that reported VmHWM verbatim
|
||||
// could show a peak below its own current - which is a number t1 cannot put
|
||||
// in MEASUREMENTS and a test cannot assert against. GitHub run 35079459114.
|
||||
struct RoleMemorySample {
|
||||
std::uint64_t PeakRssBytes = 0;
|
||||
std::uint64_t CurrentRssBytes = 0;
|
||||
@@ -75,8 +100,21 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
MemoryRole Role = MemoryRole::Client;
|
||||
};
|
||||
|
||||
// One pass over /proc/self/status folded into the PROCESS's running peak.
|
||||
RoleMemorySample SampleRoleMemory(MemoryRole role);
|
||||
|
||||
// The fold itself, over a running peak the CALLER owns: `runningPeak`
|
||||
// becomes max(runningPeak, kernelPeakRssBytes, kernelCurrentRssBytes) and
|
||||
// the sample reports that as PeakRssBytes beside `kernelCurrentRssBytes`.
|
||||
// SampleRoleMemory calls this with the process-wide running peak and the
|
||||
// numbers it just read; SessionTest calls it with a running peak of its own
|
||||
// and a stubbed reader whose current EXCEEDS its peak, which must not fail
|
||||
// (R-16's control on this rule: revert PeakRssBytes to the kernel's peak and
|
||||
// it does).
|
||||
RoleMemorySample SampleRoleMemoryInto(std::atomic<std::uint64_t>& runningPeak, MemoryRole role,
|
||||
std::uint64_t kernelPeakRssBytes,
|
||||
std::uint64_t kernelCurrentRssBytes);
|
||||
|
||||
// Emits one line at INFO level, which is what every P5 lane builds at, so
|
||||
// t1's harness can grep it out of a lane log without a new log sink. Not
|
||||
// DEBUG, which the INFO build compiles out; not ERROR, which this is not.
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
//
|
||||
// SegmentRef.sizeBytes therefore announces the MAPPING size (ring + page), which
|
||||
// is what a spawn peer must mmap. The four numbers a reader recognises - 8 MiB /
|
||||
// 32 MiB / 8 MiB / 256 KiB - are the RING sizes, which is what the knobs name.
|
||||
// 32 MiB / 16 MiB / 256 KiB (ID-47) - are the RING sizes, which is what the
|
||||
// knobs name.
|
||||
//
|
||||
// SEG_STAGE has no control page of its own: RingControl carries TWO cursor
|
||||
// triples (Ring.h:101-109) and the stage triple is the second. So SEG_STAGE is
|
||||
@@ -86,7 +87,12 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
// two either. Rounding was a ring requirement and keeping it would have
|
||||
// silently turned an operator's MOBILEGL_IPC_STAGE_MB=24 into 16.
|
||||
std::uint64_t StageBytes = 32ull * 1024 * 1024;
|
||||
std::uint64_t ReplyBytes = 8ull * 1024 * 1024; // slot pool, not a ring
|
||||
// SEG_REPLY: a slot pool, not a ring, and NO KNOB MOVES IT (contract §5 has
|
||||
// none). ID-47: 16 MiB = eight slots of 2 MiB, sized from the largest P5
|
||||
// read - the E2 retrace's full-surface 640x480 RGBA8 snapshot, 1,228,800
|
||||
// bytes - which the previous 8 MiB / 1 MiB-per-slot pool could not hold
|
||||
// (ReplySlot.h says how it was found). ProtocolSmokeTest pins the number.
|
||||
std::uint64_t ReplyBytes = 16ull * 1024 * 1024;
|
||||
std::uint64_t EventRingBytes = 256ull * 1024; // + one control page
|
||||
std::uint32_t ReplySlotCount = kDefaultReplySlotCount;
|
||||
};
|
||||
@@ -473,17 +479,48 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The ABI fingerprint's mixer.
|
||||
// The ABI fingerprint's mixer - THE ONE IMPLEMENTATION.
|
||||
//
|
||||
// It lives under Transport/ rather than in CapsCodec.cpp so that it can be
|
||||
// tested without the GL frontend's umbrella header, and so that the SIZES it
|
||||
// mixes are the caller's - CapsCodec.cpp passes the three real sizeofs, a
|
||||
// unit test passes made-up ones and can then prove a one-byte difference
|
||||
// changes the answer. A fingerprint that cannot be shown to change is
|
||||
// indistinguishable from one that is never compared.
|
||||
// CapsCodec.cpp's CapsAbiFingerprint(), the value both handshakes compare,
|
||||
// is exactly MixAbiFingerprint(CapsAbiFingerprintInputs()). It lives under
|
||||
// Transport/ so that it can be tested without the GL frontend's umbrella
|
||||
// header, and it takes its inputs as a struct so that the SAME function the
|
||||
// handshake calls can be driven with one field perturbed at a time.
|
||||
//
|
||||
// The wave-1 review (ID-46 finding 6) found the previous shape - a
|
||||
// five-argument mixer here and a SEPARATE hand-rolled FNV loop in
|
||||
// CapsCodec.cpp - had exactly one caller of this function: the sensitivity
|
||||
// test. Production never called it, so replacing CapsAbiFingerprint() with
|
||||
// `return 1;` left every fingerprint test green and both peers agreeing on
|
||||
// nothing. Now there is one mixer, the sensitivity case starts from the
|
||||
// production entry point, and that perturbation turns it red.
|
||||
// -----------------------------------------------------------------------
|
||||
std::uint64_t MixAbiFingerprint(std::uint64_t dynamicParamsSize, std::uint64_t capsSize,
|
||||
std::uint64_t functionTableSize, std::uint32_t abiVersion,
|
||||
const char* buildStamp);
|
||||
struct AbiFingerprintInputs {
|
||||
// The three struct shapes table 0's ABI-agreement row names.
|
||||
std::uint64_t DynamicParamsSize = 0;
|
||||
std::uint64_t CapsSize = 0;
|
||||
std::uint64_t FunctionTableSize = 0;
|
||||
// The caps blob's own geometry and the two blob codecs' versions: the
|
||||
// format-capability table's extents and the codec version stamps.
|
||||
std::uint64_t FormatCapabilityTargets = 0;
|
||||
std::uint64_t FormatCapabilityFormats = 0;
|
||||
std::uint64_t FormatCapabilitiesCodecVersion = 0;
|
||||
std::uint64_t RendererInfoCodecVersion = 0;
|
||||
// The catalogue's length (ID-33): a peer with one more opcode is a
|
||||
// different wire even if every struct kept its size.
|
||||
std::uint64_t OpCount = 0;
|
||||
// MOBILEGL_ABI_VERSION(major, minor): a protocol change that left every
|
||||
// struct the same size, which nothing above can see.
|
||||
std::uint32_t AbiVersion = 0;
|
||||
// GIT_COMMIT_HASH_SHORT: two builds of the same sizes can still disagree
|
||||
// about a FIELD ORDER, which no sizeof can see. nullptr and "" are
|
||||
// distinct inputs and neither equals a real stamp.
|
||||
const char* BuildStamp = nullptr;
|
||||
};
|
||||
|
||||
// FNV-1a over every field above, in declaration order. Never 0: that value is
|
||||
// reserved for "not stated", so a peer that forgot to fill the field cannot
|
||||
// accidentally agree with one that did.
|
||||
std::uint64_t MixAbiFingerprint(const AbiFingerprintInputs& inputs);
|
||||
|
||||
} // namespace MobileGL::MG_Remote::Transport
|
||||
|
||||
@@ -78,43 +78,73 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
return role == MemoryRole::Server ? "server" : "client";
|
||||
}
|
||||
|
||||
// One pass over /proc/self/status for a "VmHWM:" / "VmRSS:" line. The
|
||||
// values are in kB and the unit suffix is part of the line, so it is
|
||||
// parsed rather than assumed.
|
||||
std::uint64_t ProcStatusBytes(const char* key) {
|
||||
#if defined(__linux__) || defined(__ANDROID__)
|
||||
std::FILE* file = std::fopen("/proc/self/status", "re");
|
||||
if (file == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
// "<key>:\t <number> kB" -> bytes. The unit suffix is part of the
|
||||
// line, so it is parsed rather than assumed; anything else is a kernel
|
||||
// this code has not seen, and 0 ("not measured") is the honest answer.
|
||||
bool ParseKilobyteLine(const char* line, const char* key, std::uint64_t* outBytes) {
|
||||
const std::size_t keyLength = std::strlen(key);
|
||||
char line[256];
|
||||
std::uint64_t bytes = 0;
|
||||
while (std::fgets(line, sizeof(line), file) != nullptr) {
|
||||
if (std::strncmp(line, key, keyLength) != 0) {
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
unsigned long long kilobytes = 0;
|
||||
// The format is "<key>:\t <number> kB". Anything else is a
|
||||
// kernel this code has not seen, and 0 ("not measured") is the
|
||||
// honest answer for it.
|
||||
if (std::sscanf(line + keyLength, ": %llu kB", &kilobytes) == 1) {
|
||||
bytes = static_cast<std::uint64_t>(kilobytes) * 1024ull;
|
||||
*outBytes = static_cast<std::uint64_t>(kilobytes) * 1024ull;
|
||||
}
|
||||
break;
|
||||
return true;
|
||||
}
|
||||
std::fclose(file);
|
||||
return bytes;
|
||||
#else
|
||||
(void)key;
|
||||
return 0;
|
||||
#endif
|
||||
|
||||
// The process-wide running peak SampleRoleMemory folds into. One per
|
||||
// process, like VmHWM itself; under inproc both roles share it and the
|
||||
// log line says so.
|
||||
std::atomic<std::uint64_t>& ProcessRunningPeak() {
|
||||
static std::atomic<std::uint64_t> peak{0};
|
||||
return peak;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::uint64_t ProcessPeakRssBytes() { return ProcStatusBytes("VmHWM"); }
|
||||
// ONE PASS FOR BOTH KEYS - see RoleMemory.h. A pass per key was the wave-1
|
||||
// shape and it is what GitHub run 35079459114 caught: the second fopen grew
|
||||
// RSS past the VmHWM the first pass had reported, because the kernel only
|
||||
// stores hiwater_rss when RSS is about to drop and reports max(stored, now)
|
||||
// otherwise, so the two reads were snapshots of different "now"s.
|
||||
void ProcessRssBytes(std::uint64_t* outPeakBytes, std::uint64_t* outCurrentBytes) {
|
||||
std::uint64_t peak = 0;
|
||||
std::uint64_t current = 0;
|
||||
#if defined(__linux__) || defined(__ANDROID__)
|
||||
std::FILE* file = std::fopen("/proc/self/status", "re");
|
||||
if (file != nullptr) {
|
||||
char line[256];
|
||||
bool sawPeak = false;
|
||||
bool sawCurrent = false;
|
||||
while ((!sawPeak || !sawCurrent) && std::fgets(line, sizeof(line), file) != nullptr) {
|
||||
if (!sawPeak && ParseKilobyteLine(line, "VmHWM", &peak)) {
|
||||
sawPeak = true;
|
||||
} else if (!sawCurrent && ParseKilobyteLine(line, "VmRSS", ¤t)) {
|
||||
sawCurrent = true;
|
||||
}
|
||||
}
|
||||
std::fclose(file);
|
||||
}
|
||||
#endif
|
||||
if (outPeakBytes != nullptr) {
|
||||
*outPeakBytes = peak;
|
||||
}
|
||||
if (outCurrentBytes != nullptr) {
|
||||
*outCurrentBytes = current;
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t ProcessCurrentRssBytes() { return ProcStatusBytes("VmRSS"); }
|
||||
std::uint64_t ProcessPeakRssBytes() {
|
||||
std::uint64_t peak = 0;
|
||||
ProcessRssBytes(&peak, nullptr);
|
||||
return peak;
|
||||
}
|
||||
|
||||
std::uint64_t ProcessCurrentRssBytes() {
|
||||
std::uint64_t current = 0;
|
||||
ProcessRssBytes(nullptr, ¤t);
|
||||
return current;
|
||||
}
|
||||
|
||||
void LedgerAddSegment(MemoryRole role, std::uint64_t bytes) {
|
||||
LedgerSlot(role).fetch_add(bytes, std::memory_order_relaxed);
|
||||
@@ -140,15 +170,35 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
return total;
|
||||
}
|
||||
|
||||
RoleMemorySample SampleRoleMemory(MemoryRole role) {
|
||||
RoleMemorySample SampleRoleMemoryInto(std::atomic<std::uint64_t>& runningPeak, MemoryRole role,
|
||||
std::uint64_t kernelPeakRssBytes,
|
||||
std::uint64_t kernelCurrentRssBytes) {
|
||||
// max(running, kernel peak, kernel current), as a CAS loop: two threads
|
||||
// sampling at once (the GL thread and the apply thread both log memory
|
||||
// at phase boundaries) must not let a lower value overwrite a higher.
|
||||
const std::uint64_t observed =
|
||||
kernelPeakRssBytes > kernelCurrentRssBytes ? kernelPeakRssBytes : kernelCurrentRssBytes;
|
||||
std::uint64_t peak = runningPeak.load(std::memory_order_relaxed);
|
||||
while (observed > peak &&
|
||||
!runningPeak.compare_exchange_weak(peak, observed, std::memory_order_relaxed)) {
|
||||
}
|
||||
RoleMemorySample sample;
|
||||
sample.Role = role;
|
||||
sample.PeakRssBytes = ProcessPeakRssBytes();
|
||||
sample.CurrentRssBytes = ProcessCurrentRssBytes();
|
||||
// THE RUNNING MAXIMUM, never the kernel's peak verbatim - RoleMemory.h
|
||||
// says why, and SessionTest's stubbed-reader control is the gate on it.
|
||||
sample.PeakRssBytes = observed > peak ? observed : peak;
|
||||
sample.CurrentRssBytes = kernelCurrentRssBytes;
|
||||
sample.MappedSegmentBytes = LedgerMappedBytes(role);
|
||||
return sample;
|
||||
}
|
||||
|
||||
RoleMemorySample SampleRoleMemory(MemoryRole role) {
|
||||
std::uint64_t kernelPeak = 0;
|
||||
std::uint64_t kernelCurrent = 0;
|
||||
ProcessRssBytes(&kernelPeak, &kernelCurrent);
|
||||
return SampleRoleMemoryInto(ProcessRunningPeak(), role, kernelPeak, kernelCurrent);
|
||||
}
|
||||
|
||||
void LogRoleMemory(const char* phase, const RoleMemorySample& sample) {
|
||||
// INFO, not DEBUG: t1 greps this out of a lane log and MGLOG_D is compiled out at the
|
||||
// INFO level every P5 lane builds at. It is a handful of lines per session - the
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace MobileGL::MG_Remote::Transport {
|
||||
|
||||
@@ -30,4 +31,23 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
MGLOG_E("%s", line);
|
||||
}
|
||||
|
||||
void WireLogFatal(const char* format, ...) {
|
||||
char line[512];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
const int written = std::vsnprintf(line, sizeof(line), format, args);
|
||||
va_end(args);
|
||||
if (written < 0) {
|
||||
std::snprintf(line, sizeof(line),
|
||||
"MG_Remote wire: unformattable Fatal diagnostic (format=%s)", format);
|
||||
}
|
||||
MGLOG_F("%s", line);
|
||||
// The stderr echo is the half a death test can see (WireLog.h). Unbuffered
|
||||
// by default and flushed anyway: abort() does not flush stdio.
|
||||
std::fputs(line, stderr);
|
||||
std::fputc('\n', stderr);
|
||||
std::fflush(stderr);
|
||||
std::abort();
|
||||
}
|
||||
|
||||
} // namespace MobileGL::MG_Remote::Transport
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
// the one header under Transport/ that broke the rule; it now calls this
|
||||
// instead, and the umbrella stays inside WireLog.cpp.
|
||||
//
|
||||
// ERROR only, deliberately. Everything routed here is a latched protocol
|
||||
// violation, never per-frame noise; non-critical wire lines use MGLOG_D from a
|
||||
// .cpp, where the INFO build compiles them out entirely.
|
||||
// ERROR and FATAL only, deliberately. Everything routed here is a latched
|
||||
// protocol violation, never per-frame noise; non-critical wire lines use
|
||||
// MGLOG_D from a .cpp, where the INFO build compiles them out entirely.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -35,4 +35,25 @@ namespace MobileGL::MG_Remote::Transport {
|
||||
void
|
||||
WireLogError(const char* format, ...);
|
||||
|
||||
// Formats one line, emits it at FATAL level (MGLOG_F), ECHOES IT TO STDERR,
|
||||
// and aborts. Every `Fatal{...}` this layer raises goes through here.
|
||||
//
|
||||
// WHY STDERR AS WELL. Defines.h builds the logger with the console sink OFF
|
||||
// and the file sink ON, so an MGLOG line reaches exactly one place: the log
|
||||
// file. A Fatal is the last thing the process says, and three readers need
|
||||
// it somewhere other than a file whose path they may not know: a terminal,
|
||||
// a CI job log, and a gtest death test - which matches its regex against
|
||||
// the CHILD'S STDERR and nothing else. The wave-1 review (ID-46 finding 10)
|
||||
// found both SessionTestDeath controls written with an empty regex for
|
||||
// precisely that reason: with the diagnostic reachable only through the
|
||||
// file sink there was nothing on stderr to name, so a bare std::abort() or
|
||||
// a segfault satisfied them. The echo is what lets a death control name
|
||||
// its diagnostic (R-16: a negative control asserts its own failure reason).
|
||||
[[noreturn]]
|
||||
#if defined(__GNUC__) || defined(__clang__)
|
||||
__attribute__((format(printf, 1, 2)))
|
||||
#endif
|
||||
void
|
||||
WireLogFatal(const char* format, ...);
|
||||
|
||||
} // namespace MobileGL::MG_Remote::Transport
|
||||
|
||||
@@ -65,3 +65,33 @@ if (MSVC)
|
||||
endif ()
|
||||
|
||||
gtest_discover_tests(PipeWireCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
|
||||
# P5 s1's handshake suite (wave 1.5, ID-46 findings 6 and 7): the two null-union guards driven
|
||||
# THROUGH ServerSession::Accept and ClientSession::StartOverTransportPair, and the ABI
|
||||
# fingerprint's sensitivity case driven from CapsAbiFingerprint(), the production entry point.
|
||||
# Separate from SessionTest for two reasons: it needs CapsCodec.h and the sessions, i.e. the GL
|
||||
# frontend's umbrella header that the ring-owning suite deliberately keeps out; and each guard's
|
||||
# refusal is asserted BY MESSAGE, which with the console sink compiled out (Defines.h) means a
|
||||
# log file this process names before anything logs - PipeWireCodecTest's own-main() shape.
|
||||
add_executable(SessionHandshakeTest SessionHandshakeTest.cpp)
|
||||
|
||||
target_include_directories(SessionHandshakeTest PRIVATE
|
||||
${MGL_ROOT}/include
|
||||
${MGL_ROOT}/MobileGL
|
||||
${MGL_ROOT}/MobileGL/MG_Pipe
|
||||
${MGL_ROOT}/3rdparty/flatbuffers/include
|
||||
${MGL_ROOT}/3rdparty/xxHash
|
||||
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
|
||||
${MGL_ROOT}/3rdparty/SPIRV-Reflect
|
||||
)
|
||||
|
||||
target_link_libraries(SessionHandshakeTest PRIVATE
|
||||
GTest::gtest
|
||||
${LINK_LIBRARIES}
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
target_compile_options(SessionHandshakeTest PRIVATE /Zc:preprocessor)
|
||||
endif ()
|
||||
|
||||
gtest_discover_tests(SessionHandshakeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
|
||||
|
||||
@@ -69,11 +69,15 @@ TEST(ProtocolSmokeTest, HelloRoundTrips) {
|
||||
EXPECT_EQ(envelope->msg_as_Welcome(), nullptr);
|
||||
}
|
||||
|
||||
// The four canonical sizes: 8 MiB / 32 MiB / 16 MiB / 256 KiB. SEG_REPLY is 16 MiB by ID-47
|
||||
// (eight slots of 2 MiB, sized from the largest P5 read - E2's 640x480 RGBA8 snapshot - which
|
||||
// the previous 8 MiB pool could not hold); SessionTest pins the same number on the mapping and
|
||||
// CONTRACT-P5 §2 row 23 on paper. All three move together or not at all.
|
||||
TEST(ProtocolSmokeTest, WelcomeCarriesTheFourSegmentAnnouncements) {
|
||||
::flatbuffers::FlatBufferBuilder builder(1024);
|
||||
auto cmd = CreateSegmentRefDirect(builder, 1, SegmentKind::Cmd, 8ull * 1024 * 1024, "cmd");
|
||||
auto stage = CreateSegmentRefDirect(builder, 2, SegmentKind::Stage, 32ull * 1024 * 1024, "stage");
|
||||
auto reply = CreateSegmentRefDirect(builder, 3, SegmentKind::Reply, 8ull * 1024 * 1024, "reply");
|
||||
auto reply = CreateSegmentRefDirect(builder, 3, SegmentKind::Reply, 16ull * 1024 * 1024, "reply");
|
||||
auto event = CreateSegmentRefDirect(builder, 4, SegmentKind::Event, 256ull * 1024, "event");
|
||||
auto welcome = CreateWelcome(builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR,
|
||||
/*serverPid=*/99, cmd, stage, reply, event);
|
||||
@@ -91,6 +95,11 @@ TEST(ProtocolSmokeTest, WelcomeCarriesTheFourSegmentAnnouncements) {
|
||||
EXPECT_EQ(parsed->cmdRing()->sizeBytes(), 8ull * 1024 * 1024);
|
||||
ASSERT_NE(parsed->stageRing(), nullptr);
|
||||
EXPECT_EQ(parsed->stageRing()->sizeBytes(), 32ull * 1024 * 1024);
|
||||
// The pin never read the reply announcement back before ID-47; a size that moved on the
|
||||
// wire and not here would have gone unnoticed.
|
||||
ASSERT_NE(parsed->replyPool(), nullptr);
|
||||
EXPECT_EQ(parsed->replyPool()->kind(), SegmentKind::Reply);
|
||||
EXPECT_EQ(parsed->replyPool()->sizeBytes(), 16ull * 1024 * 1024);
|
||||
ASSERT_NE(parsed->eventRing(), nullptr);
|
||||
EXPECT_EQ(parsed->eventRing()->sizeBytes(), 256ull * 1024);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// MobileGL - MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp
|
||||
// Copyright (c) 2025-2026 MobileGL-Dev
|
||||
// Licensed under the GNU Lesser General Public License v3.0:
|
||||
// https://www.gnu.org/licenses/gpl-3.0.txt
|
||||
// https://www.gnu.org/licenses/lgpl-3.0.txt
|
||||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// End of Source File Header
|
||||
|
||||
// The two handshakes, driven for real (P5 s1, wave 1.5 - ID-46 findings 6 and 7).
|
||||
//
|
||||
// WHY THIS SUITE EXISTS BESIDE SessionTest. SessionTest is the ring-owning suite and keeps the
|
||||
// GL frontend's umbrella header out on purpose. Both of the wave-1 review's findings against it
|
||||
// were the same defect seen twice: a case that observed a property of the thing it built itself
|
||||
// - a null-union frame it never sent anywhere, a mixer production never called - and so could
|
||||
// not go red when the production code it was named for was deleted. The cure for both is to
|
||||
// start from the production entry point, and the production entry points (ServerSession::Accept,
|
||||
// ClientSession::StartOverTransportPair, CapsAbiFingerprint) all reach Includes.h. So they are
|
||||
// exercised here, in a target that carries the include paths and links gtest rather than
|
||||
// gtest_main: each guard's refusal is asserted BY MESSAGE, and with the console sink compiled
|
||||
// out (Defines.h) an MGLOG line reaches exactly one place, the log file this process names
|
||||
// before anything logs - PipeWireCodecTest's main() shape.
|
||||
//
|
||||
// EVERY CASE BELOW CARRIES ITS RED-ONCE LINE, and each of those perturbations was run.
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "Includes.h"
|
||||
|
||||
#include <MG_Remote/CapsCodec.h>
|
||||
#include <MG_Remote/Client/ClientSession.h>
|
||||
#include <MG_Remote/Protocol/generated/protocol_generated.h>
|
||||
#include <MG_Remote/Server/ServerSession.h>
|
||||
#include <MG_Remote/Transport/InProcessTransport.h>
|
||||
#include <MG_Remote/Transport/SessionRings.h>
|
||||
|
||||
#if __has_include(<MGGitHash.h>)
|
||||
#include <MGGitHash.h>
|
||||
#define MGL_HANDSHAKE_TEST_HAS_GIT_HASH 1
|
||||
#else
|
||||
#define MGL_HANDSHAKE_TEST_HAS_GIT_HASH 0
|
||||
#endif
|
||||
|
||||
#if defined(_WIN32)
|
||||
#include <process.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
using namespace MobileGL;
|
||||
using namespace MobileGL::MG_Remote;
|
||||
namespace Transport = MobileGL::MG_Remote::Transport;
|
||||
|
||||
namespace {
|
||||
|
||||
std::string g_logPath;
|
||||
|
||||
std::string ReadLog() {
|
||||
std::ifstream in(g_logPath, std::ios::binary);
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
long ProcessId() {
|
||||
#if defined(_WIN32)
|
||||
return static_cast<long>(::_getpid());
|
||||
#else
|
||||
return static_cast<long>(::getpid());
|
||||
#endif
|
||||
}
|
||||
|
||||
bool Contains(const std::string& haystack, const char* needle) {
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
// The reviewer's 24-byte shape (SessionTest.ANullUnionFrameVerifiesWhichIsThePremiseOf-
|
||||
// BothHandshakeGuards proves it verifies): the envelope's tag says `tag` and its union
|
||||
// member is NULL, because FlatBuffers' Verifier::VerifyTable is `return !table ||
|
||||
// table->Verify(*this)`.
|
||||
std::vector<Uint8> BuildNullUnionFrame(::MobileGL::Wire::CtrlMsg tag) {
|
||||
::flatbuffers::FlatBufferBuilder builder(256);
|
||||
auto envelope =
|
||||
::MobileGL::Wire::CreateCtrlEnvelope(builder, tag, ::flatbuffers::Offset<void>());
|
||||
::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, envelope);
|
||||
const Uint8* begin = builder.GetBufferPointer();
|
||||
return std::vector<Uint8>(begin, begin + builder.GetSize());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The ABI fingerprint, from the production entry point (ID-46 finding 6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The value Hello and Welcome carry is CapsAbiFingerprint(). This case starts THERE, requires it
|
||||
// to be the mixer over its own published inputs, pins those inputs to the real sizeofs and
|
||||
// constants (ID-33's list: the three struct sizes, kOpCount, the protocol ABI version, the git
|
||||
// stamp - plus the caps blob's extents and the two codec versions), and then perturbs every
|
||||
// input by one through the same mixer and requires the answer to move. RED ONCE by replacing
|
||||
// CapsAbiFingerprint()'s body with `return 1;` - the verifier's exact perturbation, which left
|
||||
// the whole unit lane green before this case existed - and it fails on the first EXPECT_EQ
|
||||
// below. Also red, separately, by deleting any one `mix(...)` line from MixAbiFingerprint: the
|
||||
// matching EXPECT_NE names the input that stopped being mixed. Both perturbations were run.
|
||||
TEST(SessionHandshakeTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) {
|
||||
const Uint64 production = CapsAbiFingerprint();
|
||||
EXPECT_NE(production, 0u) << "0 is reserved for \"not stated\"";
|
||||
EXPECT_EQ(production, CapsAbiFingerprint()) << "not stable within one build";
|
||||
|
||||
const Transport::AbiFingerprintInputs inputs = CapsAbiFingerprintInputs();
|
||||
EXPECT_EQ(production, Transport::MixAbiFingerprint(inputs))
|
||||
<< "CapsAbiFingerprint() is not MixAbiFingerprint over CapsAbiFingerprintInputs(): the "
|
||||
"handshake compares a value this case cannot reach, which is finding 6 again";
|
||||
|
||||
// The inputs are the real ones, so a CapsAbiFingerprintInputs() that hard-coded a size
|
||||
// would be caught here rather than agreed with by a peer built from a different tree.
|
||||
EXPECT_EQ(inputs.DynamicParamsSize, sizeof(MG_Backend::DynamicBackendParameters));
|
||||
EXPECT_EQ(inputs.CapsSize, sizeof(MG_Pipe::MGPCaps));
|
||||
EXPECT_EQ(inputs.FunctionTableSize, sizeof(MG_Backend::GLFunctionsTable));
|
||||
EXPECT_EQ(inputs.FormatCapabilityTargets,
|
||||
static_cast<Uint64>(MG_Backend::kFormatCapabilityTargetCount));
|
||||
EXPECT_EQ(inputs.FormatCapabilityFormats,
|
||||
static_cast<Uint64>(MG_Backend::kFormatCapabilityFormatCount));
|
||||
EXPECT_NE(inputs.FormatCapabilitiesCodecVersion, 0u);
|
||||
EXPECT_NE(inputs.RendererInfoCodecVersion, 0u);
|
||||
EXPECT_EQ(inputs.OpCount, static_cast<Uint64>(MG_Pipe::MGPWireOp::kOpCount));
|
||||
EXPECT_EQ(inputs.AbiVersion, static_cast<Uint32>(MOBILEGL_ABI_VERSION(
|
||||
MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR)));
|
||||
ASSERT_NE(inputs.BuildStamp, nullptr);
|
||||
EXPECT_NE(inputs.BuildStamp[0], '\0');
|
||||
#if MGL_HANDSHAKE_TEST_HAS_GIT_HASH
|
||||
EXPECT_STREQ(inputs.BuildStamp, GIT_COMMIT_HASH_SHORT);
|
||||
#endif
|
||||
|
||||
// Every input moves the answer. Each lambda changes exactly one field of a copy of the
|
||||
// REAL inputs, so what is proven is that the production value depends on that field.
|
||||
const auto perturbed = [&](auto&& mutate) {
|
||||
Transport::AbiFingerprintInputs copy = inputs;
|
||||
mutate(copy);
|
||||
return Transport::MixAbiFingerprint(copy);
|
||||
};
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.DynamicParamsSize; }))
|
||||
<< "sizeof(DynamicBackendParameters) is not mixed";
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.CapsSize; }))
|
||||
<< "sizeof(MGPCaps) is not mixed";
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.FunctionTableSize; }))
|
||||
<< "sizeof(GLFunctionsTable) is not mixed";
|
||||
EXPECT_NE(production,
|
||||
perturbed([](Transport::AbiFingerprintInputs& i) { ++i.FormatCapabilityTargets; }))
|
||||
<< "kFormatCapabilityTargetCount is not mixed";
|
||||
EXPECT_NE(production,
|
||||
perturbed([](Transport::AbiFingerprintInputs& i) { ++i.FormatCapabilityFormats; }))
|
||||
<< "kFormatCapabilityFormatCount is not mixed";
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) {
|
||||
++i.FormatCapabilitiesCodecVersion;
|
||||
}))
|
||||
<< "kFormatCapabilitiesCodecVersion is not mixed";
|
||||
EXPECT_NE(production,
|
||||
perturbed([](Transport::AbiFingerprintInputs& i) { ++i.RendererInfoCodecVersion; }))
|
||||
<< "kRendererInfoCodecVersion is not mixed";
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.OpCount; }))
|
||||
<< "MGPWireOp::kOpCount is not mixed (ID-33)";
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { ++i.AbiVersion; }))
|
||||
<< "the protocol ABI version is not mixed (ID-33)";
|
||||
EXPECT_NE(production,
|
||||
perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = "not-this-build"; }))
|
||||
<< "the git stamp is not mixed";
|
||||
// A missing stamp is not the same as an empty one, and neither is the same as a real build.
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = nullptr; }));
|
||||
EXPECT_NE(production, perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = ""; }));
|
||||
EXPECT_NE(perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = nullptr; }),
|
||||
perturbed([](Transport::AbiFingerprintInputs& i) { i.BuildStamp = ""; }))
|
||||
<< "\"no stamp\" and \"an empty stamp\" collapsed into one input";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The two null-union guards, driven THROUGH the handshakes (ID-46 finding 7)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A frame whose tag says Hello and whose Hello is NULL, sent on a real InProcessTransport pair to
|
||||
// a real ServerSession::Accept - a session of this case's own, not the process singleton. Accept
|
||||
// must answer MOBILEGL_ERR_PROTOCOL_MISMATCH with its guard's own line, and must not have
|
||||
// dereferenced the member: nothing Fatal in the log, nothing accepted. RED ONCE by deleting
|
||||
// `envelope->msg_as_Hello() == nullptr` from the guard in ServerSession.cpp: the tag check
|
||||
// passes, `hello` is nullptr, and `hello->buildFingerprint()` reads address 0 - the case dies
|
||||
// instead of returning. That perturbation was run.
|
||||
TEST(SessionHandshakeTest, ANullUnionHelloIsRefusedByAcceptRatherThanDereferenced) {
|
||||
std::unique_ptr<Transport::InProcessTransport> client;
|
||||
std::unique_ptr<Transport::InProcessTransport> server;
|
||||
Transport::InProcessTransport::CreatePair(client, server);
|
||||
ASSERT_NE(client, nullptr);
|
||||
ASSERT_NE(server, nullptr);
|
||||
|
||||
const std::vector<Uint8> frame = BuildNullUnionFrame(::MobileGL::Wire::CtrlMsg::Hello);
|
||||
ASSERT_EQ(client->SendFrame(MobileGLByteSpan{frame.data(), frame.size()}), MOBILEGL_OK);
|
||||
|
||||
Server::ServerSession session;
|
||||
const std::string before = ReadLog();
|
||||
EXPECT_EQ(session.Accept(*server), MOBILEGL_ERR_PROTOCOL_MISMATCH)
|
||||
<< "a null-union Hello was not refused by the handshake";
|
||||
EXPECT_FALSE(session.Accepted());
|
||||
const std::string delta = ReadLog().substr(before.size());
|
||||
EXPECT_TRUE(Contains(delta, "MG_Remote server: the first control frame is not a verifiable Hello"))
|
||||
<< "Accept refused, but not with the guard's own line. Log delta:\n"
|
||||
<< delta;
|
||||
EXPECT_FALSE(Contains(delta, "Fatal{")) << "the refusal became a Fatal. Log delta:\n" << delta;
|
||||
session.Close();
|
||||
}
|
||||
|
||||
// The Welcome guard. ClientSession::Start builds its transport pair itself, so nothing could put
|
||||
// a frame on the server->client direction ahead of the server's Welcome - which is why
|
||||
// StartOverTransportPair, Start's second half, is public (ClientSession.h). The frame is queued
|
||||
// there BEFORE Start sends Hello: the server's Accept then runs for real (segments, Welcome,
|
||||
// resolver), its genuine Welcome queues behind the null-union one, the client reads the
|
||||
// null-union one first and must refuse it with the guard's own line, and Stop()'s not-started
|
||||
// path must have closed the server the handshake had accepted. RED ONCE by deleting
|
||||
// `envelope->msg_as_Welcome() == nullptr` from the guard in ClientSession.cpp: `welcome` is then
|
||||
// nullptr and `welcome->buildFingerprint()` reads address 0 - the case dies. That perturbation
|
||||
// was run.
|
||||
TEST(SessionHandshakeTest, ANullUnionWelcomeIsRefusedByStartRatherThanDereferenced) {
|
||||
std::unique_ptr<Transport::InProcessTransport> client;
|
||||
std::unique_ptr<Transport::InProcessTransport> server;
|
||||
Transport::InProcessTransport::CreatePair(client, server);
|
||||
ASSERT_NE(client, nullptr);
|
||||
ASSERT_NE(server, nullptr);
|
||||
|
||||
const std::vector<Uint8> frame = BuildNullUnionFrame(::MobileGL::Wire::CtrlMsg::Welcome);
|
||||
ASSERT_EQ(server->SendFrame(MobileGLByteSpan{frame.data(), frame.size()}), MOBILEGL_OK);
|
||||
|
||||
Client::ClientSession& session = Client::ClientSessionInstance();
|
||||
Server::ServerSession& serverSession = Server::ServerSessionInstance();
|
||||
ASSERT_FALSE(session.Started());
|
||||
ASSERT_FALSE(serverSession.Accepted());
|
||||
|
||||
const std::string before = ReadLog();
|
||||
EXPECT_EQ(session.StartOverTransportPair(std::move(client), std::move(server)),
|
||||
MOBILEGL_ERR_PROTOCOL_MISMATCH)
|
||||
<< "a null-union Welcome was not refused by the handshake";
|
||||
EXPECT_FALSE(session.Started());
|
||||
EXPECT_EQ(Client::ClientSession::Active(), nullptr);
|
||||
// Stop()'s not-started path closes the server FIRST (ClientSession.cpp); a server left
|
||||
// m_accepted would refuse every later Start in this process.
|
||||
EXPECT_FALSE(serverSession.Accepted());
|
||||
EXPECT_EQ(Server::ServerSession::Active(), nullptr);
|
||||
|
||||
const std::string delta = ReadLog().substr(before.size());
|
||||
EXPECT_TRUE(Contains(delta,
|
||||
"MG_Remote client: the server's first control frame is not a verifiable "
|
||||
"Welcome"))
|
||||
<< "Start refused, but not with the guard's own line. Log delta:\n"
|
||||
<< delta;
|
||||
EXPECT_FALSE(Contains(delta, "Fatal{AbiMismatch"))
|
||||
<< "the null-union Welcome reached the fingerprint compare. Log delta:\n"
|
||||
<< delta;
|
||||
// And the server's half of the handshake DID run - the Hello it received was this
|
||||
// client's real one - so the case drove Start past the point a stub would stop at.
|
||||
EXPECT_TRUE(Contains(delta, "MG_Remote server: accepted with NO backend"))
|
||||
<< "ServerSession::Accept never ran, so the Welcome guard was not reached the way "
|
||||
"Start reaches it. Log delta:\n"
|
||||
<< delta;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// Before anything logs: MG_Util::Debug::InitFile() reads the variable once, on the first
|
||||
// write, and caches the FILE*. The name carries this process's pid, because
|
||||
// gtest_discover_tests runs every case as its own process, in parallel under ctest -j.
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path path = fs::temp_directory_path() /
|
||||
("mobilegl-sessionhandshake-test-" + std::to_string(ProcessId()) + ".log");
|
||||
std::error_code ec;
|
||||
fs::remove(path, ec);
|
||||
g_logPath = path.string();
|
||||
#if defined(_WIN32)
|
||||
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
|
||||
#else
|
||||
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
|
||||
#endif
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
const int rc = RUN_ALL_TESTS();
|
||||
fs::remove(path, ec);
|
||||
return rc;
|
||||
}
|
||||
@@ -198,7 +198,8 @@ TEST(SessionTest, TheDefaultGeometryIsTheFourContractRingSizes) {
|
||||
ASSERT_EQ(segments.Create(SessionSegmentSizes{}, MemoryRole::Server), MOBILEGL_OK);
|
||||
EXPECT_EQ(segments.CmdRingCapacity(), 8ull * 1024 * 1024);
|
||||
EXPECT_EQ(segments.StageBytes(), 32ull * 1024 * 1024);
|
||||
EXPECT_EQ(segments.ReplyBytes(), 8ull * 1024 * 1024);
|
||||
// ID-47: 16 MiB, eight slots of 2 MiB. ProtocolSmokeTest pins the same number on the wire.
|
||||
EXPECT_EQ(segments.ReplyBytes(), 16ull * 1024 * 1024);
|
||||
EXPECT_EQ(segments.EventRingCapacity(), 256ull * 1024);
|
||||
|
||||
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Cmd),
|
||||
@@ -206,12 +207,60 @@ TEST(SessionTest, TheDefaultGeometryIsTheFourContractRingSizes) {
|
||||
// SEG_STAGE is not a ring at all - no control page, no cursor triple, no power-of-two
|
||||
// rounding - and neither is SEG_REPLY, so both announce exactly what was asked for.
|
||||
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Stage), 32ull * 1024 * 1024);
|
||||
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Reply), 8ull * 1024 * 1024);
|
||||
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Reply), 16ull * 1024 * 1024);
|
||||
EXPECT_EQ(segments.AnnouncedSize(SessionSegmentSlot::Event),
|
||||
256ull * 1024 + sizeof(RingControl));
|
||||
segments.Close();
|
||||
}
|
||||
|
||||
// ID-47 (ID-46 finding 2). The DEFAULT reply pool must hold the largest P5 read: the E2 retrace
|
||||
// harness snapshots OpenRA's whole 640x480 surface as GL_RGBA/GL_UNSIGNED_BYTE through the
|
||||
// interposer, 1,228,800 bytes, and the previous geometry (8 MiB / 8 slots, 1 MiB minus a 16-byte
|
||||
// header) refused it by 180,240 bytes - and refused the 512x512 RGBA8 read s1-v1.md:134 claimed
|
||||
// it covered, by sixteen. This is the verifier's case, committed: it posts exactly 640*480*4
|
||||
// bytes into a pool built from SessionSegmentSizes{} and reads them back. RED ONCE by reverting
|
||||
// SessionSegmentSizes::ReplyBytes to 8 MiB: Post then takes its oversize-abort branch and the
|
||||
// case dies, which is the verifier's original outcome. That perturbation was run.
|
||||
TEST(SessionTest, TheDefaultReplyGeometryHoldsTheLargestP5Read) {
|
||||
SessionSegments segments;
|
||||
ASSERT_EQ(segments.Create(SessionSegmentSizes{}, MemoryRole::Server), MOBILEGL_OK);
|
||||
ReplySlotPool pool(segments.ReplyBase(), segments.ReplyBytes(), segments.ReplySlotCount());
|
||||
ASSERT_TRUE(pool.Valid());
|
||||
EXPECT_EQ(pool.SlotCount(), 8u);
|
||||
EXPECT_EQ(pool.SlotBytes(), 2u * 1024 * 1024);
|
||||
EXPECT_EQ(pool.MaxReplyBytes(), 2u * 1024 * 1024 - 16u);
|
||||
|
||||
const std::uint64_t kOpenRaSnapshot = 640ull * 480 * 4; // E2's read, the largest in P5
|
||||
const std::uint64_t kHalfKSquare = 512ull * 512 * 4; // the read s1-v1.md:134 got wrong
|
||||
EXPECT_TRUE(pool.CanHold(kOpenRaSnapshot));
|
||||
EXPECT_TRUE(pool.CanHold(kHalfKSquare));
|
||||
|
||||
std::vector<std::uint8_t> answer(static_cast<std::size_t>(kOpenRaSnapshot));
|
||||
for (std::size_t i = 0; i < answer.size(); ++i) {
|
||||
answer[i] = static_cast<std::uint8_t>(i * 7 + (i >> 12));
|
||||
}
|
||||
pool.Post(1, kReplyStatusOk, answer.data(), answer.size());
|
||||
|
||||
std::vector<std::uint8_t> back(answer.size(), 0);
|
||||
std::int32_t status = -1;
|
||||
std::uint64_t size = 0;
|
||||
ASSERT_TRUE(pool.Read(1, back.data(), back.size(), &status, &size))
|
||||
<< "the E2 snapshot does not fit the default reply pool";
|
||||
EXPECT_EQ(status, kReplyStatusOk);
|
||||
EXPECT_EQ(size, kOpenRaSnapshot);
|
||||
EXPECT_EQ(std::memcmp(back.data(), answer.data(), answer.size()), 0);
|
||||
|
||||
// And the 512x512 read, in the next slot, so a geometry that only just clears 640x480 by
|
||||
// some accident of rounding cannot pass this case either.
|
||||
answer.resize(static_cast<std::size_t>(kHalfKSquare));
|
||||
pool.Post(2, kReplyStatusOk, answer.data(), answer.size());
|
||||
back.assign(answer.size(), 0);
|
||||
ASSERT_TRUE(pool.Read(2, back.data(), back.size(), &status, &size));
|
||||
EXPECT_EQ(size, kHalfKSquare);
|
||||
EXPECT_EQ(std::memcmp(back.data(), answer.data(), answer.size()), 0);
|
||||
segments.Close();
|
||||
}
|
||||
|
||||
// The ledger is per role and DOES double-count under inproc, deliberately: the two roles map
|
||||
// the same pages here and will not under spawn, so the per-role numbers are what t1 subtracts
|
||||
// with and a silently deduplicated total would hide exactly that difference.
|
||||
@@ -229,9 +278,12 @@ TEST(SessionTest, TheMemoryLedgerIsPerRoleAndIsReleasedOnClose) {
|
||||
const RoleMemorySample sample = SampleRoleMemory(MemoryRole::Client);
|
||||
EXPECT_EQ(sample.MappedSegmentBytes, clientBefore + mapped);
|
||||
#if defined(__linux__) || defined(__ANDROID__)
|
||||
// VmHWM is the PROCESS's high-water mark, so it is the same number for both roles and
|
||||
// is only meaningful beside the ledger - which is why RoleMemorySample carries both.
|
||||
// The peak is the PROCESS's, so it is the same number for both roles and is only
|
||||
// meaningful beside the ledger - which is why RoleMemorySample carries both.
|
||||
EXPECT_GT(sample.PeakRssBytes, 0u);
|
||||
// Holds BY CONSTRUCTION now (the ledger's own running max, RoleMemory.h), not by
|
||||
// the kernel's grace: GitHub run 35079459114 failed exactly this line with VmHWM
|
||||
// 4,784,128 < VmRSS 4,849,664. The control on the construction is the next case.
|
||||
EXPECT_GE(sample.PeakRssBytes, sample.CurrentRssBytes);
|
||||
#endif
|
||||
}
|
||||
@@ -239,6 +291,50 @@ TEST(SessionTest, TheMemoryLedgerIsPerRoleAndIsReleasedOnClose) {
|
||||
EXPECT_EQ(LedgerMappedBytes(MemoryRole::Server), serverBefore);
|
||||
}
|
||||
|
||||
// The kernel's VmHWM is NOT a monotone bound on the kernel's VmRSS at read time: hiwater_rss
|
||||
// is stored only when RSS is about to drop, task_mem() reports max(stored, rss-now), and the
|
||||
// wave-1 sampler read the two keys in two passes, so the second fopen could grow RSS past the
|
||||
// peak the first pass reported. GitHub run 35079459114 (ubuntu-24.04) caught it; the probe under
|
||||
// ~/w7/p5-s1-probe reproduced it locally. The rule is therefore that the ledger keeps ITS OWN
|
||||
// running peak and folds the kernel's two numbers into it, so a stubbed reader whose current
|
||||
// exceeds its peak must not fail. RED ONCE by reverting SampleRoleMemoryInto to
|
||||
// `sample.PeakRssBytes = kernelPeakRssBytes;` - the first EXPECT_EQ below then reads 100 against
|
||||
// 200 and the EXPECT_GE beside it is the CI line again. That perturbation was run.
|
||||
TEST(SessionTest, TheLedgersPeakIsItsOwnRunningMaxAndNeverTheKernelsHighWaterMarkVerbatim) {
|
||||
std::atomic<std::uint64_t> runningPeak{0};
|
||||
|
||||
// The CI shape: the kernel says peak 100, current 200.
|
||||
RoleMemorySample sample = SampleRoleMemoryInto(runningPeak, MemoryRole::Client, 100, 200);
|
||||
EXPECT_EQ(sample.PeakRssBytes, 200u)
|
||||
<< "the kernel's peak was reported verbatim although its current exceeded it";
|
||||
EXPECT_GE(sample.PeakRssBytes, sample.CurrentRssBytes);
|
||||
EXPECT_EQ(sample.CurrentRssBytes, 200u);
|
||||
EXPECT_EQ(runningPeak.load(), 200u);
|
||||
|
||||
// A later, smaller sample does not lower it: a running max never decreases.
|
||||
sample = SampleRoleMemoryInto(runningPeak, MemoryRole::Server, 150, 120);
|
||||
EXPECT_EQ(sample.PeakRssBytes, 200u);
|
||||
EXPECT_EQ(sample.CurrentRssBytes, 120u);
|
||||
EXPECT_EQ(sample.Role, MemoryRole::Server);
|
||||
|
||||
// The kernel's peak still counts when it IS the larger number: it is a lower bound on
|
||||
// the true peak that this process's own samples may have missed.
|
||||
sample = SampleRoleMemoryInto(runningPeak, MemoryRole::Client, 300, 100);
|
||||
EXPECT_EQ(sample.PeakRssBytes, 300u);
|
||||
EXPECT_EQ(runningPeak.load(), 300u);
|
||||
|
||||
// And the production sampler reads BOTH keys in ONE pass, so the pair it folds is one
|
||||
// snapshot: on Linux neither number is 0 and the pair is self-consistent.
|
||||
#if defined(__linux__) || defined(__ANDROID__)
|
||||
std::uint64_t kernelPeak = 0;
|
||||
std::uint64_t kernelCurrent = 0;
|
||||
ProcessRssBytes(&kernelPeak, &kernelCurrent);
|
||||
EXPECT_GT(kernelPeak, 0u);
|
||||
EXPECT_GT(kernelCurrent, 0u);
|
||||
EXPECT_GE(kernelPeak, kernelCurrent) << "one pass over /proc/self/status disagreed with itself";
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two real threads, twenty thousand records
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -571,14 +667,72 @@ TEST(SessionTest, DeclinedIsARealAnswerWithNoPayload) {
|
||||
#if defined(GTEST_HAS_DEATH_TEST) && GTEST_HAS_DEATH_TEST
|
||||
// A reply larger than a slot is FATAL, not chunked and not truncated: P5's only large answer is
|
||||
// a blocking ReadPixels whose size the client knows before it emits, so an overflow means the
|
||||
// two sides disagree about the frame. A gate that cannot go red is not a gate.
|
||||
// two sides disagree about the frame. A gate that cannot go red is not a gate - and a death
|
||||
// control with an empty regex is not a gate either (ID-46 finding 10: a bare std::abort(), or a
|
||||
// segfault in a broken refusal path, satisfied the previous `""`). The regex below is the
|
||||
// diagnostic's own wording, which WireLogFatal echoes to stderr for exactly this reader. RED ONCE
|
||||
// by replacing the oversize branch's WireLogFatal with a bare std::abort(): the process still
|
||||
// dies, the regex finds nothing, and the case fails on "died but not with the expected error".
|
||||
// That perturbation was run. The boundary is a pair: exactly MaxReplyBytes() posts and reads
|
||||
// back, one more byte is the named refusal.
|
||||
TEST(SessionTestDeath, AReplyLargerThanItsSlotIsFatalRatherThanTruncated) {
|
||||
SessionSegments segments;
|
||||
ASSERT_EQ(segments.Create(TestSizes(), MemoryRole::Server), MOBILEGL_OK);
|
||||
ReplySlotPool pool(segments.ReplyBase(), segments.ReplyBytes(), segments.ReplySlotCount());
|
||||
ASSERT_TRUE(pool.Valid());
|
||||
std::vector<std::uint8_t> oversize(pool.SlotBytes() + 1, 0xAB);
|
||||
EXPECT_DEATH(pool.Post(1, kReplyStatusOk, oversize.data(), oversize.size()), "");
|
||||
ASSERT_EQ(pool.SlotBytes(), 8192u);
|
||||
ASSERT_EQ(pool.MaxReplyBytes(), 8176u);
|
||||
|
||||
std::vector<std::uint8_t> exact(pool.MaxReplyBytes(), 0xAB);
|
||||
EXPECT_TRUE(pool.CanHold(exact.size()));
|
||||
pool.Post(1, kReplyStatusOk, exact.data(), exact.size());
|
||||
std::vector<std::uint8_t> back(exact.size(), 0);
|
||||
std::uint64_t size = 0;
|
||||
ASSERT_TRUE(pool.Read(1, back.data(), back.size(), nullptr, &size));
|
||||
EXPECT_EQ(size, exact.size());
|
||||
|
||||
std::vector<std::uint8_t> oversize(pool.MaxReplyBytes() + 1, 0xAB);
|
||||
EXPECT_FALSE(pool.CanHold(oversize.size()));
|
||||
EXPECT_DEATH(pool.Post(2, kReplyStatusOk, oversize.data(), oversize.size()),
|
||||
"reply pool: Fatal\\{ProtocolCorruption\\} - a 8177 byte answer for seq 2 does not "
|
||||
"fit a 8192 byte slot \\(payload cap 8176\\)");
|
||||
}
|
||||
|
||||
// ID-47's client half, on the DEFAULT geometry so the numbers are the ruling's: exactly
|
||||
// MaxReplyBytes() = 2,097,136 passes, one more byte is refused BY NAME before emission, and the
|
||||
// 1024x512 RGBA8 read - exactly 2 MiB, sixteen bytes over the cap, the same sixteen-byte shape
|
||||
// that sank the previous geometry's 512x512 claim - is refused with its own dimensions in the
|
||||
// line. The message is the contract's verbatim: `ReadPixels <w>x<h> <format> <bytes> > <cap>`.
|
||||
// RED ONCE by replacing RequireReadPixelsFits's WireLogFatal with a bare std::abort() (the two
|
||||
// death regexes then match nothing) and, separately, by making CanHold `<` instead of `<=` (the
|
||||
// exact-cap call then dies). Both perturbations were run.
|
||||
TEST(SessionTestDeath, AReadPixelsLargerThanAReplySlotIsRefusedAtTheClientByName) {
|
||||
SessionSegments segments;
|
||||
ASSERT_EQ(segments.Create(SessionSegmentSizes{}, MemoryRole::Server), MOBILEGL_OK);
|
||||
ReplySlotPool pool(segments.ReplyBase(), segments.ReplyBytes(), segments.ReplySlotCount());
|
||||
ASSERT_TRUE(pool.Valid());
|
||||
const std::uint64_t cap = pool.MaxReplyBytes();
|
||||
ASSERT_EQ(cap, 2097136u);
|
||||
constexpr std::uint32_t kGlRgba = 0x1908;
|
||||
constexpr std::uint32_t kGlUnsignedByte = 0x1401;
|
||||
|
||||
// Exactly the cap: 524,284 RGBA8 pixels in one row. Returns, no death.
|
||||
ASSERT_EQ(524284ull * 1 * 4, cap);
|
||||
EXPECT_TRUE(pool.CanHold(cap));
|
||||
pool.RequireReadPixelsFits(524284, 1, kGlRgba, kGlUnsignedByte, cap);
|
||||
|
||||
// One more byte.
|
||||
EXPECT_FALSE(pool.CanHold(cap + 1));
|
||||
EXPECT_DEATH(pool.RequireReadPixelsFits(524284, 1, kGlRgba, kGlUnsignedByte, cap + 1),
|
||||
"Fatal\\{ReplyTooLarge, \"ReadPixels 524284x1 0x1908/0x1401 2097137 > 2097136\"\\}");
|
||||
|
||||
// The read a caller would actually make: 1024x512 RGBA8 = 2 MiB, 16 over.
|
||||
EXPECT_DEATH(pool.RequireReadPixelsFits(1024, 512, kGlRgba, kGlUnsignedByte, 1024ull * 512 * 4),
|
||||
"Fatal\\{ReplyTooLarge, \"ReadPixels 1024x512 0x1908/0x1401 2097152 > 2097136\"\\}");
|
||||
|
||||
// And E2's read, the reason for the geometry, is not refused.
|
||||
pool.RequireReadPixelsFits(640, 480, kGlRgba, kGlUnsignedByte, 640ull * 480 * 4);
|
||||
segments.Close();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -766,27 +920,16 @@ TEST(SessionTest, ShutdownUnparksTheApplyThreadAndTheJoinIsBounded) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The ABI fingerprint's mixer
|
||||
// The ABI fingerprint's mixer: MOVED to SessionHandshakeTest (ID-46 finding 6).
|
||||
//
|
||||
// The case that lived here drove MixAbiFingerprint with made-up sizes and never touched
|
||||
// CapsAbiFingerprint(), the value the two handshakes actually compare - which had its own
|
||||
// second FNV loop and no caller of the mixer at all, so `return 1;` in production left the
|
||||
// whole unit lane green. The sensitivity case now starts from CapsAbiFingerprint(), which
|
||||
// needs CapsCodec.h and therefore the GL frontend's umbrella header that this suite keeps
|
||||
// out; it lives in SessionHandshakeTest.cpp beside the two handshake-guard controls.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A fingerprint that cannot be SHOWN to change is indistinguishable from one that is never
|
||||
// compared, which is why the mixer takes its sizes as arguments instead of reading sizeof
|
||||
// directly: a test can vary one byte and prove the answer moves.
|
||||
TEST(SessionTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) {
|
||||
const std::uint64_t base = MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1234");
|
||||
EXPECT_NE(base, 0u) << "0 is reserved for \"not stated\"";
|
||||
EXPECT_EQ(base, MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1234"));
|
||||
|
||||
EXPECT_NE(base, MixAbiFingerprint(1025, 1080, 552, 0x00010000, "abc1234"));
|
||||
EXPECT_NE(base, MixAbiFingerprint(1024, 1081, 552, 0x00010000, "abc1234"));
|
||||
EXPECT_NE(base, MixAbiFingerprint(1024, 1080, 553, 0x00010000, "abc1234"));
|
||||
EXPECT_NE(base, MixAbiFingerprint(1024, 1080, 552, 0x00010001, "abc1234"));
|
||||
EXPECT_NE(base, MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1235"));
|
||||
// A missing stamp is not the same as an empty one, and neither is the same as a real build.
|
||||
EXPECT_NE(MixAbiFingerprint(1024, 1080, 552, 0x00010000, nullptr),
|
||||
MixAbiFingerprint(1024, 1080, 552, 0x00010000, "abc1234"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix round 1 - the cases the adversarial review's findings earned
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -796,13 +939,19 @@ TEST(SessionTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) {
|
||||
// appliedSeq is behind, every later advance is a no-op for ever and every WaitForApplied on
|
||||
// kWaitForever - the verb barrier and every reply wait - blocks permanently. A hang whose only
|
||||
// evidence is one ERROR line is not a diagnosis.
|
||||
//
|
||||
// The regex names the diagnostic (ID-46 finding 10; see the reply-pool death control for why an
|
||||
// empty one was not a gate). RED ONCE by replacing AdvanceMonotonic's WireLogFatal block with a
|
||||
// bare std::abort(): the case then fails on "died but not with the expected error". That
|
||||
// perturbation was run.
|
||||
#if defined(GTEST_HAS_DEATH_TEST) && GTEST_HAS_DEATH_TEST
|
||||
TEST(SessionTestDeath, AWatermarkThatMovesBackwardsIsFatalRatherThanIgnored) {
|
||||
alignas(4096) RingControl control{};
|
||||
InitRingControl(control);
|
||||
Watermark::AdvanceApplied(control, 10);
|
||||
ASSERT_EQ(control.appliedSeq.load(), 10u);
|
||||
EXPECT_DEATH(Watermark::AdvanceApplied(control, 9), "");
|
||||
EXPECT_DEATH(Watermark::AdvanceApplied(control, 9),
|
||||
"Fatal\\{ProtocolCorruption, \"watermark\"\\} appliedSeq moved backwards, 10 -> 9");
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -925,10 +1074,17 @@ TEST(SessionTest, TheProducerRemembersWhatItPublishedEvenIfTheWatermarkLags) {
|
||||
|
||||
// M-3. FlatBuffers' Verifier::VerifyTable is `return !table || table->Verify(*this)`, so a NULL
|
||||
// union member PASSES verification: a 24-byte frame verifies, carries the file identifier,
|
||||
// reports msg_type() == Hello, and returns nullptr from msg_as_Hello(). Both handshakes now
|
||||
// fold that into their guard instead of dereferencing it. This case is the proof the shape is
|
||||
// reachable at all, so the guard cannot be "simplified" away later.
|
||||
TEST(SessionTest, AVerifiableFrameCanCarryANullUnionPayload) {
|
||||
// reports msg_type() == Hello, and returns nullptr from msg_as_Hello(). Both handshakes fold
|
||||
// that into their guard instead of dereferencing it.
|
||||
//
|
||||
// THIS CASE IS THE PREMISE, NOT THE CONTROL. It proves the shape is reachable - a FlatBuffers
|
||||
// property - and nothing about MobileGL: the wave-1 review (ID-46 finding 7) deleted both
|
||||
// `msg_as_*() == nullptr` clauses and this case stayed green, because it never calls Accept or
|
||||
// Start. The controls on the two guards are SessionHandshakeTest's, which drive this exact
|
||||
// frame THROUGH ServerSession::Accept and ClientSession::StartOverTransportPair and require
|
||||
// each guard's own refusal line. s1-v1.md:338 claimed this case meant "the guard cannot be
|
||||
// simplified away"; it did not, and s1-v3.md says so.
|
||||
TEST(SessionTest, ANullUnionFrameVerifiesWhichIsThePremiseOfBothHandshakeGuards) {
|
||||
::flatbuffers::FlatBufferBuilder builder(256);
|
||||
auto envelope = ::MobileGL::Wire::CreateCtrlEnvelope(builder, ::MobileGL::Wire::CtrlMsg::Hello,
|
||||
::flatbuffers::Offset<void>());
|
||||
@@ -1028,26 +1184,69 @@ TEST(SessionTest, TheStageCursorTripleStaysDeadAcrossAWholeSession) {
|
||||
// have every var-tail record SKIPPED by Pop, silently, with the record lost and nothing logged on
|
||||
// either side. Pop now requires a filler to carry both the flag AND kind == kRingPadRecordKind, so
|
||||
// a real record wearing that bit is delivered instead of eaten.
|
||||
//
|
||||
// THE BIT HAS TO BE ON THE WIRE. The first version of this case asked Reserve(7, kRecPad, 16),
|
||||
// and RingProducer::Reserve MASKS kRecPad OUT of whatever the caller passes (Ring.cpp: `flags &
|
||||
// ~kRecPad`), so the header it stored had flags == 0, Pop's pad arm was never entered, and the
|
||||
// case stayed green with the kind check deleted (ID-46 finding 5, executed by the verifier). So
|
||||
// this producer writes the header ITSELF after Reserve, the way a codec with its own header
|
||||
// struct does - MGPWireRecHeader and RingRecordHeader are the same eight bytes - which is the one
|
||||
// path Reserve's mask cannot cover. RingTest.TheTwoFlagSpacesAreDisjointByTranslation (ID-43) is
|
||||
// the same control at the ring; this one is at the SESSION, where what is pinned in addition is
|
||||
// that appliedSeq counts the delivered record and does NOT count the genuine filler beside it.
|
||||
// RED ONCE by deleting `&& header.kind == kRingPadRecordKind` from RingConsumer::Pop: the record
|
||||
// vanishes into the wrap-filler skip, `seen` stays 0 and appliedSeq stays 0, and the case fails
|
||||
// on the first EXPECT's message. That perturbation was run.
|
||||
TEST(SessionTest, ARecordWearingThePadBitIsDeliveredRatherThanEatenAsAFiller) {
|
||||
SessionFixture session;
|
||||
ASSERT_TRUE(session.Build(TestSizes()));
|
||||
|
||||
// kRecPad is MGPipeCallFlags::kVarTail's bit. Kind 7 is a real opcode, not a filler.
|
||||
void* payload = session.cmdProducer.Reserve(7, kRecPad, 16);
|
||||
void* payload = session.cmdProducer.Reserve(7, kRecNone, 16);
|
||||
ASSERT_NE(payload, nullptr);
|
||||
std::memset(payload, 0xAB, 16);
|
||||
// Stamp the bit past Reserve's mask, exactly where the codec's header struct would put it.
|
||||
auto* headerBytes = static_cast<std::uint8_t*>(payload) - sizeof(RingRecordHeader);
|
||||
RingRecordHeader stamped{};
|
||||
std::memcpy(&stamped, headerBytes, sizeof(stamped));
|
||||
ASSERT_EQ(stamped.kind, 7u);
|
||||
ASSERT_EQ(stamped.flags & kRecPad, 0u) << "Reserve stopped masking kRecPad";
|
||||
stamped.flags = static_cast<std::uint16_t>(stamped.flags | kRecPad);
|
||||
std::memcpy(headerBytes, &stamped, sizeof(stamped));
|
||||
session.producer.PublishAndNotify(1);
|
||||
|
||||
int seen = 0;
|
||||
std::uint16_t seenKind = 0;
|
||||
std::uint16_t seenFlags = 0;
|
||||
while (session.consumer.ApplyOne([&](const RingRecordView& view) {
|
||||
seenKind = view.kind;
|
||||
seenFlags = view.flags;
|
||||
++seen;
|
||||
})) {
|
||||
}
|
||||
EXPECT_EQ(seen, 1) << "the record was skipped as a wrap filler because it wore bit 2";
|
||||
EXPECT_EQ(seen, 1) << "the record was skipped as a wrap filler because it wore bit 2 - Pop's "
|
||||
"kind check (a filler is kRecPad AND kind == kRingPadRecordKind) is the "
|
||||
"only thing between a stamped header and a silently deleted record";
|
||||
EXPECT_EQ(seenKind, 7);
|
||||
EXPECT_NE(seenFlags & kRecPad, 0u)
|
||||
<< "the bit arrives intact: the kind check narrows the SKIP, it does not scrub the bit";
|
||||
EXPECT_EQ(session.Control().appliedSeq.load(), 1u);
|
||||
|
||||
// The opposite control, so that "delivered" cannot be satisfied by not skipping anything:
|
||||
// a header wearing kRecPad whose kind IS kRingPadRecordKind is a genuine wrap filler, still
|
||||
// vanishes, and is NOT counted by appliedSeq (R-9: a filler does not advance seq).
|
||||
void* filler = session.cmdProducer.Reserve(kRingPadRecordKind, kRecNone, 16);
|
||||
ASSERT_NE(filler, nullptr);
|
||||
headerBytes = static_cast<std::uint8_t*>(filler) - sizeof(RingRecordHeader);
|
||||
std::memcpy(&stamped, headerBytes, sizeof(stamped));
|
||||
stamped.flags = static_cast<std::uint16_t>(stamped.flags | kRecPad);
|
||||
std::memcpy(headerBytes, &stamped, sizeof(stamped));
|
||||
session.producer.PublishAndNotify(2);
|
||||
while (session.consumer.ApplyOne([&](const RingRecordView&) { ++seen; })) {
|
||||
}
|
||||
EXPECT_EQ(seen, 1) << "a genuine filler was delivered as a record: Pop's skip was removed, "
|
||||
"not narrowed";
|
||||
EXPECT_EQ(session.Control().appliedSeq.load(), 1u) << "a filler advanced appliedSeq (R-9)";
|
||||
}
|
||||
|
||||
// The kHostSpan/kRecBorrowSlot collision, made loud. P5 implements no borrowed slots and is ruled
|
||||
|
||||
Reference in New Issue
Block a user