diff --git a/MobileGL/MG_Remote/Client/EmitTables.cpp b/MobileGL/MG_Remote/Client/EmitTables.cpp index 5bd989ec..961d09ac 100644 --- a/MobileGL/MG_Remote/Client/EmitTables.cpp +++ b/MobileGL/MG_Remote/Client/EmitTables.cpp @@ -209,6 +209,8 @@ namespace MobileGL::MG_Remote::Client { RequireClientTablesInstalled(slot); ClientSession* session = ClientSession::Active(); if (session == nullptr) { + // @Ph-declined (ID-P7-1): returns ClientSession& and runs in the CLIENT - no + // session to hand back, no peer bytes, and the latch is a server-session idea. SessionFail(MGFatalFamily::NoClientSession, "MGPipe: Fatal{NoClientSession, \"%s\"} - the remote emit table is " "installed but no ClientSession is active. A slot may not fall through " "to a driver this role does not have", diff --git a/MobileGL/MG_Remote/Client/WireTables.cpp b/MobileGL/MG_Remote/Client/WireTables.cpp index 4f96f46c..cafe15b0 100644 --- a/MobileGL/MG_Remote/Client/WireTables.cpp +++ b/MobileGL/MG_Remote/Client/WireTables.cpp @@ -115,6 +115,8 @@ namespace MobileGL::MG_Remote::Client { RequireClientTablesInstalled(row); ClientSession* session = ClientSession::Active(); if (session == nullptr) { + // @Ph-declined (ID-P7-1): returns ClientSession& and runs in the CLIENT - no + // session to hand back, no peer bytes, and the latch is a server-session idea. SessionFail(MGFatalFamily::NoClientSession, "MGPipe: Fatal{NoClientSession, \"%s\"} - the client wire tables are " "installed but no ClientSession is active. A row may not fall through to " "a driver this role does not have", diff --git a/MobileGL/MG_Remote/FatalFunnel.cpp b/MobileGL/MG_Remote/FatalFunnel.cpp index 39b37134..c5efd76a 100644 --- a/MobileGL/MG_Remote/FatalFunnel.cpp +++ b/MobileGL/MG_Remote/FatalFunnel.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include @@ -67,29 +68,119 @@ namespace MobileGL::MG_Remote { MG_Pipe::MGPipeInstallSessionFailHook(&PipeSessionFailAdapter); } - void SessionFail(MGFatalFamily family, const char* fmt, ...) { + namespace { // The line, formatted exactly as the site's old MGLOG_F would have. Same buffer size and // same fallback as WireLogFatal, so a death that used either path reads identically. + void FormatFaultLine(char (&line)[512], const char* fmt, va_list args) { + const int written = std::vsnprintf(line, sizeof(line), fmt, args); + if (written < 0) { + std::snprintf(line, sizeof(line), + "MG_Remote: unformattable Fatal diagnostic (format=%s)", fmt); + } + } + + // The log line and its stderr echo: Defines.h builds the logger with the console sink + // off, so an MGLOG line reaches only the file - and a gtest death matcher reads the + // child's stderr. Flushed because abort() does not flush stdio. + void LogFaultLine(const char* line) { + MGLOG_F("%s", line); + std::fputs(line, stderr); + std::fputc('\n', stderr); + std::fflush(stderr); + } + + // The SessionFault frame to whichever peer this process has; defined below SessionFail, + // whose body it was until PH-1 (3) gave the latch a second caller. + void PublishSessionFault(MGFatalFamily family, const char* line); + + // PH-1 (3): the latch's state. Armed once, by the session child; latched once, by the + // first fault. The line is written under the mutex BEFORE the release store that + // publishes `g_latched`, so a reader that saw true reads a whole line. + std::atomic g_latchArmed{false}; + std::atomic g_latched{false}; + std::atomic g_latchCount{0}; + std::mutex g_latchMutex; + MGFatalFamily g_latchedFamily = MGFatalFamily::ProtocolCorruption; + char g_latchedLine[512] = {}; + } // namespace + + void ArmSessionLatch() { g_latchArmed.store(true, std::memory_order_release); } + + bool SessionLatchArmed() { return g_latchArmed.load(std::memory_order_acquire); } + + bool SessionLatched() { return g_latched.load(std::memory_order_acquire); } + + MGFatalFamily SessionLatchedFamily() { + const std::lock_guard lock(g_latchMutex); + return g_latchedFamily; + } + + const char* SessionLatchedLine() { return g_latchedLine; } + + std::uint64_t SessionLatchCount() { return g_latchCount.load(std::memory_order_relaxed); } + + bool SessionLatch(MGFatalFamily family, const char* fmt, ...) { char line[512]; va_list args; va_start(args, fmt); - const int written = std::vsnprintf(line, sizeof(line), fmt, args); + FormatFaultLine(line, fmt, args); va_end(args); - if (written < 0) { - std::snprintf(line, sizeof(line), - "MG_Remote: unformattable Fatal diagnostic (format=%s)", fmt); + + // UNARMED IS SessionFail, byte for byte: the inproc server, the client and every unit + // death test keep the death they had (FatalFunnel.h's block says why inproc must). + if (!g_latchArmed.load(std::memory_order_acquire)) { + SessionFail(family, "%s", line); } g_sessionFaultCount.fetch_add(1, std::memory_order_relaxed); + g_latchCount.fetch_add(1, std::memory_order_relaxed); + LogFaultLine(line); - MGLOG_F("%s", line); - // The stderr echo is the half a death test can see: Defines.h builds the logger with the - // console sink off, so an MGLOG line reaches only the file - and a gtest death matcher - // reads the child's stderr. Flushed because abort() does not flush stdio. - std::fputs(line, stderr); - std::fputc('\n', stderr); - std::fflush(stderr); + bool first = false; + { + const std::lock_guard lock(g_latchMutex); + if (!g_latched.load(std::memory_order_relaxed)) { + std::snprintf(g_latchedLine, sizeof(g_latchedLine), "%s", line); + g_latchedFamily = family; + g_latched.store(true, std::memory_order_release); + first = true; + } + } + if (!first) { + // A second fault after the first one latched: the session is already declining, so + // this is logged and counted but it neither re-publishes nor replaces the cause. + MGLOG_E("MG_Remote server: SessionLatch - a further named fault after the latched one " + "(%s); it is declined with the rest of the session", + FatalFamilyName(family)); + return false; + } + // ONE LINE THAT SAYS WHAT HAPPENS NEXT, so a log reader can tell a latched end from a + // crash without the supervisor's reap line. + MGLOG_E("MG_Remote server: SessionLatch{%s} - the first named fault of this session is " + "latched; every further record and control op is declined and the session " + "closes (exit %d) for the supervisor to serve the next connection (PH-1, " + "ID-P7-1)", + FatalFamilyName(family), kSessionLatchedExitCode); + PublishSessionFault(family, line); + return false; + } + void SessionFail(MGFatalFamily family, const char* fmt, ...) { + char line[512]; + va_list args; + va_start(args, fmt); + FormatFaultLine(line, fmt, args); + va_end(args); + + g_sessionFaultCount.fetch_add(1, std::memory_order_relaxed); + + LogFaultLine(line); + PublishSessionFault(family, line); + std::abort(); + } + + namespace { + void PublishSessionFault(MGFatalFamily family, const char* line) { // A SessionFault frame to the peer, BEFORE the abort, so the other side's log names the // family instead of reading a bare EOF (5.2). Best-effort by nature: this thread is about // to die, so a send that blocks or fails changes nothing it could have changed anyway. @@ -123,8 +214,7 @@ namespace MobileGL::MG_Remote { (void)control->SendFrame( MobileGLByteSpan{builder.GetBufferPointer(), builder.GetSize()}); } - - std::abort(); } + } // namespace } // namespace MobileGL::MG_Remote diff --git a/MobileGL/MG_Remote/FatalFunnel.h b/MobileGL/MG_Remote/FatalFunnel.h index 959de20d..cda1b541 100644 --- a/MobileGL/MG_Remote/FatalFunnel.h +++ b/MobileGL/MG_Remote/FatalFunnel.h @@ -44,9 +44,62 @@ namespace MobileGL::MG_Remote { void SessionFail(MGFatalFamily family, const char* fmt, ...); // How many times a session has ended through the funnel this process. Exit gate S8 asserts - // this is zero over a whole good run; a test reads it to prove the funnel was reached. + // this is zero over a whole good run; a test reads it to prove the funnel was reached. A + // latched fault (SessionLatch below) counts here too: it ends the session just as surely. ::std::uint64_t SessionFaultCount(); + // ---- PH-1 (3), ID-P7-1: THE PER-SESSION LATCH --------------------------------------- + // + // WHAT IT IS. At a site whose input bytes the PEER wrote, and which already sits in a + // function that can say "no" (Bool / MobileGLResult), a named fault no longer has to kill the + // process: SessionLatch logs the SAME line SessionFail would (family word and all, so every + // `Fatal{` grep, the census and the SessionFault frame read exactly as before), publishes the + // SessionFault to the peer, records the FIRST fault (family + line), and RETURNS false. The + // site returns failure, the decoder declines, ServerLoop::DrainRing stops applying at its + // next check, the apply thread leaves its loop, and ServerMain::RunSession closes the session + // cleanly and exits kSessionLatchedExitCode - a named, counted end the supervisor reaps and + // then serves the next connection, exactly as P6.5's fork-per-session already does for a + // crash (ID-P7-1: "下一连接照常服务" is the supervisor's, not the latch's). + // + // WHERE IT IS ARMED, AND WHY ONLY THERE. ArmSessionLatch() is called by exactly one site: + // ServerMain::RunSession, i.e. the spawn / TCP session child (with or without --serve), whose + // process IS the session. Unarmed, SessionLatch IS SessionFail - same line, same abort - and + // that is the answer everywhere else, deliberately: + // * INPROC keeps Fatal. CONTRACT-P5's R-2 arms (rules A/B/C, the four honesty arms) are + // Fatal{ProtocolCorruption} in the formal wording, and under inproc the client and the + // server share one process: a latched-but-alive server would leave the client's GL thread + // parked on a barrier in the SAME process with no EOF to wake it, and there is no + // supervisor to serve a "next connection" - the process is the session. So the latch + // changes nothing inproc and every death test stays a death test. + // * THE CLIENT never arms it: a client-side arm of a shared helper (the encoder's honesty + // pass) is a local bug, not a peer's bytes. + // The census (scripts/ci/fatal_census.py rule 2) checks SessionLatch calls the way it checks + // SessionFail calls: the string must carry its `Fatal{Word` and the word must have a .def row. + void ArmSessionLatch(); + bool SessionLatchArmed(); + // True once the first fault has latched. One acquire load: DrainRing asks it per record. + bool SessionLatched(); + // The first latched fault - its family and its line, verbatim. Meaningful only once + // SessionLatched() is true; the line's storage lives for the process. + MGFatalFamily SessionLatchedFamily(); + const char* SessionLatchedLine(); + // Every fault this armed process latched or declined after the first (>= 1 once latched). + ::std::uint64_t SessionLatchCount(); + + // The session child's exit status when its session ended on a latched fault. Distinct from + // RunSession's other codes (64-74) so the supervisor's reap line names it; nonzero, so it is + // counted in `sessionsFaulted` like every other fault. + inline constexpr int kSessionLatchedExitCode = 75; + + // LATCH-OR-DIE. Armed: log + stderr echo + SessionFault (first fault only) + count, then + // return false, so a Bool site reads `return SessionLatch(...);`. Unarmed: SessionFail with + // the identical line (does not return). + bool +#if defined(__GNUC__) || defined(__clang__) + __attribute__((format(printf, 2, 3))) +#endif + SessionLatch(MGFatalFamily family, const char* fmt, ...); + // P7 wave 0. Points MG_Pipe's MGPipeSessionFail seam (MG_Pipe/PipeSessionFail.h) at // SessionFail, which is what makes the three Magma wire funnels in MG_Backend's renderer // publish a SessionFault, bump SessionFaultCount() and appear in the census like every other diff --git a/MobileGL/MG_Remote/Protocol/SurfaceOpCodec.cpp b/MobileGL/MG_Remote/Protocol/SurfaceOpCodec.cpp index aa2ca1f2..9499073f 100644 --- a/MobileGL/MG_Remote/Protocol/SurfaceOpCodec.cpp +++ b/MobileGL/MG_Remote/Protocol/SurfaceOpCodec.cpp @@ -205,28 +205,40 @@ namespace MobileGL::MG_Remote { } MobileGLResult ServerApplyWireSurfaceOp(const ::MobileGL::Wire::SurfaceOp& op, SurfaceControlFrame* replyOut) { + // PH-1 (3), ID-P7-1: a latched session answers no further control op. RunSession stops + // reading the control connection once it sees the latch; this covers the op it may + // already be holding. + if (SessionLatched()) return MOBILEGL_ERR_PROTOCOL_MISMATCH; SurfaceControlFrame frame; const SurfaceWireError error = DecodeWireSurfaceOp(op, &frame); if (error != SurfaceWireError::None) { + // PH-1 (3): the op's bytes are the peer's. Each of the three refusals latches in an + // armed session child and the op is answered with no dispatch; unarmed they die. if (error == SurfaceWireError::AndroidNativeWindowArrived) { - SessionFail(MGFatalFamily::UnmigratedSurface, + (void)SessionLatch(MGFatalFamily::UnmigratedSurface, "MGPipe: Fatal{UnmigratedSurface, \"AndroidNativeWindow@P12\"} - a wire " "SurfaceOp (%s) named an ANativeWindow*, which is a pointer into the " "CLIENT's process and means nothing here. Real window arrival is P12; " "until then the spawn surface path is pbuffer/surfaceless only", ::MobileGL::Wire::EnumNameSurfaceOpKind(op.kind())); } else if (error == SurfaceWireError::MetalLayerArrived) { - SessionFail(MGFatalFamily::UnmigratedSurface, + (void)SessionLatch(MGFatalFamily::UnmigratedSurface, "MGPipe: Fatal{UnmigratedSurface, \"MetalLayer@P12\"} - a wire " "SurfaceOp named a CAMetalLayer* in the CLIENT's process. " "Real window arrival is P12"); } else { - SessionFail(MGFatalFamily::ProtocolCorruption, + (void)SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SurfaceOp\"} - a wire surface op " "failed validation: %s (wire kind %u, window kind %u)", SurfaceWireErrorName(error), static_cast(op.kind()), static_cast(op.windowKind())); } + if (replyOut != nullptr) { + *replyOut = SurfaceControlFrame{}; + replyOut->seq = op.seq(); + replyOut->ok = false; + } + return MOBILEGL_ERR_PROTOCOL_MISMATCH; } const MobileGLResult rc = Server::ServerLoopInstance().RunSurfaceControlFrame(frame); if (replyOut != nullptr) *replyOut = frame; diff --git a/MobileGL/MG_Remote/Server/PipeApplier.cpp b/MobileGL/MG_Remote/Server/PipeApplier.cpp index 896c1997..78d2d3fe 100644 --- a/MobileGL/MG_Remote/Server/PipeApplier.cpp +++ b/MobileGL/MG_Remote/Server/PipeApplier.cpp @@ -341,7 +341,9 @@ namespace MobileGL::MG_Remote::Server { // is a protocol fault, because such a client is pacing on this answer and a 0 would // acknowledge a frame nobody asked about. if (present.FrameSerial == 0 && MGPipeServerPublishesRunAhead()) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"Present.FrameSerial\"} - a run-ahead " + // PH-1 (3): latches in an armed session child - no credit is returned for it, the + // session closes and the peer's credit wait ends on the hang-up. + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"Present.FrameSerial\"} - a run-ahead " "server was handed present serial 0. The client mints this 1-based and " "waits on it for its credit (CONTRACT-P5E §2.4); returning a credit for " "serial 0 would release a wait that is asking about frame N"); @@ -502,8 +504,11 @@ namespace MobileGL::MG_Remote::Server { // slot flipped on the client ahead of its server half aborts BY NAME on the apply thread // rather than rendering nothing. Named "(server sink)" in the message so a log reader can // tell which half is missing. - [[noreturn]] static void ServerUnmigratedVerbFatal(const char* slot) { - SessionFail(MGFatalFamily::UnmigratedVerb, "MGPipe: Fatal{UnmigratedVerb, \"%s\"} (server sink: the record crossed and " + // + // PH-1 (3): the shape is the PEER's (a multi-draw record it chose to send), so in an armed + // session child the refusal latches and the verb returns false; unarmed it still dies. + static Bool ServerUnmigratedVerbLatch(const char* slot) { + return SessionLatch(MGFatalFamily::UnmigratedVerb, "MGPipe: Fatal{UnmigratedVerb, \"%s\"} (server sink: the record crossed and " "ServerVerbSink has no body for it yet - CONTRACT-P5B.md names the package)", slot); } @@ -567,13 +572,16 @@ namespace MobileGL::MG_Remote::Server { // "both counts are still 0", and it is distinguishable from the narrowing this refuses // (a narrowed window carries a non-zero count that is merely too small). The moment // either set has been received, the rule binds. - void CheckUnitWindows(const MG_Pipe::MGPipeApplierState& st, const char* verb) { - if (st.SamplerViewCount == 0 && st.SamplerStateCount == 0) return; + // + // PH-1 (3): both windows are the PEER's records, so the refusals latch in an armed + // session child (false = latched, the verb declines); unarmed they still die. + Bool CheckUnitWindows(const MG_Pipe::MGPipeApplierState& st, const char* verb) { + if (st.SamplerViewCount == 0 && st.SamplerStateCount == 0) return true; const Int maxTouched = MG_Pipe::gPipeInputs.GetMaxTouchedTextureUnit(); - if (maxTouched < 0) return; + if (maxTouched < 0) return true; const Uint32 required = static_cast(maxTouched) + 1u; if (st.SamplerViewStart != 0 || st.SamplerViewCount < required) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SetSamplerViews.Count\"} - %s applies with " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SetSamplerViews.Count\"} - %s applies with " "units 0..%d touched, so set_sampler_views must carry Start=0 and Count >= %u " "(CONTRACT-P5E.md §5.3); the applied window is Start=%u Count=%u, which drops " "the sync of at least one texture this draw samples", @@ -581,13 +589,14 @@ namespace MobileGL::MG_Remote::Server { st.SamplerViewCount); } if (st.SamplerStateStart != 0 || st.SamplerStateCount < required) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"BindSamplerStates.Count\"} - %s applies with " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"BindSamplerStates.Count\"} - %s applies with " "units 0..%d touched, so bind_sampler_states must carry Start=0 and Count >= %u " "(CONTRACT-P5E.md §5.3); the applied window is Start=%u Count=%u, which leaves " "an earlier draw's sampler object on at least one unit", verb, static_cast(maxTouched), required, st.SamplerStateStart, st.SamplerStateCount); } + return true; } } // namespace #endif @@ -596,8 +605,8 @@ namespace MobileGL::MG_Remote::Server { const MG_Pipe::MGPDrawRange* ranges, const MG_Pipe::MGHostSpan* userIndices, const MG_Pipe::MGPDrawIndirect* indirect) { - if (userIndices != nullptr) { - Wire::CheckDrawUserIndices(info, ranges, *userIndices); + if (userIndices != nullptr && !Wire::CheckDrawUserIndices(info, ranges, *userIndices)) { + return false; // PH-1 (3): latched (armed session child); unarmed it died inside } // The witness first, before the backend is consulted, so a unit process with no // backend object still sees the wire's fields (PipeApplier.h LastDraw). @@ -617,7 +626,7 @@ namespace MobileGL::MG_Remote::Server { #if MOBILEGL_BUILD_DISAGGREGATED // Ruling 19 / ID-95: the window promise, checked at every draw, before the backend is // asked to resolve anything out of the windows. - CheckUnitWindows(MG_Pipe::MGPipeApplier(), "draw_vbo"); + if (!CheckUnitWindows(MG_Pipe::MGPipeApplier(), "draw_vbo")) return false; #endif const MG_Backend::GlobalBackendFunctionsTable* table = Table("draw_vbo"); if (table == nullptr) return false; @@ -694,12 +703,12 @@ namespace MobileGL::MG_Remote::Server { // ---- the multi-draws: the two arrays rebuilt from the ranges (rule C) -------------- if (info.NumDraws != 1) { if (userIndices != nullptr) { - ServerUnmigratedVerbFatal(info.IndexSize == 0 ? "MultiDrawArrays+CLIENT_INDICES" - : "MultiDrawElements+CLIENT_INDICES"); + return ServerUnmigratedVerbLatch(info.IndexSize == 0 ? "MultiDrawArrays+CLIENT_INDICES" + : "MultiDrawElements+CLIENT_INDICES"); } if (instanced) { - ServerUnmigratedVerbFatal(info.IndexSize == 0 ? "MultiDrawArrays+INSTANCED" - : "MultiDrawElements+INSTANCED"); + return ServerUnmigratedVerbLatch(info.IndexSize == 0 ? "MultiDrawArrays+INSTANCED" + : "MultiDrawElements+INSTANCED"); } const auto n = static_cast(info.NumDraws); m_multiCounts.resize(n); @@ -840,7 +849,7 @@ namespace MobileGL::MG_Remote::Server { Bool ServerVerbSink::OnLaunchGrid(const MG_Pipe::MGPGridInfo& grid) { #if MOBILEGL_BUILD_DISAGGREGATED // Ruling 19 / ID-95: a dispatch samples through the same unit windows a draw does. - CheckUnitWindows(MG_Pipe::MGPipeApplier(), "launch_grid"); + if (!CheckUnitWindows(MG_Pipe::MGPipeApplier(), "launch_grid")) return false; #endif const MG_Backend::GlobalBackendFunctionsTable* table = Table("launch_grid"); if (table == nullptr) return false; @@ -1164,8 +1173,9 @@ namespace MobileGL::MG_Remote::Server { // session, so the only legal sequence is 0, 1, 2, ... and the session's own count of // accepted resets IS the expected value; anything else means the two ends disagree // about how many make-current edges have crossed, which no backend answer can fix. + // PH-1 (3): latches in an armed session child (the peer wrote the serial); dies unarmed. if (reset.ContextSerial != m_applierResetSerial) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ApplierReset.ContextSerial\"} - the " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ApplierReset.ContextSerial\"} - the " "record carries %llu and this session has accepted %llu reset(s); the " "serial is asserted against the session's own count, not dispatched on " "(one context per session in P5c)", @@ -1186,13 +1196,14 @@ namespace MobileGL::MG_Remote::Server { // §1's zero ruling: a null handle means "the object never crossed", and the client // emits NOTHING in that case (§5.2) - so a null handle arriving here is corruption, // not a no-op. + // PH-1 (3): both refusals latch in an armed session child; unarmed they die as before. if (MG_Pipe::MGPipeHandleIsNull(death.Handle)) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Handle\"} - a null " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Handle\"} - a null " "handle never crosses: the client emits nothing for an object its own " "allocator cannot resolve (CONTRACT-P5C.md §5.2)"); } if (death.Kind >= static_cast(MG_Pipe::MGPipeKind::KindCount)) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Kind\"} - %u is not an " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ObjectDeath.Kind\"} - %u is not an " "MGPipeKind", static_cast(death.Kind)); } diff --git a/MobileGL/MG_Remote/Server/ServerLoop.cpp b/MobileGL/MG_Remote/Server/ServerLoop.cpp index be672f9a..61416a8d 100644 --- a/MobileGL/MG_Remote/Server/ServerLoop.cpp +++ b/MobileGL/MG_Remote/Server/ServerLoop.cpp @@ -426,6 +426,15 @@ namespace MobileGL::MG_Remote::Server { if (stopOrForfeit()) break; if (signals.EventRingFull->load(std::memory_order_acquire) == 0) DrainRing(); if (stopOrForfeit()) break; + // PH-1 (3): a latched session applies nothing more, so this thread leaves the loop + // (and does not spin on a ring whose head is still ahead of a tail DrainRing will no + // longer move). The exit path below tears the backend down as for a Stop; the + // control connection's owner (ServerMain::RunSession) sees the latch and closes. + if (SessionLatched()) { + MGLOG_E("MG_Remote server: mgl-srv-apply leaves its loop - the session latched a " + "named fault and declines the rest of the ring (PH-1)"); + break; + } if (ready()) continue; const Uint64 waits = m_parks.fetch_add(1, std::memory_order_acq_rel) + 1; @@ -627,11 +636,18 @@ namespace MobileGL::MG_Remote::Server { // the consumer's own m_localTail (Ring.cpp:222-232) - no mutex, no steady_clock, no // allocation; `applied == 0` then skips the retire block below entirely. Keep it that // way: anything added here runs ~900 times a frame doing nothing. + // + // PH-1 (3), ID-P7-1: THE LATCH CHECK, AT THE TOP AND BEFORE EVERY POP. Once a named fault + // has latched (an armed spawn / TCP session child only - unarmed this is one acquire load + // of a flag nobody sets), no further record is applied: the rest of the ring is declined + // unread, the apply thread leaves its loop, and RunSession closes the session by name. + if (SessionLatched()) return 0; ServerSession& session = *m_session; Transport::SessionConsumer& consumer = session.Consumer(); PipeApplier& applier = session.Applier(); Uint64 applied = 0; for (;;) { + if (SessionLatched()) break; bool corrupt = false; const bool popped = consumer.ApplyOne( [this, &applier](const Transport::RingRecordView& record) { @@ -654,10 +670,13 @@ namespace MobileGL::MG_Remote::Server { if (corrupt) { // Ring.h's own rule: a header the producer could not have written is // Fatal{ProtocolCorruption}, never a retry. Retrying re-reads the same bytes - // for ever; skipping desynchronises seq, and seq IS the reply-slot id. - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SEG_CMD record header\"} - the " + // for ever; skipping desynchronises seq, and seq IS the reply-slot id. PH-1 (3): + // the header is the peer's bytes, so an armed session child latches here and + // stops reading the ring - neither a retry nor a skip. + (void)SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SEG_CMD record header\"} - the " "consumer refused a record header at applied seq %llu", static_cast(consumer.AppliedSeq())); + break; } if (!popped) break; ++applied; @@ -672,12 +691,15 @@ namespace MobileGL::MG_Remote::Server { // and PipeWireDecoder keeps its own count; a batched publish would move one and not // the other, which a single counter could not have told apart (w1-v1 5). if (applier.DecoderAppliedSeq() != consumer.AppliedSeq()) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"appliedSeq batched\"} - the " + // PH-1 (3): latched, and nothing is retired against a watermark this side no + // longer believes. + (void)SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"appliedSeq batched\"} - the " "session's watermark is %llu and the decoder applied %llu records. P5 " "forbids batching appliedSeq (R-9): the verb barrier's waiter reads it, " "and a watermark ahead of the decoder promises work that has not run", static_cast(consumer.AppliedSeq()), static_cast(applier.DecoderAppliedSeq())); + return applied; } // RETIRE. MANDATORY, not optional: RingProducer::FreeBytes() reclaims against // retiredTail only, and w1's SEG_STAGE linear allocator reclaims on retiredSeq - so @@ -917,23 +939,30 @@ namespace MobileGL::MG_Remote::Server { // WindowHandle, so an out-of-range tag is a memory-corruption shape, not bad input - // same discipline as the ring's Fatal{ProtocolCorruption} on a header the producer could // not have written. - MG_Backend::WindowBackend WindowBackendFromFrameValue(Int value) { + // + // PH-1 (3): false = the refusal latched (an armed spawn / TCP session child, where the + // frame's values came off the wire through SurfaceOpCodec). Under spawn the codec has + // already mapped the wire WindowKind onto a legal tag, so this arm is defence in depth + // there; unarmed it still dies. + Bool WindowBackendFromFrameValue(Int value, MG_Backend::WindowBackend* out) { if (value < static_cast(MG_Backend::WindowBackend::Unknown) || value >= static_cast(MG_Backend::WindowBackend::WindowBackendCount)) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SurfaceOp.windowBackend\"} - a " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SurfaceOp.windowBackend\"} - a " "control frame carried window backend tag %d, which names no " "WindowBackend", value); } - return static_cast(value); + *out = static_cast(value); + return true; } - MG_Backend::WindowHandle UnpackWindowHandle(const SurfaceControlFrame& frame) { + Bool UnpackWindowHandle(const SurfaceControlFrame& frame, MG_Backend::WindowHandle* out) { MG_Backend::WindowHandle handle; - handle.Backend = WindowBackendFromFrameValue(frame.windowBackend); + if (!WindowBackendFromFrameValue(frame.windowBackend, &handle.Backend)) return false; handle.Handle = HandleFromToken(frame.nativeToken); handle.Width = static_cast(frame.width); handle.Height = static_cast(frame.height); - return handle; + *out = handle; + return true; } // P7 CI. THE TEST LEVER FOR THE CLIENT'S COLD-START BUDGET (ClientSession.cpp, @@ -1003,8 +1032,13 @@ namespace MobileGL::MG_Remote::Server { return MOBILEGL_OK; } case SurfaceControlOp::CreateWindowSurface: { + MG_Backend::WindowHandle window; + if (!UnpackWindowHandle(frame, &window)) { + frame.ok = false; + return MOBILEGL_ERR_PROTOCOL_MISMATCH; // PH-1 (3): latched + } frame.ok = backend->CreateEGLWindowSurface(HandleFromToken(frame.surface), - UnpackWindowHandle(frame)); + window); // N-3: BackendObject_DirectGLES destroys and recreates the native context to create // a DIFFERENT surface, so whatever tuple was bound names a dead context. if (frame.ok) ForgetCurrentTuple(); @@ -1103,10 +1137,16 @@ namespace MobileGL::MG_Remote::Server { ForgetCurrentTuple(); frame.ok = true; return MOBILEGL_OK; - case SurfaceControlOp::SetWindowHandle: - backend->SetWindowHandle(UnpackWindowHandle(frame)); + case SurfaceControlOp::SetWindowHandle: { + MG_Backend::WindowHandle window; + if (!UnpackWindowHandle(frame, &window)) { + frame.ok = false; + return MOBILEGL_ERR_PROTOCOL_MISMATCH; // PH-1 (3): latched + } + backend->SetWindowHandle(window); frame.ok = true; return MOBILEGL_OK; + } case SurfaceControlOp::InitCapabilities: // Inproc-only (f0-egl §4.2): on the wire the ANSWER to this op is the CapsSnapshot // frame itself, so there is no SurfaceOp kind for it. The frame channel still has to @@ -1142,8 +1182,12 @@ namespace MobileGL::MG_Remote::Server { // A kind the switch does not know never legitimately posts (RunSurfaceControlFrame // refuses None), so reaching this line means the slot's bytes were not written by the // poster at all - the ring's Fatal{ProtocolCorruption} discipline, one channel over. - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SurfaceOp.kind\"} - the control slot held " + // PH-1 (3): latched in an armed session child (under spawn SurfaceOpCodec has already + // refused an unknown wire kind, so this is defence in depth); unarmed it dies. + frame.ok = false; + (void)SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"SurfaceOp.kind\"} - the control slot held " "kind %d, which names no dispatchable op", static_cast(frame.kind)); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; } // --------------------------------------------------------------------------------- diff --git a/MobileGL/MG_Remote/Server/ServerMain.cpp b/MobileGL/MG_Remote/Server/ServerMain.cpp index 9e132c7d..d1e5c281 100644 --- a/MobileGL/MG_Remote/Server/ServerMain.cpp +++ b/MobileGL/MG_Remote/Server/ServerMain.cpp @@ -6,6 +6,7 @@ #include "../Transport/WireLog.h" #include "../Protocol/SurfaceOpCodec.h" #include "../Handshake.h" +#include "../FatalFunnel.h" #include "ServerLoop.h" #include "ServerSession.h" #include "SurfaceControlFrame.h" @@ -245,6 +246,11 @@ void SendLogAck(void* pointer) { MobileGL::MG_ConfigLoader::Init(); MobileGL::MG_Config::Transport = MobileGL::MG_Config::TransportMode::Spawn; MobileGL::MG_Pipe::MGPipeSetServerProcessRole(true); + // PH-1 (3), ID-P7-1: THIS PROCESS IS THE SESSION, so a named fault on the peer's bytes may end + // it cleanly instead of aborting it (FatalFunnel.h's latch block). Armed here and nowhere + // else: inproc keeps every Fatal (client and server share that process), and the supervisor + // that forked this child never applies a record. + ArmSessionLatch(); // The child bypasses MobileGL::Initialize: arm its own role counters. MobileGL::MG_Util::PipeStats::Init(); const auto backend = static_cast(hello->backendType()); @@ -368,15 +374,26 @@ void SendLogAck(void* pointer) { // this child would sit here, holding the supervisor's one session slot, answering every // later client Busy. Now each quiet interval asks whether the apply thread is still there; // when it is not (forfeit, or a dead data bell), the session takes the ordinary exit below: - // Stop, Close, _exit(0). The interval is the supervisor's own accept poll. + // Stop, Close, _exit(0). (PH-6 used the supervisor's 250 ms accept poll as the interval; the + // PH-1 note below says why the slice is now 100 ms.) // // ASKED AT THE TOP OF EVERY TURN, NOT ONLY WHEN A WAIT TIMES OUT (PH-6 fix round). A peer that // forfeited the reverse channel but keeps TALKING - a LogFlush or a surface op more often than // every 250 ms, each of which this loop answers - never let ReceiveFrame time out, so the // question was never asked and the session, with the supervisor's one slot, lived as long as // the peer kept chattering. - constexpr std::uint32_t kControlPollMs = 250; + // + // PH-1 (3): the same wait is also how a LATCH raised on the apply thread (the decoder, the + // applier, DrainRing) ends this loop within one slice while the peer is silent. ReceiveFrame + // keeps a partial frame in its reassembler across a timeout, so slicing loses no bytes; a + // latched session is then closed below with kSessionLatchedExitCode. The latch is asked + // BEFORE the apply thread's liveness because a latch also stops that thread (it leaves its + // loop and clears m_running), and the session must be named by its latch, not as a stopped + // apply thread. One slice serves both: the latch's 100 ms (PH-6 had 250 ms; the shorter wait + // only asks the question more often). + constexpr std::uint32_t kControlSliceMs = 100; for (;;) { + if (SessionLatched()) break; if (!loop.Running()) { WireLogError("MG_Remote server: pid=%d the apply thread has stopped (%s, %llu event(s) dropped); " "ending the session", @@ -385,7 +402,7 @@ void SendLogAck(void* pointer) { break; } std::uint64_t size = 0; - const auto result = control->ReceiveFrame({buffer.data(), buffer.size()}, &size, kControlPollMs); + const auto result = control->ReceiveFrame({buffer.data(), buffer.size()}, &size, kControlSliceMs); if (result == MOBILEGL_ERR_TIMEOUT) continue; if (result == MOBILEGL_ERR_BUFFER_TOO_SMALL) { buffer.resize(static_cast(size)); continue; } if (result != MOBILEGL_OK) { @@ -454,6 +471,9 @@ void SendLogAck(void* pointer) { if (!operation) break; Server::SurfaceControlFrame reply{}; (void)ServerApplyWireSurfaceOp(*operation, &reply); + // PH-1 (3): an op that latched (its own bytes, or a record the apply thread was on) is + // answered by the SessionFault already sent and the close below, not by a reply. + if (SessionLatched()) break; session.FlushDataProgress(); if (session.DataLink()) reply.eventHead = session.DataLink()->EventPublishedHead(); flatbuffers::FlatBufferBuilder builder(256); @@ -466,11 +486,22 @@ void SendLogAck(void* pointer) { loop.Stop(); // Publish the final server window while log forwarding is still attached. MobileGL::MG_Util::PipeStats::Shutdown(); + // PH-1 (3): a latched session ends HERE, by the same orderly path as a peer's EOF - the apply + // thread has already left its loop and destroyed the backend on its own thread (loop.Stop() + // joined it), the session closes, and the exit status names the latch so the supervisor + // counts it. The first fault's line was logged (and sent as a SessionFault) when it latched. + const bool latched = SessionLatched(); + if (latched) { + WireLogError("MG_Remote server: pid=%d session closed on a latched fault (%s; %llu named " + "fault(s) latched or declined); exiting %d for the supervisor to reap - PH-1", + selfPid, FatalFamilyName(SessionLatchedFamily()), + static_cast(SessionLatchCount()), kSessionLatchedExitCode); + } session.Close(); MobileGL::MG_Util::Debug::SetLogForwarder(nullptr, nullptr); // _exit closes control after cleanup/flush; peer EOF is the client's fence. std::fflush(nullptr); - ::_exit(0); + ::_exit(latched ? kSessionLatchedExitCode : 0); } // P7 wave 0, Ph slice (1) (ID-P7-1). THE SUPERVISOR OBSERVES AND NAMES A SESSION'S DEATH. @@ -498,8 +529,11 @@ bool ReapActiveSession(pid_t& active, unsigned long& sessionsFaulted) { if (WIFEXITED(status)) { const int code = WEXITSTATUS(status); if (code != 0) ++sessionsFaulted; - WireLogError("MG_Remote server: session pid=%d reaped exit=%d sessionsFaulted=%lu", - reaped, code, sessionsFaulted); + // PH-1 (3): a latched session is a named, orderly end - still a fault (counted), and said + // as one so the reap line alone tells it from a crash or a refused bring-up. + WireLogError("MG_Remote server: session pid=%d reaped exit=%d%s sessionsFaulted=%lu", + reaped, code, code == kSessionLatchedExitCode ? " (latched fault)" : "", + sessionsFaulted); } else if (WIFSIGNALED(status)) { ++sessionsFaulted; WireLogError("MG_Remote server: session pid=%d reaped signal=%d sessionsFaulted=%lu", diff --git a/MobileGL/MG_Remote/Server/ServerSession.cpp b/MobileGL/MG_Remote/Server/ServerSession.cpp index a1f000e0..e1c03099 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.cpp +++ b/MobileGL/MG_Remote/Server/ServerSession.cpp @@ -404,6 +404,9 @@ namespace MobileGL::MG_Remote::Server { ServerSession& ReverseCallbackOwner(const char* callback) { auto* session = ServerSession::Active(); if (session == nullptr) { + // @Ph-declined (ID-P7-1): returns ServerSession& - there is no session to hand + // back and no honest "declined" reference, and the input is the backend's own + // reverse call, not a peer's bytes. Stays Fatal. SessionFail(MGFatalFamily::RoleViolation, "MGPipe: Fatal{RoleViolation, \"%s.session-missing\"} - " "a reverse callback has no accepted session owner", callback); } @@ -432,6 +435,9 @@ namespace MobileGL::MG_Remote::Server { // The backend handed over a segment-tagged blobref. The ONLY legal shape at // this boundary is the monolith one - Offset is the mapped address, valid // for this call - because the segment copy is THIS function's own job. + // @Ph-declined (ID-P7-1): the blobref is the BACKEND's (a reverse callback with + // a void signature and no caller that could act on a decline), and it rides the + // ServerSession& above; not a peer's bytes. Stays Fatal. SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Seg\"} - the " "writeback producer was handed Seg %u; at the backend boundary the " "blobref names the backend's own mapped bytes (Seg = " @@ -997,6 +1003,8 @@ namespace MobileGL::MG_Remote::Server { Transport::RingControl& ServerSession::Control() { Transport::RingControl* control = m_link->Memory().CmdControl(); if (control == nullptr) { + // @Ph-declined (ID-P7-1): returns RingControl& - no honest reference exists before + // Accept() mapped SEG_CMD, and this is a call-order invariant, not a peer's bytes. SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ServerSession::Control\"} - the control " "page does not exist until Accept() has mapped SEG_CMD"); } @@ -1022,6 +1030,8 @@ namespace MobileGL::MG_Remote::Server { return *m_externalConsumerBell; } if (m_transport == nullptr) { + // @Ph-declined (ID-P7-1): returns Doorbell& - no bell exists to hand back before + // Accept(); a call-order invariant, not a peer's bytes. SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ServerSession::ConsumerDoorbell\"} - no " "transport; Accept() has not run"); } @@ -1031,6 +1041,8 @@ namespace MobileGL::MG_Remote::Server { // P6: SocketTransport's pair. The accessors stay off ITransport by ruling (contract // §3.9) precisely so that this stays one switch in one file rather than two virtuals // every transport has to invent a home for. + // @Ph-declined (ID-P7-1): returns Doorbell& for a transport role this build wires no + // pair for - a build-shape fact, not a peer's bytes, and no reference to decline with. SessionFail(MGFatalFamily::UnmigratedVerb, "MGPipe: Fatal{UnmigratedVerb, \"ServerSession::ConsumerDoorbell\"} - transport " "role %u has no doorbell pair yet; that is P6's SocketTransport", static_cast(m_transport->Role())); @@ -1041,12 +1053,14 @@ namespace MobileGL::MG_Remote::Server { return *m_externalProducerBell; } if (m_transport == nullptr) { + // @Ph-declined (ID-P7-1): returns Doorbell& - as ConsumerDoorbell above. SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ServerSession::ProducerDoorbell\"} - no " "transport; Accept() has not run"); } if (m_transport->Role() == Transport::TransportRole::InProcess) { return static_cast(m_transport)->PeerDoorbell(); } + // @Ph-declined (ID-P7-1): returns Doorbell& - as ConsumerDoorbell above. SessionFail(MGFatalFamily::UnmigratedVerb, "MGPipe: Fatal{UnmigratedVerb, \"ServerSession::ProducerDoorbell\"} - transport " "role %u has no doorbell pair yet; that is P6's SocketTransport", static_cast(m_transport->Role())); diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index b1c7852a..b633c251 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -281,6 +281,17 @@ namespace MobileGL::MG_Remote::Wire { static_assert(sizeof(kWirePayloadBytes) / sizeof(kWirePayloadBytes[0]) == static_cast(MGPWireOp::kOpCount)); + // PH-1 (3): the pre-gate in DecodeAndApply (AdmitsTheGeneratedGate) asks "shorter than + // its own type" with this file's payload list, and the generated MGP_WIRE_CHECK_BOUNDS + // asks it with sizeof(MGPWireRec_X). Pinned equal row by row, so a record the pre-gate + // admits can never be one the generated gate would still abort on. +#define MGPW_REC_ROW(Name, Payload) \ + static_assert(sizeof(MG_Pipe::MGPWireRec_##Name) == \ + Align8(sizeof(MGPWireRecHeader) + sizeof(MG_Pipe::Payload)), \ + "MGPWireRec_" #Name " and this file's payload row disagree on size"); + MGPW_FOR_EACH_CALL(MGPW_REC_ROW) +#undef MGPW_REC_ROW + // The header's Size field is 32 bits by wire contract (Ring.h's kMaxRingCapacity // comment). Every count-derived total is checked against this BEFORE it is used, so a // corrupt Count can never wrap the arithmetic that is supposed to catch it. @@ -389,6 +400,18 @@ namespace MobileGL::MG_Remote::Wire { static_cast(got), static_cast(expected)); } + // PH-1 (3): the same two lines through the latch (PipeWireCodec.h says where each is used). + Bool WireProtocolLatch(const char* what, const char* detail) { + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} %s", what, + detail != nullptr ? detail : ""); + } + + Bool WireProtocolLatchAt(const char* what, Uint64 got, Uint64 expected) { + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} got=%llu expected=%llu", + what, static_cast(got), + static_cast(expected)); + } + // --------------------------------------------------------------------------------- // SegmentTable // --------------------------------------------------------------------------------- @@ -472,22 +495,22 @@ namespace MobileGL::MG_Remote::Wire { // R-2's honesty arms // --------------------------------------------------------------------------------- - void CheckBlobIsHonest(MGPWireOp op, const MGPBlobRef& blob, const SegmentTable& segments) { + Bool CheckBlobIsHonest(MGPWireOp op, const MGPBlobRef& blob, const SegmentTable& segments) { if (blob.Size == 0) { if (blob.Seg != kSegNone || blob.Offset != 0) { // The monolith shape: Seg = None, Offset = a host address, Size = 0. Legal // there, and precisely what must not cross (rule A). Reading it as "absent" // would silently drop the bytes of every record an unconverted emitter sent. - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} a blob with Size 0 " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} a blob with Size 0 " "declares Seg=%u Offset=%llu; under split a blobref is either fully " "declared or all three fields zero", WireOpName(op), static_cast(blob.Seg), static_cast(blob.Offset)); } - return; + return true; } if (blob.Seg == kSegNone) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} Size=%llu with no segment " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} Size=%llu with no segment " "(R-2.3): a non-zero Size must name a real SEG_*", WireOpName(op), static_cast(blob.Size)); } @@ -511,7 +534,7 @@ namespace MobileGL::MG_Remote::Wire { // seventh name would be a token nothing else in the tree recognises. The SEGMENT is in // the message, which is what has to be greppable. if (blob.Seg != kSegStage) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu " "size=%llu is not SEG_STAGE(%u); every client->server content blob is " "staged whole in SEG_STAGE (contract table 1 groups A/B/C, R-10) and no " "other segment may carry one", @@ -521,45 +544,47 @@ namespace MobileGL::MG_Remote::Wire { static_cast(kSegStage)); } if (segments.Resolve(blob.Seg, blob.Offset, blob.Size) == nullptr) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu size=%llu " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} seg=%u offset=%llu size=%llu " "does not lie inside that segment (R-2.3)", WireOpName(op), static_cast(blob.Seg), static_cast(blob.Offset), static_cast(blob.Size)); } + return true; } - void RequireDeclaredBlob(MGPWireOp op, const MGPBlobRef& blob, const SegmentTable& segments) { + Bool RequireDeclaredBlob(MGPWireOp op, const MGPBlobRef& blob, const SegmentTable& segments) { if (blob.Size == 0) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} the record's own fields say " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s.blob\"} the record's own fields say " "it carries content and its blob declares none (R-2.2 / rule A)", WireOpName(op)); } - CheckBlobIsHonest(op, blob, segments); + return CheckBlobIsHonest(op, blob, segments); } - void CheckHostSpanIsHonest(const MGHostSpan& span) { + Bool CheckHostSpanIsHonest(const MGHostSpan& span) { if (span.Ptr != nullptr) { // Rule B. In one address space this pointer WORKS, which is the whole reason the // rule has to be mechanical: an inproc implementation that kept using it is // indistinguishable from a correct one until the day it is a second process. - WireProtocolFatal("host-span", - "MGHostSpan::Ptr is non-null under split; the encoder writes " - "nullptr and names SEG_STAGE (R-2.1)"); + return WireProtocolLatch("host-span", + "MGHostSpan::Ptr is non-null under split; the encoder writes " + "nullptr and names SEG_STAGE (R-2.1)"); } if (span.Size != 0 && span.Seg == kMGHostSpanSegNone) { - WireProtocolFatalAt("host-span.seg", span.Size, 0); + return WireProtocolLatchAt("host-span.seg", span.Size, 0); } if (span.Size == 0 && span.Seg != kMGHostSpanSegNone) { - WireProtocolFatalAt("host-span.size", span.Seg, 0); + return WireProtocolLatchAt("host-span.size", span.Seg, 0); } + return true; } - void CheckHostSpanIsHonest(const MGHostSpan& span, const SegmentTable& segments) { - CheckHostSpanIsHonest(span); + Bool CheckHostSpanIsHonest(const MGHostSpan& span, const SegmentTable& segments) { + if (!CheckHostSpanIsHonest(span)) return false; if (span.Size == 0) { // Fully absent, and the arms above already proved Seg agrees with that. - return; + return true; } // ARM 4, WHICH THE OTHER OVERLOAD CANNOT DO. A span naming a real segment and a run // past the end of it used to pass every check and reach WireVerbSink::OnDrawVbo, which @@ -569,32 +594,34 @@ namespace MobileGL::MG_Remote::Wire { // resolver, reads outside the segment. P5 emits no spans, so this was latent; P8 arms // it, which is exactly when nobody will be reading this code. if (segments.Resolve(span.Seg, span.Offset, span.Size) == nullptr) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"host-span\"} seg=%u offset=%llu " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"host-span\"} seg=%u offset=%llu " "size=%llu does not lie inside that segment (R-2.3 arm 4)", static_cast(span.Seg), static_cast(span.Offset), static_cast(span.Size)); } + return true; } - void CheckDrawUserIndices(const MGPDrawInfo& info, const MGPDrawRange* ranges, + Bool CheckDrawUserIndices(const MGPDrawInfo& info, const MGPDrawRange* ranges, const MGHostSpan& span) { if ((info.Flags & kDrawHasUserIndices) == 0 || (info.Flags & kDrawIsIndirect) != 0 || info.NumDraws != 1 || ranges == nullptr) { - WireProtocolFatal("DrawVbo.userIndices.shape", - "a user-index span requires exactly one direct indexed range"); + return WireProtocolLatch("DrawVbo.userIndices.shape", + "a user-index span requires exactly one direct indexed range"); } if (info.IndexSize != 1 && info.IndexSize != 2 && info.IndexSize != 4) { - WireProtocolFatalAt("DrawVbo.userIndices.IndexSize", info.IndexSize, 4); + return WireProtocolLatchAt("DrawVbo.userIndices.IndexSize", info.IndexSize, 4); } // Client arrays are staged from their first index; Start only addresses an EBO. if (ranges[0].Start != 0) { - WireProtocolFatalAt("DrawVbo.userIndices.Start", ranges[0].Start, 0); + return WireProtocolLatchAt("DrawVbo.userIndices.Start", ranges[0].Start, 0); } const Uint64 required = static_cast(ranges[0].Count) * info.IndexSize; if (required > span.Size) { - WireProtocolFatalAt("DrawVbo.userIndices.extent", required, span.Size); + return WireProtocolLatchAt("DrawVbo.userIndices.extent", required, span.Size); } + return true; } // --------------------------------------------------------------------------------- @@ -625,8 +652,8 @@ namespace MobileGL::MG_Remote::Wire { const auto& p = *static_cast(payload); if (p.Count > kMGPipeMaxVertexAttribs || static_cast(p.Start) + p.Count > kMGPipeMaxVertexAttribs) { - WireProtocolFatalAt("SetVertexBuffers.Count", - static_cast(p.Start) + p.Count, kMGPipeMaxVertexAttribs); + return WireProtocolLatchAt("SetVertexBuffers.Count", + static_cast(p.Start) + p.Count, kMGPipeMaxVertexAttribs); } tail0 = TailBytesFor(p.Count, sizeof(MGPVertexBuffer)); tails = 1; @@ -635,8 +662,8 @@ namespace MobileGL::MG_Remote::Wire { case MGPWireOp::SetSamplerViews: { const auto& p = *static_cast(payload); if (static_cast(p.Start) + p.Count > kMGPipeMaxTextureUnits) { - WireProtocolFatalAt("SetSamplerViews.Count", static_cast(p.Start) + p.Count, - kMGPipeMaxTextureUnits); + return WireProtocolLatchAt("SetSamplerViews.Count", static_cast(p.Start) + p.Count, + kMGPipeMaxTextureUnits); } tail0 = TailBytesFor(p.Count, sizeof(MGPBoundView)); tails = 1; @@ -645,8 +672,8 @@ namespace MobileGL::MG_Remote::Wire { case MGPWireOp::BindSamplerStates: { const auto& p = *static_cast(payload); if (static_cast(p.Start) + p.Count > kMGPipeMaxTextureUnits) { - WireProtocolFatalAt("BindSamplerStates.Count", - static_cast(p.Start) + p.Count, kMGPipeMaxTextureUnits); + return WireProtocolLatchAt("BindSamplerStates.Count", + static_cast(p.Start) + p.Count, kMGPipeMaxTextureUnits); } tail0 = TailBytesFor(p.Count, sizeof(MGPipeHandle)); tails = 1; @@ -655,8 +682,8 @@ namespace MobileGL::MG_Remote::Wire { case MGPWireOp::SetShaderImages: { const auto& p = *static_cast(payload); if (static_cast(p.Start) + p.Count > kMGPipeMaxImageUnits) { - WireProtocolFatalAt("SetShaderImages.Count", static_cast(p.Start) + p.Count, - kMGPipeMaxImageUnits); + return WireProtocolLatchAt("SetShaderImages.Count", static_cast(p.Start) + p.Count, + kMGPipeMaxImageUnits); } tail0 = TailBytesFor(p.Count, sizeof(MGPImageView)); tails = 1; @@ -672,14 +699,14 @@ namespace MobileGL::MG_Remote::Wire { // is the arm that turns Count into a byte length, so a forged count has to be // refused before it is multiplied rather than after. if (p.Class >= kMGPipeShaderBufferClassCount) { - WireProtocolFatalAt("SetShaderBuffers.Class", p.Class, kMGPipeShaderBufferClassCount); + return WireProtocolLatchAt("SetShaderBuffers.Class", p.Class, kMGPipeShaderBufferClassCount); } if (static_cast(p.Start) + p.Count > kMGPipeMaxBufferBindingPoints) { - WireProtocolFatalAt("SetShaderBuffers.Count", static_cast(p.Start) + p.Count, - kMGPipeMaxBufferBindingPoints); + return WireProtocolLatchAt("SetShaderBuffers.Count", static_cast(p.Start) + p.Count, + kMGPipeMaxBufferBindingPoints); } if (p.HostSpanCount != 0 && p.HostSpanCount != p.Count) { - WireProtocolFatalAt("SetShaderBuffers.HostSpanCount", p.HostSpanCount, p.Count); + return WireProtocolLatchAt("SetShaderBuffers.HostSpanCount", p.HostSpanCount, p.Count); } tail0 = TailBytesFor(p.Count, sizeof(MGPBufferRange)); tail1 = TailBytesFor(p.HostSpanCount, sizeof(MGHostSpan)); @@ -709,16 +736,16 @@ namespace MobileGL::MG_Remote::Wire { // multiplied. const auto& p = *static_cast(payload); if (p.BlockBindingCount > kMGPipeMaxProgramBlockBindings) { - WireProtocolFatalAt("SetProgramBindings.BlockBindingCount", p.BlockBindingCount, - kMGPipeMaxProgramBlockBindings); + return WireProtocolLatchAt("SetProgramBindings.BlockBindingCount", p.BlockBindingCount, + kMGPipeMaxProgramBlockBindings); } if (p.SamplerUnitCount > kMGPipeMaxProgramSamplerUnits) { - WireProtocolFatalAt("SetProgramBindings.SamplerUnitCount", p.SamplerUnitCount, - kMGPipeMaxProgramSamplerUnits); + return WireProtocolLatchAt("SetProgramBindings.SamplerUnitCount", p.SamplerUnitCount, + kMGPipeMaxProgramSamplerUnits); } if (p.StorageOverrideCount > kMGPipeMaxProgramStorageOverrides) { - WireProtocolFatalAt("SetProgramBindings.StorageOverrideCount", - p.StorageOverrideCount, kMGPipeMaxProgramStorageOverrides); + return WireProtocolLatchAt("SetProgramBindings.StorageOverrideCount", + p.StorageOverrideCount, kMGPipeMaxProgramStorageOverrides); } tail0 = TailBytesFor(p.BlockBindingCount, sizeof(Int32)); tail1 = TailBytesFor(p.SamplerUnitCount, sizeof(MGPProgramSamplerUnit)); @@ -742,10 +769,10 @@ namespace MobileGL::MG_Remote::Wire { // scatters attribute values onto the wrong locations. const auto& p = *static_cast(payload); if (p.Count != PopCount32(p.Mask)) { - WireProtocolFatalAt("SetVertexAttribDefaults.Count", p.Count, PopCount32(p.Mask)); + return WireProtocolLatchAt("SetVertexAttribDefaults.Count", p.Count, PopCount32(p.Mask)); } if (p.Count > kMGPipeMaxVertexAttribs) { - WireProtocolFatalAt("SetVertexAttribDefaults.Count", p.Count, kMGPipeMaxVertexAttribs); + return WireProtocolLatchAt("SetVertexAttribDefaults.Count", p.Count, kMGPipeMaxVertexAttribs); } tail0 = TailBytesFor(p.Count, sizeof(MGPAttribValue)); tails = 1; @@ -768,7 +795,7 @@ namespace MobileGL::MG_Remote::Wire { // regions, so a non-zero count is a fault rather than a tail. const auto& p = *static_cast(payload); if (p.RegionCount != 0) { - WireProtocolFatalAt("BufferSubDataResident.RegionCount", p.RegionCount, 0); + return WireProtocolLatchAt("BufferSubDataResident.RegionCount", p.RegionCount, 0); } break; } @@ -785,12 +812,12 @@ namespace MobileGL::MG_Remote::Wire { // buffer by GL rule - and an indirect draw declares no ranges, because the server // never reads the indirect buffer to learn a count and the client has none to send. if (userIndices && indirect) { - WireProtocolFatal("DrawVbo.Flags", - "kDrawHasUserIndices and kDrawIsIndirect are exclusive; an " - "indirect draw's indices come from the bound element buffer"); + return WireProtocolLatch("DrawVbo.Flags", + "kDrawHasUserIndices and kDrawIsIndirect are exclusive; an " + "indirect draw's indices come from the bound element buffer"); } if (indirect && p.NumDraws != 0) { - WireProtocolFatalAt("DrawVbo.NumDraws", p.NumDraws, 0); + return WireProtocolLatchAt("DrawVbo.NumDraws", p.NumDraws, 0); } if (userIndices) { tail1 = sizeof(MGHostSpan); @@ -830,7 +857,7 @@ namespace MobileGL::MG_Remote::Wire { out.TailCount = tails; out.TotalBytes = Align8(out.TotalBytes); if (out.TotalBytes > kMaxRecordBytesOnTheWire) { - WireProtocolFatalAt("record.Size", out.TotalBytes, kMaxRecordBytesOnTheWire); + return WireProtocolLatchAt("record.Size", out.TotalBytes, kMaxRecordBytesOnTheWire); } return true; } @@ -1075,7 +1102,9 @@ namespace MobileGL::MG_Remote::Wire { MGHostSpan span{}; std::memcpy(&span, tails[1].Bytes, sizeof(span)); const auto& info = *static_cast(payload); - CheckDrawUserIndices(info, static_cast(tails[0].Bytes), span); + // Encoder side: the latch is armed only in a server session child, so here the + // arm dies on a bad shape and its answer carries nothing to act on (PH-1 (3)). + (void)CheckDrawUserIndices(info, static_cast(tails[0].Bytes), span); } const Uint64 total = layout.TotalBytes; @@ -1185,7 +1214,7 @@ namespace MobileGL::MG_Remote::Wire { for (Uint32 i = 0; i < slots.Count; ++i) { MGPBlobRef blob{}; std::memcpy(&blob, bytes + slots.Offset + i * sizeof(MGPBlobRef), sizeof(MGPBlobRef)); - CheckBlobIsHonest(op, blob, *m_segments); + (void)CheckBlobIsHonest(op, blob, *m_segments); // encoder: unarmed, dies (PH-1 (3)) // THE ENCODER MUST NOT ACCEPT A RECORD THE DECODER FATALS ON. w1's ruling that // CreateShaderState's modules travel inside the Reflection archive lived only // in the decoder, so an emitter that declared a per-stage run got a valid seq @@ -1210,7 +1239,7 @@ namespace MobileGL::MG_Remote::Wire { std::memcpy(&span, bytes + at + i * sizeof(MGHostSpan), sizeof(MGHostSpan)); // All four arms, including the one that needs the table: a producer that // computed an offset wrongly is caught here rather than on a peer. - CheckHostSpanIsHonest(span, *m_segments); + (void)CheckHostSpanIsHonest(span, *m_segments); // encoder: unarmed, dies (PH-1 (3)) } } @@ -1445,7 +1474,7 @@ namespace MobileGL::MG_Remote::Wire { return t_activeDecoder->ApplyChecked(op, record, size); } - void PipeWireDecoder::NoteResolvedRun(MGPWireOp op, const MGPBlobRef& blob) { + Bool PipeWireDecoder::NoteResolvedRun(MGPWireOp op, const MGPBlobRef& blob) { // UNREACHABLE NOW, AND LOUD RATHER THAN SILENT. This used to `return`, and that made // the audit's bookkeeping quietly optional: a record naming a non-SEG_STAGE carrier // was applied AND recorded nothing, so PoisonedStageBytes() stayed zero and rule C's @@ -1455,7 +1484,7 @@ namespace MobileGL::MG_Remote::Wire { // the audit has stopped covering the carrier, which is the same failure as no audit at // all - the reason the run-count overflow just below is a Fatal too. if (blob.Size == 0 || blob.Seg != kSegStage) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} the audit was asked to record a " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} the audit was asked to record a " "resolved run with seg=%u size=%llu; only declared SEG_STAGE(%u) runs " "reach the poison fill (R-2.5)", WireOpName(op), static_cast(blob.Seg), @@ -1466,24 +1495,28 @@ namespace MobileGL::MG_Remote::Wire { // LOUD, NOT A SILENT DROP. This array is what the 0xDD fill covers, and a poison // that quietly stopped covering a run is the same failure as no poison at all - // rule C's only mechanical control going dark without a line in the log. - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} more than %llu SEG_STAGE runs in " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} more than %llu SEG_STAGE runs in " "one record; the audit fill would stop covering them", WireOpName(op), static_cast(sizeof(m_resolved) / sizeof(m_resolved[0]))); } m_resolved[m_resolvedCount++] = blob; + return true; } + // PH-1 (3): nullptr ONLY when a named fault latched (an armed session child); every caller + // returns false on it. Unarmed, each arm below still dies by name and this never returns null. const void* PipeWireDecoder::ResolveOrFatal(MGPWireOp op, const MGPBlobRef& blob) { - RequireDeclaredBlob(op, blob, *m_segments); + if (!RequireDeclaredBlob(op, blob, *m_segments)) return nullptr; const void* bytes = m_segments->Resolve(blob.Seg, blob.Offset, blob.Size); if (bytes == nullptr) { // RequireDeclaredBlob already ran the same arithmetic, so reaching here means the // table moved under us rather than that the record is wrong. Same Fatal either // way: there is no recovery from a segment that stopped covering its own runs. - WireProtocolFatalAt("segment-resolve", blob.Offset, blob.Size); + (void)WireProtocolLatchAt("segment-resolve", blob.Offset, blob.Size); + return nullptr; } - NoteResolvedRun(op, blob); + if (!NoteResolvedRun(op, blob)) return nullptr; return bytes; } @@ -1513,6 +1546,36 @@ namespace MobileGL::MG_Remote::Wire { m_resolvedCount = 0; } + namespace { + // PH-1 (3): THE GENERATED GATE'S TWO PEER-REACHABLE DEATHS, ASKED FIRST AND BY NAME, in + // an armed session child only. MGP_WIRE_CHECK_BOUNDS (MG_Pipe/generated/PipeWire.inc) + // aborts on an opcode no row names and on a record shorter than its own type or not + // 8-aligned, through MGPipeWireProtocolFatal - a line with no `Fatal{` marker, in a file + // gen_pipe.py owns (fatal_census.py's FUNNEL_SITES records that debt). Both operands are + // the ring header's `kind` and `size`, which the peer writes, so a session child asks the + // same two questions here first and a failure latches by name; whatever reaches the + // generated gate then passes it by construction (MGPW_REC_ROW pins the sizes equal). + // Unarmed - inproc, the unit cases - this admits everything and the generated gate keeps + // its old death line for line. + Bool AdmitsTheGeneratedGate(MGPWireOp op, Uint64 size) { + if (!SessionLatchArmed()) return true; + const SizeT index = static_cast(op); + if (index == 0 || index >= static_cast(MGPWireOp::kOpCount)) { + return WireProtocolLatchAt("opcode", static_cast(op), + static_cast(MGPWireOp::kOpCount)); + } + WireRecordLayout minimum{}; + (void)MGPipeWireRecordLayout(op, nullptr, minimum); + // Its own word, not the layout's "record.Size": the two refusals are different + // questions (shorter than the type vs. past the wire's 32-bit Size), and a log line + // - and PeerLatchTest's first-Fatal check - has to be able to tell them apart. + if (size < minimum.TotalBytes || (size % 8) != 0) { + return WireProtocolLatchAt("record.Minimum", size, minimum.TotalBytes); + } + return true; + } + } // namespace + Bool PipeWireDecoder::DecodeAndApply(const Transport::RingRecordView& record) { if (!Valid()) { WireProtocolFatal("PipeWireDecoder::DecodeAndApply", "no control page or segment table"); @@ -1533,15 +1596,18 @@ namespace MobileGL::MG_Remote::Wire { m_resolvedCount = 0; m_lastAcceptanceKnown = false; - PipeWireDecoder* previous = t_activeDecoder; - t_activeDecoder = this; - // The generated gate first, ALWAYS: MGPipeApplyWireRecord owns the per-opcode - // `size >= sizeof(MGPWireRec_X)` check, because that is the half that follows from the - // opcode alone and therefore belongs to the generator. It then calls back into - // ApplyChecked through the hook, which owns the half that needs the payload. The hook - // was installed once, at construction. - const Bool applied = MG_Pipe::MGPipeApplyWireRecord(op, base, size, size); - t_activeDecoder = previous; + Bool applied = false; + if (AdmitsTheGeneratedGate(op, size)) { + PipeWireDecoder* previous = t_activeDecoder; + t_activeDecoder = this; + // The generated gate first, ALWAYS: MGPipeApplyWireRecord owns the per-opcode + // `size >= sizeof(MGPWireRec_X)` check, because that is the half that follows from the + // opcode alone and therefore belongs to the generator. It then calls back into + // ApplyChecked through the hook, which owns the half that needs the payload. The hook + // was installed once, at construction. + applied = MG_Pipe::MGPipeApplyWireRecord(op, base, size, size); + t_activeDecoder = previous; + } PoisonResolvedRuns(); @@ -1576,16 +1642,24 @@ namespace MobileGL::MG_Remote::Wire { const auto* base = static_cast(record); const void* payload = base + sizeof(MGPWireRecHeader); + // PH-1 (3), ID-P7-1: FROM HERE ON EVERY NAMED FAULT ON A PEER'S BYTES LATCHES. In an armed + // session child each arm below logs its unchanged `Fatal{ProtocolCorruption, "..."}` line + // and returns false, and so does this function: the record is declined, DrainRing sees + // the latch before the next one, and the session closes by name. Unarmed (inproc, unit + // cases) every arm still dies exactly as it did. WireRecordLayout layout{}; - if (!MGPipeWireRecordLayout(op, payload, layout)) { - WireProtocolFatalAt("opcode", static_cast(op), - static_cast(MGPWireOp::kOpCount)); + const SizeT opIndex = static_cast(op); + if (opIndex == 0 || opIndex >= static_cast(MGPWireOp::kOpCount)) { + return WireProtocolLatchAt("opcode", static_cast(op), + static_cast(MGPWireOp::kOpCount)); } + // With the opcode known, false here means a count bound in the layout latched. + if (!MGPipeWireRecordLayout(op, payload, layout)) return false; // THE TAIL CROSS-CHECK (BRIEF §5 w1). The generated gate proved `size >= sizeof(the // record type)`; it CANNOT SEE THE TAIL, so this is the first thing that holds a // record declaring Count = 4000 while carrying 8 bytes to its own arithmetic. if (size != layout.TotalBytes) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} the record declares Size=%llu and " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"%s\"} the record declares Size=%llu and " "its own count fields describe %llu bytes (fixed payload %llu + tails " "%llu/%llu/%llu)", WireOpName(op), static_cast(size), @@ -1656,7 +1730,7 @@ namespace MobileGL::MG_Remote::Wire { base + sizeof(MGPWireRecHeader) + offsetof(MGPCaps, FormatCapabilities) + i * sizeof(MGPBlobRef), sizeof(MGPBlobRef)); - CheckBlobIsHonest(op, blob, *m_segments); + if (!CheckBlobIsHonest(op, blob, *m_segments)) return false; } return false; @@ -1781,13 +1855,14 @@ namespace MobileGL::MG_Remote::Wire { // chunk table computes it from the mask the record itself carries. const Uint64 expected = static_cast(MGPipePipelineChunkBlobBytes(desc.ChunkMask)); - RequireDeclaredBlob(op, desc.Blob, *m_segments); + if (!RequireDeclaredBlob(op, desc.Blob, *m_segments)) return false; if (desc.Blob.Size != expected) { - WireProtocolFatalAt("CreateRenderState.Blob", desc.Blob.Size, expected); + return WireProtocolLatchAt("CreateRenderState.Blob", desc.Blob.Size, expected); } chunks = ResolveOrFatal(op, desc.Blob); - } else { - CheckBlobIsHonest(op, desc.Blob, *m_segments); + if (chunks == nullptr) return false; + } else if (!CheckBlobIsHonest(op, desc.Blob, *m_segments)) { + return false; } MGPipeApplyCreateRenderState(desc, chunks); return true; @@ -1807,6 +1882,7 @@ namespace MobileGL::MG_Remote::Wire { // (PipeApply.cpp:1990-1999) and is the model every other row copies; the decoder // adds only what the applier cannot see, which is that the bytes exist at all. const void* blob = ResolveOrFatal(op, desc.Blob); + if (blob == nullptr) return false; MGPipeApplyCreateVertexElements(desc, blob); return true; } @@ -1825,12 +1901,13 @@ namespace MobileGL::MG_Remote::Wire { // and borderColorForm must survive BYTE FOR BYTE, because all three colour // representations are always numerically populated and it is the only thing that // says which one the backend must use (MGPipeTypes.h:425-428). - RequireDeclaredBlob(op, desc.Parameters, *m_segments); + if (!RequireDeclaredBlob(op, desc.Parameters, *m_segments)) return false; if (desc.Parameters.Size != sizeof(MobileGL::SamplerParameters)) { - WireProtocolFatalAt("CreateSamplerState.Parameters", desc.Parameters.Size, - sizeof(MobileGL::SamplerParameters)); + return WireProtocolLatchAt("CreateSamplerState.Parameters", desc.Parameters.Size, + sizeof(MobileGL::SamplerParameters)); } const void* bytes = ResolveOrFatal(op, desc.Parameters); + if (bytes == nullptr) return false; // Copied into a local rather than reinterpret_cast in place: the staged run is // 8-aligned by the ring, but SamplerParameters is a frontend type and this file // may not assume its alignment requirement is one the ring happens to satisfy. @@ -1868,9 +1945,9 @@ namespace MobileGL::MG_Remote::Wire { // archive - and then the archive has to stop carrying generatedSpirv, in the same // change, or the two disagree. for (Uint32 i = 0; i < 6; ++i) { - CheckBlobIsHonest(op, desc.Spirv[i], *m_segments); + if (!CheckBlobIsHonest(op, desc.Spirv[i], *m_segments)) return false; if (desc.Spirv[i].Size != 0) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"CreateShaderState.Spirv[%u]\"} " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"CreateShaderState.Spirv[%u]\"} " "declares %llu bytes; under split the modules travel inside the " "Reflection archive and the six per-stage runs stay undeclared", static_cast(i), @@ -1878,6 +1955,7 @@ namespace MobileGL::MG_Remote::Wire { } } const void* archiveBytes = ResolveOrFatal(op, desc.Reflection); + if (archiveBytes == nullptr) return false; // P5e (pg), gap G-A: DECODED ONTO THE HEAP AND HANDED TO THE RECORD, not onto this // stack. Before P5e these were two locals that died at the closing brace and the // applier kept only the descriptor, so every reflection question the program twin @@ -1893,10 +1971,13 @@ namespace MobileGL::MG_Remote::Wire { if (!MG_State::GLState::DecodeProgramArchive(static_cast(archiveBytes), static_cast(desc.Reflection.Size), *archive)) { - WireProtocolFatal("CreateShaderState.Reflection", - "DecodeProgramArchive refused the archive - a truncated " - "stream, a codec version mismatch, a struct-size mismatch, or " - "a stage list that does not match the modules it frames"); + // PH-5's ArchiveVector bound (ProgramArtifactsCodec.cpp) refuses here too: a + // vector count whose immediate allocation exceeds the archive's remaining bytes + // makes DecodeProgramArchive answer false. + return WireProtocolLatch("CreateShaderState.Reflection", + "DecodeProgramArchive refused the archive - a truncated " + "stream, a codec version mismatch, a struct-size mismatch, or " + "a stage list that does not match the modules it frames"); } MGPipeApplyCreateShaderState(desc, &archive->Link, &archive->Spirv, archive); return true; @@ -1917,13 +1998,14 @@ namespace MobileGL::MG_Remote::Wire { if (dyn.ChunkMask != 0) { const Uint64 expected = static_cast(MGPipeDynamicChunkBlobBytes(dyn.ChunkMask)); - RequireDeclaredBlob(op, dyn.Blob, *m_segments); + if (!RequireDeclaredBlob(op, dyn.Blob, *m_segments)) return false; if (dyn.Blob.Size != expected) { - WireProtocolFatalAt("SetDynamicState.Blob", dyn.Blob.Size, expected); + return WireProtocolLatchAt("SetDynamicState.Blob", dyn.Blob.Size, expected); } chunks = ResolveOrFatal(op, dyn.Blob); - } else { - CheckBlobIsHonest(op, dyn.Blob, *m_segments); + if (chunks == nullptr) return false; + } else if (!CheckBlobIsHonest(op, dyn.Blob, *m_segments)) { + return false; } MGPipeApplySetDynamicState(dyn, chunks); return true; @@ -1983,7 +2065,7 @@ namespace MobileGL::MG_Remote::Wire { // ALL FOUR ARMS, the segment-range one included: this row and DrawVbo are // the only two host-span carriers in the catalogue, and an out-of-segment // span used to pass every check here. - CheckHostSpanIsHonest(span, *m_segments); + if (!CheckHostSpanIsHonest(span, *m_segments)) return false; } } MGPipeApplySetShaderBuffers(*static_cast(payload), @@ -2029,7 +2111,7 @@ namespace MobileGL::MG_Remote::Wire { std::memcpy(&entry, reinterpret_cast(overrides) + i * sizeof(MGPProgramStorageOverride), sizeof(MGPProgramStorageOverride)); - CheckHostSpanIsHonest(entry.Name, *m_segments); + if (!CheckHostSpanIsHonest(entry.Name, *m_segments)) return false; // THE NUL IS THE CLIENT'S AND IS CHECKED, not assumed: the emitter stages // the name with its terminator (ProgramEmit.h), and a span whose last byte // is not 0 would make the applier's String(const char*) walk off the end of @@ -2039,9 +2121,9 @@ namespace MobileGL::MG_Remote::Wire { m_segments->Resolve(entry.Name.Seg, entry.Name.Offset, entry.Name.Size)); if (entry.Name.Size == 0 || name == nullptr || name[entry.Name.Size - 1] != '\0') { - WireProtocolFatal("SetProgramBindings.StorageOverrides.Name", - "an override name span is empty or is not " - "NUL-terminated; the applier copies it as a C string"); + return WireProtocolLatch("SetProgramBindings.StorageOverrides.Name", + "an override name span is empty or is not " + "NUL-terminated; the applier copies it as a C string"); } overrideNames.push_back(name); } @@ -2061,6 +2143,7 @@ namespace MobileGL::MG_Remote::Wire { // program's GlobalUboSize, which this record does not carry). Under rule A it // stops being inert for the first time, which is the whole point of arming Size. const void* bytes = ResolveOrFatal(op, rec.Blob); + if (bytes == nullptr) return false; MGPipeApplySetGlobalConstants(rec, bytes); return true; } @@ -2101,12 +2184,13 @@ namespace MobileGL::MG_Remote::Wire { // rather than ">=" is what makes a client built against an older block a loud // mismatch instead of a silently short read of CapabilityBits. const auto& rec = *static_cast(payload); - RequireDeclaredBlob(op, rec.Blob, *m_segments); + if (!RequireDeclaredBlob(op, rec.Blob, *m_segments)) return false; if (rec.Blob.Size != sizeof(ResidualValueBlock)) { - WireProtocolFatalAt("SetResidualValueState.Blob", rec.Blob.Size, - sizeof(ResidualValueBlock)); + return WireProtocolLatchAt("SetResidualValueState.Blob", rec.Blob.Size, + sizeof(ResidualValueBlock)); } const void* bytes = ResolveOrFatal(op, rec.Blob); + if (bytes == nullptr) return false; ResidualValueBlock block{}; std::memcpy(&block, bytes, sizeof(block)); MGPipeApplySetResidualValueState(block); @@ -2128,7 +2212,7 @@ namespace MobileGL::MG_Remote::Wire { Uint64(rec.UnionBox.X) + rec.UnionBox.W > rec.LevelWidth || Uint64(rec.UnionBox.Y) + rec.UnionBox.H > rec.LevelHeight || Uint64(rec.UnionBox.Z) + rec.UnionBox.D > rec.LevelDepth)) - WireProtocolFatal("ResourceSubData.LevelExtent", "invalid full image extent or dirty box"); + return WireProtocolLatch("ResourceSubData.LevelExtent", "invalid full image extent or dirty box"); // Rule A arms both halves. The buffer half already declared a real size in // monolith and is cross-checked at PipeApply.cpp:702; THE TEXTURE HALF DECLARED 0 // (TextureEmit.h:1265-1267, on the grounds that the byte count was "the server's @@ -2145,7 +2229,7 @@ namespace MobileGL::MG_Remote::Wire { const Bool boxIsWhole = rec.UnionBox.W != 0 && rec.UnionBox.H != 0 && rec.UnionBox.D != 0; if (!namesABuffer && !boxIsEmpty && !boxIsWhole) { - SessionFail(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ResourceSubData\"} the union box " + return SessionLatch(MGFatalFamily::ProtocolCorruption, "MGPipe: Fatal{ProtocolCorruption, \"ResourceSubData\"} the union box " "%ux%ux%u has a zero extent on some axes and not others; a box is " "either empty or whole", rec.UnionBox.W, rec.UnionBox.H, rec.UnionBox.D); @@ -2155,8 +2239,9 @@ namespace MobileGL::MG_Remote::Wire { const void* bytes = nullptr; if (carriesContent) { bytes = ResolveOrFatal(op, rec.Blob); - } else { - CheckBlobIsHonest(op, rec.Blob, *m_segments); + if (bytes == nullptr) return false; + } else if (!CheckBlobIsHonest(op, rec.Blob, *m_segments)) { + return false; } noteAcceptance(MGPipeApplyResourceSubData( rec, bytes, reinterpret_cast(tailAt(0)))); @@ -2168,10 +2253,12 @@ namespace MobileGL::MG_Remote::Wire { // kOptional is a CAPABILITY question under split, not a null-pointer one: the // client gates on kCapResidentSubData through the caps mirror before it emits // (R-8). By the time a record is here, the answer was already yes. - const void* bytes = MGPipeSubDataBufferSize(rec) != 0 ? ResolveOrFatal(op, rec.Blob) - : nullptr; - if (bytes == nullptr) { - CheckBlobIsHonest(op, rec.Blob, *m_segments); + const void* bytes = nullptr; + if (MGPipeSubDataBufferSize(rec) != 0) { + bytes = ResolveOrFatal(op, rec.Blob); + if (bytes == nullptr) return false; + } else if (!CheckBlobIsHonest(op, rec.Blob, *m_segments)) { + return false; } MGPipeApplyBufferSubDataResident(rec, bytes); return true; @@ -2241,16 +2328,17 @@ namespace MobileGL::MG_Remote::Wire { MGHostSpan span{}; if ((info.Flags & kDrawHasUserIndices) != 0) { if (layout.TailCount != 2 || layout.TailBytes[1] != sizeof(MGHostSpan)) { - WireProtocolFatalAt("DrawVbo.userIndices", layout.TailBytes[1], - sizeof(MGHostSpan)); + return WireProtocolLatchAt("DrawVbo.userIndices", layout.TailBytes[1], + sizeof(MGHostSpan)); } std::memcpy(&span, tailAt(1), sizeof(span)); // ALL FOUR ARMS. WireVerbSink's header promises OnDrawVbo "a DECODED, // VALIDATED argument list"; without the segment-range arm a span whose run // left SEG_STAGE reached the sink and that promise was false. P5b's d1 is what // arms this path (client-side index arrays staged whole, CONTRACT-P5B.md d1). - CheckHostSpanIsHonest(span, *m_segments); - CheckDrawUserIndices(info, reinterpret_cast(tailAt(0)), span); + if (!CheckHostSpanIsHonest(span, *m_segments)) return false; + if (!CheckDrawUserIndices(info, reinterpret_cast(tailAt(0)), span)) + return false; userIndices = &span; } // P5b d1: the indirect block, in the span's place. The layout already refused a @@ -2259,8 +2347,8 @@ namespace MobileGL::MG_Remote::Wire { MGPDrawIndirect indirectBlock{}; if ((info.Flags & kDrawIsIndirect) != 0) { if (layout.TailCount != 2 || layout.TailBytes[1] != sizeof(MGPDrawIndirect)) { - WireProtocolFatalAt("DrawVbo.indirect", layout.TailBytes[1], - sizeof(MGPDrawIndirect)); + return WireProtocolLatchAt("DrawVbo.indirect", layout.TailBytes[1], + sizeof(MGPDrawIndirect)); } std::memcpy(&indirectBlock, tailAt(1), sizeof(indirectBlock)); indirect = &indirectBlock; @@ -2326,17 +2414,18 @@ namespace MobileGL::MG_Remote::Wire { // (rule C). const auto& rec = *static_cast(payload); constexpr Uint64 kMaxBlockNameBytes = 4096; - RequireDeclaredBlob(op, rec.Name, *m_segments); + if (!RequireDeclaredBlob(op, rec.Name, *m_segments)) return false; if (rec.Name.Size > kMaxBlockNameBytes) { - WireProtocolFatalAt("SetStorageBlockBinding.Name", rec.Name.Size, kMaxBlockNameBytes); + return WireProtocolLatchAt("SetStorageBlockBinding.Name", rec.Name.Size, kMaxBlockNameBytes); } const void* bytes = ResolveOrFatal(op, rec.Name); + if (bytes == nullptr) return false; char name[kMaxBlockNameBytes + 1]; std::memcpy(name, bytes, static_cast(rec.Name.Size)); name[rec.Name.Size] = '\0'; if (name[rec.Name.Size - 1] != '\0') { - WireProtocolFatal("SetStorageBlockBinding.Name", - "the staged block name is not NUL-terminated; Size is strlen + 1"); + return WireProtocolLatch("SetStorageBlockBinding.Name", + "the staged block name is not NUL-terminated; Size is strlen + 1"); } return m_verbs != nullptr && m_verbs->OnSetStorageBlockBinding(rec, name); } @@ -2386,8 +2475,8 @@ namespace MobileGL::MG_Remote::Wire { case MGPWireOp::kInvalid: case MGPWireOp::kOpCount: default: - WireProtocolFatalAt("opcode", static_cast(op), - static_cast(MGPWireOp::kOpCount)); + return WireProtocolLatchAt("opcode", static_cast(op), + static_cast(MGPWireOp::kOpCount)); } } diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.h b/MobileGL/MG_Remote/Wire/PipeWireCodec.h index 1c961561..d327655d 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.h +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.h @@ -112,6 +112,16 @@ namespace MobileGL::MG_Remote::Wire { [[noreturn]] void WireProtocolFatal(const char* what, const char* detail); [[noreturn]] void WireProtocolFatalAt(const char* what, Uint64 got, Uint64 expected); + // PH-1 (3), ID-P7-1: THE SAME TWO LINES, THROUGH THE LATCH. Byte-identical to the two above + // (same `Fatal{ProtocolCorruption, ""}` wording), but through MG_Remote::SessionLatch: + // in the spawn / TCP session child the fault latches and these RETURN false, so the decode + // path declines the record and the session closes by name; everywhere else (inproc, the + // client, a unit case) SessionLatch is SessionFail and these do not return. Used ONLY on the + // decode path, where the bytes are the peer's - an encoder-side check is a local bug and + // keeps the [[noreturn]] spelling. + Bool WireProtocolLatch(const char* what, const char* detail); + Bool WireProtocolLatchAt(const char* what, Uint64 got, Uint64 expected); + // R-2.3 arms 1-4 over one record's blobref. Split only; a monolith emission is exempt by // construction because it never reaches this layer. // @@ -128,11 +138,15 @@ namespace MobileGL::MG_Remote::Wire { // destination range, GlobalUboSize, the stage mask), which this signature does not see. // RequireDeclaredBlob below is arm 2, and the decoder's per-op arm calls it exactly where // the payload says content is implied. - void CheckBlobIsHonest(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob, + // + // PH-1 (3): EVERY HONESTY ARM NOW ANSWERS. `true` = honest; `false` = a named fault latched + // (armed session child only - unarmed, the arm dies exactly as before and never returns + // false). A decode-path caller returns on false; an encoder-side caller may discard it. + Bool CheckBlobIsHonest(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob, const SegmentTable& segments); // R-2.3 arm 2: the record's other fields say it carries content, so the blob must be // declared. Fatal on an absent blob, then CheckBlobIsHonest on a present one. - void RequireDeclaredBlob(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob, + Bool RequireDeclaredBlob(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob, const SegmentTable& segments); // R-2.3 arms 1 and 3 for MGHostSpan. P5's reduced path should produce ZERO host spans // (kCapNeedsHostIndexBytes / kCapNeedsHostUboBytes are both 0 in P5, table 0), so this @@ -142,17 +156,18 @@ namespace MobileGL::MG_Remote::Wire { // run PAST THE END OF IT passes this function. Use the overload below on any path that has // a table; this one exists because c0 shipped the signature and other packages compile // against it. - void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span); + Bool CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span); // All four arms. Arm 4 is the one the signature above cannot express: a span is only // honest if its Offset+Size actually lies inside the segment it names, and the promise // WireVerbSink's header makes - that OnDrawVbo is handed a VALIDATED argument list - is // false without it. Latent in P5 (nothing emits a span) and armed at P8, which is exactly // when nobody will be reading this file. - void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span, const SegmentTable& segments); + Bool CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span, const SegmentTable& segments); // A legal segment run must also contain every index the draw will consume. Shared by // the encoder, decoder and sink so a direct sink call cannot bypass the extent gate. - void CheckDrawUserIndices(const MG_Pipe::MGPDrawInfo& info, + // PH-1 (3): answers like the arms above (false = latched). + Bool CheckDrawUserIndices(const MG_Pipe::MGPDrawInfo& info, const MG_Pipe::MGPDrawRange* ranges, const MG_Pipe::MGHostSpan& span); @@ -199,7 +214,9 @@ namespace MobileGL::MG_Remote::Wire { // `payload` must already be known to hold at least the op's payload struct - that is what // MGP_WIRE_CHECK_BOUNDS proves, and this function is only ever called after it. Returns // false for an opcode outside the catalogue; a count past its own GL bound is Fatal, - // because a decoder holding such a record has nothing safe left to do with it. + // because a decoder holding such a record has nothing safe left to do with it - and in an + // armed session child (PH-1 (3)) that Fatal latches instead and this returns false too, which + // is why the decoder asks the opcode question itself before calling. Bool MGPipeWireRecordLayout(MG_Pipe::MGPWireOp op, const void* payload, WireRecordLayout& out); // The catalogue's own spelling of an opcode, for a Fatal line. Out of range is "". @@ -722,8 +739,9 @@ namespace MobileGL::MG_Remote::Wire { private: Bool ApplyChecked(MG_Pipe::MGPWireOp op, const void* record, Uint64 size); + // nullptr only when the session latched a named fault (PH-1 (3)); unarmed it dies. const void* ResolveOrFatal(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob); - void NoteResolvedRun(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob); + Bool NoteResolvedRun(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob); void PoisonResolvedRuns(); // The ONLY way this class answers a record. Fatals if `op` carries no kReplySlot - // see LastAcceptanceKnown() for why that is a Fatal and not a log line. diff --git a/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp b/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp index 8a7e0838..39c6c156 100644 --- a/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp +++ b/MobileGL/MG_Test/Wire/ServerSpawnTest.cpp @@ -34,6 +34,7 @@ // the boundary before the data plane needs it. #include +#include #include #include #include @@ -290,12 +291,20 @@ TEST(ServerSpawnTest, ARawPeerMutatesARecordAfterTheClientEncoderAcceptedIt) { } } ASSERT_EQ(reaped, MOBILEGL_OK) << "server did not reject the mutated record promptly"; - EXPECT_EQ(exitCode, -SIGABRT) << "the malformed opcode did not reach the server decoder"; + // PH-1 (3): the opcode is the peer's byte, so the session child LATCHES by name (the + // decoder's pre-gate asks the generated gate's question first) and closes with + // kSessionLatchedExitCode instead of aborting in MGPipeWireProtocolFatal, whose line carries + // no `Fatal{` marker at all. Red-once: drop AdmitsTheGeneratedGate and this reads -SIGABRT and + // "protocol corruption applying " again. + EXPECT_EQ(exitCode, MobileGL::MG_Remote::kSessionLatchedExitCode) + << "the malformed opcode did not latch the session by name"; const std::string log = MobileGL::MG_Util::Debug::ReadRoleLogs(logBase.c_str()); - EXPECT_NE(log.find("protocol corruption applying"), std::string::npos) - << "the server process exited without the protocol corruption diagnostic"; - EXPECT_NE(log.find("unknown opcode"), std::string::npos) - << "the malformed opcode was not identified in the server log"; + EXPECT_NE(log.find("Fatal{ProtocolCorruption, \"opcode\"} got=65535"), std::string::npos) + << "the server process exited without the named opcode fault"; + EXPECT_NE(log.find("SessionLatch{ProtocolCorruption}"), std::string::npos) + << "the fault was not latched"; + EXPECT_EQ(log.find("protocol corruption applying"), std::string::npos) + << "the record reached the generated gate's unnamed death"; client.Stop(); } diff --git a/scripts/ci/fatal_census.py b/scripts/ci/fatal_census.py index 29507bb0..9d429adb 100644 --- a/scripts/ci/fatal_census.py +++ b/scripts/ci/fatal_census.py @@ -67,8 +67,9 @@ THE FOUR RULES, spelled out rather than inferred: 1. an abort's `Fatal{Word` marker is within kMarkerWindow lines above it, OR the abort is exempted by FUNNEL_FILES / FUNNEL_SITES; - 2. every WireLogFatal / SessionFail call carries a `Fatal{` in its format string - a funnel that - accepted an unmarked string would launder exactly what rule 1 refuses; + 2. every WireLogFatal / SessionFail / SessionLatch call carries a `Fatal{` in its format string - + a funnel that accepted an unmarked string would launder exactly what rule 1 refuses (PH-1 (3) + added SessionLatch, the per-session latch-or-die twin of SessionFail); 3. every family word found in the perimeter has a row in FatalFamilies.def. a6's finding was a 30-word vocabulary against a 7-value wire enum; the .def is the projection that bounds the divergence, and a word with no row projects onto nothing at all; @@ -173,6 +174,12 @@ WIRE_LOG_FATAL_CALL = re.compile(r"WireLogFatal\s*\(") # and it reported MG_Pipe's own seam - whose callers carry the word exactly as SessionFail's do - # for being a SessionFail call with no word in its DECLARATION. SESSION_FAIL_CALL = re.compile(r"(?