diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34b42ac1..c8afb67b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1111,6 +1111,9 @@ jobs: # and not a phase one - which is the whole reason both lanes exist. ctest --output-on-failure -L integration-spawn --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/spawn-baseline.xml" python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/spawn-baseline.xml" --require-spawn-ran + ctest --show-only=json-v1 -L '^integration-tcp$' > "${RUNNER_TEMP}/tcp-discovery.json" + ctest --output-on-failure -L '^integration-tcp$' --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/tcp-baseline.xml" + python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/tcp-baseline.xml" --require-tcp-ran --require-run-ahead --discovery "${RUNNER_TEMP}/tcp-discovery.json" # Both full transport runs are hard gates. The per-case comparison below # remains diagnostic because explicit mechanism controls retain their own mode. @@ -2940,6 +2943,9 @@ jobs: - name: The MGPipe generators' checks can still fail run: python3 scripts/gen_pipe.py --self-test + - name: The link seam owns physical storage and keeps link dispatch out of callers + run: python3 scripts/ci/link_seam_purity.py --self-test + # The same question for the symbol tool the P1 gate is written in terms of. - name: The symbol report's buckets and gates can still fail run: python3 scripts/symbol_report.py --self-test @@ -3015,7 +3021,10 @@ jobs: # feat/disaggregated trigger at the top of this file. It costs a couple of seconds and needs # no build. - name: The split and retrace negative controls still reject a red that is not theirs (R-16) - run: bash scripts/ci/control_smoke_test.sh + run: | + bash scripts/ci/control_smoke_test.sh + python3 scripts/ci/test_trace_infrastructure.py + python3 scripts/ci/tcp_lane_tools_test.py # A GATE as of P3a (G5). "pool 与延迟释放原样搬" (ROADMAP.md:19) is meant literally: the # buffer pool, the deferred-release drain and the three persistently mapped rings move diff --git a/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp b/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp index 2275a89d..4ffd3030 100644 --- a/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp +++ b/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp @@ -120,7 +120,12 @@ MOBILEGL_GL_API void glFinish() { MobileGL::MG_Pipe::MGPipeClientFinish(); #endif } -MOBILEGL_GL_API void glFlush() { MGLOG_D("Implementing function: %s(...)", __FUNCTION__); } +MOBILEGL_GL_API void glFlush() { + MGLOG_D("Implementing function: %s(...)", __FUNCTION__); +#if MOBILEGL_BUILD_DISAGGREGATED + MobileGL::MG_Pipe::MGPipeClientFlush(); +#endif +} DECLARE_GL_FUNCTION_HEAD(void, FramebufferRenderbuffer, GLenum target, GLenum attachment, GLenum renderbuffertarget, GLuint renderbuffer) DECLARE_GL_FUNCTION_END_NO_RETURN(void, FramebufferRenderbuffer, target, attachment, renderbuffertarget, renderbuffer) DECLARE_GL_FUNCTION_HEAD(void, FramebufferTexture2D, GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level) DECLARE_GL_FUNCTION_END_NO_RETURN(void, FramebufferTexture2D, target, attachment, textarget, texture, level) DECLARE_GL_FUNCTION_HEAD(void, FrontFace, GLenum mode) DECLARE_GL_FUNCTION_END_NO_RETURN(void, FrontFace, mode) diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp index 033d6980..a78c4887 100644 --- a/MobileGL/MG_Impl/Pipe/PipeFill.cpp +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -1251,6 +1251,14 @@ namespace MobileGL::MG_Pipe { // P5e (ra), CONTRACT-P5E §2.5. Declared in PipeMutation.h; see there for why it is not a // ClientSession call at the GL entry point. + void MGPipeClientFlush() { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + if (MG_Remote::Client::RunsAsTheServerRole()) return; + if (MG_Remote::Client::ClientSession* session = MG_Remote::Client::ClientSession::Active()) { + session->Flush(); + } + } + void MGPipeClientFinish() { if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; if (MG_Remote::Client::RunsAsTheServerRole()) return; diff --git a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp index 6c957e26..c0cb0f29 100644 --- a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp +++ b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp @@ -11,6 +11,8 @@ #if !defined(__ANDROID__) #include #include +#include +#include #define MGITEST_PERSISTENT_MAP_PEEK_LIVE 1 #endif @@ -27,6 +29,19 @@ namespace MGITest { + bool PeekSeparateClientMapStats(unsigned long long* acquisitions, unsigned long long* pushedBytes) { +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) && MOBILEGL_BUILD_DISAGGREGATED && MOBILEGL_PIPE_PUSH + if (MobileGL::MG_Config::Transport != MobileGL::MG_Config::TransportMode::Spawn) return false; + namespace Stats = MobileGL::MG_Util::PipeStats; + *acquisitions = Stats::TotalCalls(Stats::CallClass::MapPersistentRoundtrips); + *pushedBytes = Stats::TotalBytes(Stats::ByteClass::PersistentMapPush); + return true; +#else + (void)acquisitions; (void)pushedBytes; + return false; +#endif + } + bool PersistentMapPeekAvailable() { #if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) return true; diff --git a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h index 374401a0..942ac03e 100644 --- a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h +++ b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h @@ -58,4 +58,8 @@ namespace MGITest { // Same contract as above: false means "could not look". bool PeekBufferIsLivePersistentMap(unsigned int bufferName, bool* outLive); + // Separate-process clients own these counters; the remote Present window + // belongs to the server and cannot report client map acquisitions or bytes. + bool PeekSeparateClientMapStats(unsigned long long* acquisitions, unsigned long long* pushedBytes); + } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h index 24582fdb..488f20d7 100644 --- a/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h +++ b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h @@ -174,7 +174,7 @@ namespace MGITest::PipeStatsWindow { // "the stats channel never reached the process". Concatenated, Last() takes whichever role // emitted the final summary. inline Window LastFromLaneLog() { - return Last(ReadWholeFile(LibraryLogPath()) + ReadWholeFile(ServerLibraryLogPath())); + return Last(ReadLaneLog()); } // One counter out of that line, by its short name ("mpr", "draws", "csom"), or -1 when the diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitLogPaths.cmake.in b/MobileGL/MG_IntegrationTest/Harness/SplitLogPaths.cmake.in index 1ab35245..a95fe114 100644 --- a/MobileGL/MG_IntegrationTest/Harness/SplitLogPaths.cmake.in +++ b/MobileGL/MG_IntegrationTest/Harness/SplitLogPaths.cmake.in @@ -18,6 +18,15 @@ foreach(listName IN ITEMS @MGL_SPLIT_ARM_TEST_LISTS@) foreach(entry IN LISTS ${listName}) set_tests_properties("${entry}" PROPERTIES ENVIRONMENT "MOBILEGL_LOG_FILE_PATH=@CMAKE_CURRENT_BINARY_DIR@/split-logs/${entry}.log") + if(entry MATCHES "TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame$") + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MGITEST_STRICT_ARMING_LANE=1;MOBILEGL_PIPE_STATS=1;MOBILEGL_PIPE_STATS_PERIOD=1") + elseif(entry MATCHES "PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares$") + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MGITEST_PMAP_LANE=1;MGITEST_PERSISTENT_MAP_ARM=emulated;MOBILEGL_PIPE_STATS=1;MOBILEGL_PIPE_STATS_PERIOD=1") + elseif(entry MATCHES "CtWireScenario\\.") + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT "MOBILEGL_TEST_SERVER_COUNTERS=1") + endif() endforeach() endforeach() foreach(entry IN LISTS MGL_SPLIT_PA_STRICT_DRAW_TESTS MGL_SPLIT_PA_STRICT_DISPATCH_TESTS) @@ -28,6 +37,13 @@ foreach(scenario @MGL_SPLIT_SMALL_RING_SCENARIOS@) foreach(entry IN LISTS MGL_SPLIT_SMALL_${scenario}_TESTS) set_tests_properties("${entry}" PROPERTIES ENVIRONMENT "MOBILEGL_LOG_FILE_PATH=@CMAKE_CURRENT_BINARY_DIR@/split-logs/${entry}.log") + if(entry MATCHES "TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame$") + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MGITEST_STRICT_ARMING_LANE=1;MOBILEGL_PIPE_STATS=1;MOBILEGL_PIPE_STATS_PERIOD=1") + elseif(entry MATCHES "PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares$") + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MGITEST_PMAP_LANE=1;MGITEST_PERSISTENT_MAP_ARM=emulated;MOBILEGL_PIPE_STATS=1;MOBILEGL_PIPE_STATS_PERIOD=1") + endif() endforeach() endforeach() # d1's entries are covered by MGL_SPLIT_ARM_TESTS above (P6 `t6`). diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp index 52761613..4bf1f13f 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp @@ -55,24 +55,30 @@ namespace { return MobileGL::MG_Remote::Server::ServerSessionInstance().Applier().Verbs(); } - // P6 `t6`: THE COUNTERS BELOW LIVE IN THE SERVER'S PROCESS, and under spawn that is not - // this one. - // - // ServerVerbs() reads PipeApplier's in-process tallies. Under inproc the applier is a thread - // here and the numbers are this process's own; under spawn it is a thread in a DIFFERENT - // process and this process's copy stays 0 forever - so these cases would fail asserting a - // fact about somebody else's memory, which is a harness limit and not a product defect. - // - // SKIPPED BY NAME RATHER THAN EXCLUDED FROM THE LANE. Exit gate 9.2 asks that - // integration-spawn's case-name SET equal integration-split's, and a case dropped from the - // registration would make the two sets differ silently - the exact drift the shared - // registration macro exists to prevent. A skip keeps the name, states the reason, and is - // counted as a skip rather than as coverage. - // - // WHAT WOULD RETIRE IT: cross-process telemetry, which is also what `t6`'s S8 needs for - // SessionFaultCount() over a whole run. Until that exists this is the honest answer. - bool ServerCountersAreObservableHere() { - return MobileGL::MG_Config::Transport != MobileGL::MG_Config::TransportMode::Spawn; + // Inproc reads its local sink. A separate-process session snapshots that + // same sink on LogFlush, after the client fences its complete command prefix. + // TCP forwards the server log; unix+shm reads the server's local role file. + struct CounterSnapshot { + bool valid = false; + MobileGL::Uint64 resets = 0, serial = 0, deaths = 0; + }; + + CounterSnapshot ServerCounters() { + if (MobileGL::MG_Config::Transport != MobileGL::MG_Config::TransportMode::Spawn) { + return {true, ServerVerbs().ApplierResets(), ServerVerbs().ExpectedApplierResetSerial(), + ServerVerbs().ObjectDeaths()}; + } + MGPipeSyncPeerLog(); + const std::string log = PipeStatsWindow::ReadWholeFile(PipeStatsWindow::ServerLibraryLogPath()); + const auto at = log.rfind("MGPipe server counters:"); + if (at == std::string::npos) return {}; + const PipeStatsWindow::Window window{true, log.substr(at, log.find('\n', at) - at)}; + const auto resets = PipeStatsWindow::CounterOrAbsent(window, "resets"); + const auto serial = PipeStatsWindow::CounterOrAbsent(window, "reset-serial"); + const auto deaths = PipeStatsWindow::CounterOrAbsent(window, "deaths"); + if (resets < 0 || serial < 0 || deaths < 0) return {}; + return {true, static_cast(resets), static_cast(serial), + static_cast(deaths)}; } class CtWireScenario : public ScenarioTest { @@ -116,24 +122,20 @@ namespace { TEST_F(CtWireScenario, ApplierResetCrossesAtThePrimedEdgeInSerialOrder) { if (!Ready()) return; - if (!ServerCountersAreObservableHere()) { - GTEST_SKIP() << "transport=spawn: the applier's counters are in the SERVER's process " - "and this one's copy is 0 by construction. The record still crosses - " - "the retrace lane is what proves that - but this case asserts on a " - "tally only an in-process applier can publish."; - } // The first verb of the process primes the tracker; the edge is the producer. The // clear also gives the case its pixels, so a lane that emitted nothing is red twice // (here and in the fixture's emit-ordinal rule). glClearColor(0.2f, 0.4f, 0.6f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); - EXPECT_GE(ServerVerbs().ApplierResets(), 1u) + const auto counters = ServerCounters(); + ASSERT_TRUE(counters.valid) << "server control-record telemetry missing"; + EXPECT_GE(counters.resets, 1u) << "the FreshlyPrimed edge emitted no applier_reset; the server's g_applier was " "never reset for this session"; // The serial sequence is the session's own count (§1: asserted, never dispatched // on): every record the sink accepted carried the serial it expected, or the counter // and the accepted tally would disagree. - EXPECT_EQ(ServerVerbs().ExpectedApplierResetSerial(), ServerVerbs().ApplierResets()) + EXPECT_EQ(counters.serial, counters.resets) << "an applier_reset record was dropped or replayed on the wire"; GLubyte pixel[4]{}; @@ -169,12 +171,9 @@ namespace { // The object dies unbound so the destructor - and the death record - fire at the // delete, and the tally is read AFTER the EmitAndWait the delete blocked on. glBindTexture(GL_TEXTURE_2D, 0); - if (!ServerCountersAreObservableHere()) { - GTEST_SKIP() << "transport=spawn: ObjectDeaths is the SERVER process's tally and this " - "process's copy never moves. The death record itself crosses; what " - "cannot cross today is the counter that would prove it here."; - } - const MobileGL::Uint64 deathsBefore = ServerVerbs().ObjectDeaths(); + const auto beforeCounters = ServerCounters(); + ASSERT_TRUE(beforeCounters.valid) << "server object-death telemetry missing"; + const MobileGL::Uint64 deathsBefore = beforeCounters.deaths; glDeleteTextures(1, &texture); // THE FENCE, the framebuffer case's exactly (MOBILEGL_IPC_BATCH_WAITS, default on): // object_death is a kCtxObject value-class record, published WITHOUT waiting for its @@ -183,7 +182,9 @@ namespace { // clock-free spin changed that timing, then failed 1 in 5. A clear is kCtxVerb and // still waits, and its wait covers the death. glClear(GL_COLOR_BUFFER_BIT); - EXPECT_GT(ServerVerbs().ObjectDeaths(), deathsBefore) + const auto afterCounters = ServerCounters(); + ASSERT_TRUE(afterCounters.valid) << "server object-death telemetry missing"; + EXPECT_GT(afterCounters.deaths, deathsBefore) << "the texture's death produced no object_death record; the server's twin was " "never told to let go"; @@ -230,12 +231,9 @@ namespace { // Die unbound, framebuffer first so the attachment's own death is a separate record. glBindFramebuffer(GL_FRAMEBUFFER, 0); glBindRenderbuffer(GL_RENDERBUFFER, 0); - if (!ServerCountersAreObservableHere()) { - GTEST_SKIP() << "transport=spawn: ObjectDeaths is the SERVER process's tally and this " - "process's copy never moves. The death record itself crosses; what " - "cannot cross today is the counter that would prove it here."; - } - const MobileGL::Uint64 deathsBefore = ServerVerbs().ObjectDeaths(); + const auto beforeCounters = ServerCounters(); + ASSERT_TRUE(beforeCounters.valid) << "server object-death telemetry missing"; + const MobileGL::Uint64 deathsBefore = beforeCounters.deaths; glDeleteFramebuffers(1, &fbo); // THE FENCE (MOBILEGL_IPC_BATCH_WAITS, default on): object_death is a kCtxObject // value-class record - published WITHOUT waiting for its own apply (production-safe: @@ -243,7 +241,9 @@ namespace { // server-side counter this assertion reads only moves at apply time, so it needs a // wait boundary: a clear is kCtxVerb and still waits, and its wait covers the death. glClear(GL_COLOR_BUFFER_BIT); - EXPECT_GT(ServerVerbs().ObjectDeaths(), deathsBefore) + const auto afterCounters = ServerCounters(); + ASSERT_TRUE(afterCounters.valid) << "server object-death telemetry missing"; + EXPECT_GT(afterCounters.deaths, deathsBefore) << "the framebuffer's death produced no object_death record - and no other " "opcode can carry it"; glDeleteRenderbuffers(1, &renderbuffer); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.cpp index b0f79fb0..99ea9578 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/PersistentCoherentMapScenario.cpp @@ -466,6 +466,8 @@ void main() { oColor = vec4(vColor, 1.0); } } Gl().EndFrame(); // close the setup window; SetUp's own store and map go in it + unsigned long long acquisitionsBefore = 0, pushedBefore = 0; + const bool separateClientStats = PeekSeparateClientMapStats(&acquisitionsBefore, &pushedBefore); // One acquisition inside the counted window: one fresh immutable store, mapped once. const CoherentStore store = MakeCoherentlyMappedQuadStore(); @@ -500,7 +502,16 @@ void main() { oColor = vec4(vColor, 1.0); } "not reach the process, or nothing reached PipeStats::OnPresent."; RecordProperty("stats_line", window.line.c_str()); - const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr"); + unsigned long long acquisitionsAfter = 0, pushedAfter = 0; + if (separateClientStats) { + ASSERT_TRUE(PeekSeparateClientMapStats(&acquisitionsAfter, &pushedAfter)); + ASSERT_GE(acquisitionsAfter, acquisitionsBefore); + ASSERT_GE(pushedAfter, pushedBefore); + RecordProperty("stats_counter_owner", "client process totals over the counted workload"); + } + const long long roundtrips = separateClientStats + ? static_cast(acquisitionsAfter - acquisitionsBefore) + : PipeStatsWindow::CounterOrAbsent(window, "mpr"); ASSERT_GE(roundtrips, 0) << "the summary line carries no mpr= field: " << window.line; EXPECT_EQ(roundtrips, kExpectedRoundtrips) << "one PERSISTENT|WRITE|COHERENT map of one freshly defined store is ONE acquisition " @@ -523,7 +534,9 @@ void main() { oColor = vec4(vColor, 1.0); } // cross-check. Deriving the arm from pmap would have made "the push was never wired" and // "the store was adopted" the same reading, and they are different defects with different // owners. - const double pushedBytes = PipeStatsWindow::CounterAsDoubleOrAbsent(window, "pmap"); + const double pushedBytes = separateClientStats + ? static_cast(pushedAfter - pushedBefore) + : PipeStatsWindow::CounterAsDoubleOrAbsent(window, "pmap"); ASSERT_GE(pushedBytes, 0.0) << "the summary line carries no pmap= field: " << window.line; RecordProperty("persistent_map_push_bytes_per_frame", std::to_string(pushedBytes).c_str()); RecordProperty("map_persistent_roundtrips", std::to_string(roundtrips).c_str()); diff --git a/MobileGL/MG_Pipe/PipeMutation.h b/MobileGL/MG_Pipe/PipeMutation.h index c9f6fb25..b46b8a46 100644 --- a/MobileGL/MG_Pipe/PipeMutation.h +++ b/MobileGL/MG_Pipe/PipeMutation.h @@ -263,6 +263,8 @@ namespace MobileGL::MG_Pipe { // monolith transport, and on a lockstep one (there the client waited out every command it // issued before it could reach the call). void MGPipeClientFinish(); + // Submit a stream link's published prefix; never wait for appliedSeq. + void MGPipeClientFlush(); #endif void MGPipeEmitResourceCreate(MG_State::GLState::BufferObject& buffer); diff --git a/MobileGL/MG_Pipe/PipeRoute.h b/MobileGL/MG_Pipe/PipeRoute.h index 42d186e8..5044a126 100644 --- a/MobileGL/MG_Pipe/PipeRoute.h +++ b/MobileGL/MG_Pipe/PipeRoute.h @@ -321,6 +321,10 @@ namespace MobileGL::MG_Pipe { MGPReplySlot reply = MGPipeMintReplySlot(); MGP_ResourceSubData(&record, bytes, byteCount, regions, record.RegionCount, &reply); const Bool accepted = MGPipeTakeReplyBool(reply, "resource_subdata"); +#if MOBILEGL_BUILD_DISAGGREGATED + // A cancelled wire upload is declined even when no server reply was requested. + if (MGPipeInstalledArm() == MGPipeRouteArm::kClientWire) return accepted; +#endif return MGPipeSubDataWantsItsReply(record) ? accepted : true; } inline void MGPipeRouteBufferSubDataResident(const MGPSubData& record, const void* bytes, diff --git a/MobileGL/MG_Remote/CONTRACT-P65.md b/MobileGL/MG_Remote/CONTRACT-P65.md new file mode 100644 index 00000000..29d191b4 --- /dev/null +++ b/MobileGL/MG_Remote/CONTRACT-P65.md @@ -0,0 +1,60 @@ +# P6.5 第一波:TCP 控制与 stream 数据 + +2026-09-22。依据 `docs/Disaggregated/ROADMAP.md` 的「立即的下一项」。本文件记录实现所需的协议细节;验收结果单独记入阶段报告,不能用本文件代替运行证据。 + +## 范围与配置 + +本波实现 POSIX client/server 的全 TCP 连接;主验收形态是 WSL x86_64 client 与 Redmi aarch64 server。保留 inproc 与 fork/UNIX 的共享段路径。Windows client、TCP 控制加共享段、压缩、内容去重、异步 reply、真实窗口均不在本波。 + +`MOBILEGL_TRANSPORT=spawn` 表示两进程拓扑。`MOBILEGL_IPC_CONTROL` 选择 `fork`(默认)、`unix:` 或 `tcp://host:port`,`MOBILEGL_IPC_DATA=auto|shm|stream` 选择数据面。本波 TCP 只接受 stream,fork/UNIX 只接受 shm;不支持的显式组合具名拒绝。Connect 不启动本地子进程,实际 server pid 来自 Welcome。 + +TCP 两条连接依次为控制和数据。每条均设置 NODELAY、KEEPALIVE,Linux/Android 另设置 KEEPIDLE=2、KEEPINTVL=1、KEEPCNT=3、USER_TIMEOUT=5000。网络错误和 EOF 才触发 peer-hung-up;apply 超时不是 device loss。无 `MOBILEGL_IPC_TOKEN` 的 listener 仅能绑定 loopback;错令牌返回 Refuse,不打印令牌值。 + +## 握手与布局 + +Hello/Welcome 追加 `wireFingerprint`、`LinkTerms`,Hello 带 dial 与 token,Welcome 带实际 backend。原 union 标签不重排;Refuse 与 LogFlush 追加。Refuse 是正常的拒绝结果,不能改成 ABI mismatch abort。 + +wire 指纹覆盖 PipeFields 逐成员名称/offset/size、按序 opcode/payload/flags/WaitClass、记录头、句柄、render-state 字段表、blob codec 版本、协议版本、字节序、指针宽度与 caps/dynamic-parameters 布局。build stamp 单独比较:Fork 必须相同,Connect 不同只警告,`MOBILEGL_IPC_REQUIRE_SAME_BUILD=1` 则拒绝。窗口尺寸由 server 陈述,不进入布局指纹。DynamicBackendParameters 使用定宽整数;没有借机改 GLFunctionsTable ABI。 + +启动必须收到并采用真实的首个 CapsSnapshot,之后才能锁存 run-ahead 并宣布 session started。 +后台控制 reader 尚未把 caps 放入 inbox,不能解释成 server 不支持能力;也不能先用 placeholder +永久锁成 lockstep,再期待后来的快照自动提升。后续快照仍只允许 demotion,缺少首 caps 是有界启动失败。 + +program archive 在 split 构建使用 v2,头中校验真实序列化 schema 摘要,且版本和摘要进入握手指纹。v1 的宿主对象大小 echo 在 Linux 是 1056,在 Android 是 0,不能用于跨端兼容判定;libstdc++ 与 libc++ 的字符串/容器布局不相同,即使两端都是 LP64。payload 按字段序列化,schema 递归覆盖字段名、次序和类型,不包含容器宿主 sizeof/offsetof。宿主大小编译绊线保留为本地维护检查;非 split 路径保留 v1,G1 不因此让步。 + +## 数据面与生命周期 + +ILink 拥有数据面存储与端点。ShmLink 保留映射、reply slot pool 和现有 ring 实现;StreamLink 拥有私有镜像。session 只缓存端点/进度指针;reserve/pop 仍使用原有非虚 ring 操作。测试兼容的原始 ring 构造入口和启动时的 descriptor 交付不作为生产链路选择入口。 + +数据帧含 MGLD magic、长度、kind、两个 64-bit 字段及 payload。Stage 在引用它的 Cmd 之前发送;Cmd/Event 按逻辑 cursor 严格顺序接收并检查容量。接收线程先写数据,再 release 发布 head/水位。每侧数据连接有一个独立 reader;发送在调用线程串行进行,不引入无界发送队列。 + +进度保留单调、`>=`、只能晚不能早的规则。Progress 必须包含 **cmdRetiredTail**:FreeBytes 依赖退役尾,只有 applied tail 不能回收 borrowed record,也不能保证环回绕。event credit 以 ClientProgress 返回。越窗、非法方向、倒退水位是 ProtocolCorruption。 + +server 在 park 前、Reply 之后、present/completion 变化时冲刷进度,并按 64 条记录/1 ms 合并普通进度。client 每个等待点先 Flush。Stream reply 使用 seq 标识的消息邮箱;已经观察到 applied 却没有相应 reply 必须拒绝,不能永久等待。ShmLink 保留旧 slot 语义。 + +Present 与 `glFlush` 也是 client 的发送边界:将已经发布的批次交给对端,保持 run-ahead,不新增 apply 等待。单靠下一次 credit wait 冲刷,会让最后一帧在 client 随后空闲时永久留在本地,credit=3 时还会把前三帧积在发送端。 + +已经丢失连接或在 stage/cmd 空间等待中观察到真实挂断时,编码器取消本次及后续发射:不复制新 blob、不增加 wire seq,返回 DECLINED,并保留本地 reply ticket 的合法性。只因超时不能闩 device loss;stage 空间等待使用与 barrier 一致的 120 秒上限,让传输层有时间报告半开连接的失败。 + +两条 TCP 连接之间没有到达顺序保证。因此 surface RPC 前先等待此前提交的命令;SurfaceReply 另携带 **eventHead**。server 冲刷事件后发回复,client 等数据 reader 收到该 head 后才完成 RPC。仅按两个 send 的先后推断 SurfaceChanged 已到达是错误的。 + +## 控制、日志和进程 + +client 的专用控制 reader 直接落盘 LogLine,其他控制消息进入有界 inbox,避免 GL 线程等数据时 server 因日志 socket 写满而停住。server 同时写本地日志并前送;client 写自己的 `.server.log`。`MGPipeSyncPeerLog()` 先同步命令,再以 LogFlush/ack 对齐日志观察点。正常的无远端情形是 no-op,损坏的 ack 不能静默成功。 + +supervisor 接受连接后 fork,每会话独立进程,父进程不创建 backend/EGL;同时只允许一会话,第二个返回 Busy。父进程关闭自己的 socket 副本时 **不调用 shutdown**,否则会关闭 child 仍使用的连接。后端由 Hello 请求,显式 server 环境钉住的后端不匹配则拒绝。server 子进程保留 P6 的 PipeStats Init/Shutdown。 + +正常 client 结束请求时只先关闭控制 socket 的写半边,等 server 完成 backend 销毁、最终日志和进程退出后的 EOF,再完成本地关闭。否则连续的独立 client 会抢到前一会话仍在清理的时间窗。Busy 拒绝在读取 Hello 后发送,避免带未读请求直接 close 引发 TCP RST,丢掉已经排队的 Refuse。 + +trace flavour 的前台 MobileGLServerService 从 nativeLibraryDir 启动可执行 server,设置服务端角色与反递归标志,持 wake lock。`am force-stop` 终止该包的服务进程。 + +## 带宽和测量口径 + +第一波不启用压缩或内容去重。P6 gate 8 的 rd12 stage 中位/均值是 816,248 / 1,534,555 B/帧,60 fps 对应约 49 / 92 MB/s;OpenRA 短样本均值明显受加载尖峰影响。该数据已经说明 Wi-Fi 可能成为瓶颈,但没有压缩率、额外 CPU 或纹理重复率证据,不能据此选定压缩算法,也不能宣称已满足 60 fps。 + +`MOBILEGL_PIPE_STATS=1` 下,P65LinkMetrics 记录逐帧 reply 次数、RTT 固定桶、stage bytes、wall time 与 client thread CPU。p50/p99 为桶上界;回复的测量包含必要的发送、排队与 apply,不能冒充空载网络 ping。Stage 字节/帧除以 wall time 是负载流量;链路吞吐另用真实 TCP 64 MiB 突发测量。与 PipeStats 的同源字节计数是独立观测,不能相加。 + +## 验收中的两处澄清 + +- 车道 parity 比较 DirectGLES 的可比用例集合,而不是不同标签下的总条数;当前基线是 102 条。TCP 每条还需真实 `control=tcp data=stream server=... pid=N` 证明与本地零子进程断言。fixture 不算用例通过数,禁止用 EGL 启动失败的 SKIP 代替通过。 +- lf 文档列出的四个 arming 场景中,UnlocatedIoBlock、PrimitivesGeneratedNoXfb、PointSizeDemotion 的 marker 来自 server;关闭日志前送应让对应断言变红。PipeVerifyArming 的 marker 来自 **client PipeFill**,关闭 server 前送本来就不应使它变红;它应继续通过,作为角色分离对照。不得伪造一个 server marker 来满足错误的负控预期。 diff --git a/MobileGL/MG_Remote/CapsCodec.cpp b/MobileGL/MG_Remote/CapsCodec.cpp index df958267..c1763569 100644 --- a/MobileGL/MG_Remote/CapsCodec.cpp +++ b/MobileGL/MG_Remote/CapsCodec.cpp @@ -36,6 +36,7 @@ #include "Protocol/mg_protocol_base.h" #include +#include #include #include @@ -472,6 +473,8 @@ namespace MobileGL::MG_Remote { inputs.FormatCapabilityFormats = MG_Backend::kFormatCapabilityFormatCount; inputs.FormatCapabilitiesCodecVersion = kFormatCapabilitiesCodecVersion; inputs.RendererInfoCodecVersion = kRendererInfoCodecVersion; + inputs.ProgramArtifactsCodecVersion = MG_State::GLState::kProgramArtifactsCodecVersion; + inputs.ProgramArtifactsSchema = MG_State::GLState::ProgramArtifactsSchemaFingerprint(); inputs.OpCount = static_cast(MG_Pipe::MGPWireOp::kOpCount); inputs.AbiVersion = MOBILEGL_ABI_VERSION(MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR); inputs.PointerBits = sizeof(void*) * 8; diff --git a/MobileGL/MG_Remote/Client/ClientSession.cpp b/MobileGL/MG_Remote/Client/ClientSession.cpp index 8636078d..144b65e3 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.cpp +++ b/MobileGL/MG_Remote/Client/ClientSession.cpp @@ -196,9 +196,9 @@ namespace MobileGL::MG_Remote::Client { // the queue's work is not the caller's budget being spent. const auto wait = producer.WaitForAppliedOrEventBacklog(seq, chunkMs); if (wait == Transport::SessionWait::Reached) { - Transport::RingControl* control = producer.Control(); - if (control == nullptr || - Transport::Watermark::Reached(control->Progress.appliedSeq, seq)) { + const auto* progress = producer.Progress(); + if (progress == nullptr || + Transport::Watermark::Reached(progress->appliedSeq, seq)) { return wait; } DrainEventRing(events); @@ -1025,8 +1025,20 @@ namespace MobileGL::MG_Remote::Client { } void ClientSession::SyncPeerLog() { - if (!m_started || !m_controlInbox || !m_transport || DeviceLost()) return; - WaitForApplyToCatchUp("SyncPeerLog"); + if (!m_started || !m_socketTransport || !m_transport || DeviceLost()) return; + const auto submitted = m_producer.LastPublishedSeq(); + m_producer.PublishAndNotify(submitted); + const auto wait = WaitForAppliedBudget(m_producer, *m_events, submitted, kRemoteControlTimeoutMs); + if (wait == Transport::SessionWait::ShutDown) { + if (const auto* bell = m_producer.SelfDoorbell(); bell && bell->PeerHungUp()) + LatchDeviceLost("log synchronization woke on a peer that had hung up"); + return; + } + if (wait != Transport::SessionWait::Reached) { + SessionFail(MGFatalFamily::BarrierTimeout, + "MGPipe: Fatal{BarrierTimeout, LogFlush} apply prefix was not completed"); + } + DrainEventRing(*m_events); const std::lock_guard lock(m_remoteControlMutex); const auto seq = ++m_logFlushSeq; flatbuffers::FlatBufferBuilder builder(64); @@ -1254,21 +1266,48 @@ namespace MobileGL::MG_Remote::Client { m_encoder = Wire::PipeWireEncoder(m_link.get(), &m_segments); m_encoder.SetLink(m_link->Capabilities().PublishIsDelivery ? nullptr : m_link.get()); m_encoder.SetStageRetirementDoorbell(m_producer.SelfDoorbell()); + m_encoder.SetStageWaitTimeoutMs(kBarrierTimeoutMs); + m_encoder.SetCancellationState(&m_deviceLost, +[](void* self) { + auto& session = *static_cast(self); + auto* bell = session.m_producer.SelfDoorbell(); + if (bell && bell->PeerHungUp()) { + session.LatchDeviceLost("staging observed a peer that had hung up"); + } + return session.m_deviceLost.load(std::memory_order_acquire) || (bell && bell->Dead()); + }, this); + // P5e (ra, §2.6): the stage bell is a client wait and every client wait drains. The // encoder owns the park; the session owns SEG_EVENT, so the session lends it a drain. m_encoder.SetStageWaitHook( +[](void* self) { DrainEventRing(static_cast(self)->Events()); }, this); - // ---- 7. the first CapsSnapshot, if the server had a backend to publish one from. - // ONE DRAIN, ONE ADOPTER (c1): PumpControlPlane below is the only thing in the client - // that turns a CapsSnapshot into a CapsMirror generation, so R-12's "a second arrival - // IS the invalidation" lives in exactly one place. s1's step here used to stop at - // "the snapshot arrived and is verifiable"; it now goes all the way, through the same - // function every later arrival goes through. - if (PumpControlPlane() == 0) { - MGLOG_W("MG_Remote client: the handshake carried no CapsSnapshot - the caps mirror is " - "a PLACEHOLDER until one arrives. Every caps read until then answers a " - "default and says so"); + // ---- 7. A real first snapshot belongs to startup, not to a later GL call. + // Inproc already queued it; an independent control reader may still be + // receiving it. Do not latch run-ahead against a placeholder: promotion + // after this first decision is deliberately forbidden. + { + std::vector initialCaps; + const auto received = ReceiveControlFrame(initialCaps, kHandshakeTimeoutMs); + if (received != MOBILEGL_OK) { + MGLOG_E("MG_Remote client: Refuse{InitialCapsSnapshot} no initial capabilities " + "within %u ms (rc=%d); the session was not started", + kHandshakeTimeoutMs, static_cast(received)); + Stop(); + return received; + } + const auto* envelope = ParseEnvelope(initialCaps); + if (envelope == nullptr || envelope->msg_type() != ::MobileGL::Wire::CtrlMsg::CapsSnapshot || + envelope->msg_as_CapsSnapshot() == nullptr || + !AdoptCapsSnapshot(envelope->msg_as_CapsSnapshot())) { + MGLOG_E("MG_Remote client: Refuse{InitialCapsSnapshot} first post-Welcome control " + "frame is not a valid capabilities snapshot; the session was not started"); + Stop(); + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + } + LatchRunAheadFromCaps(); + // A newer snapshot already queued may demote the initial decision. + // LogLine frames on TCP were filtered by ControlInbox before this read. + PumpControlPlane(); } // BOTH, AND m_started FIRST. `Active()` is what the integration lane's skip reads and @@ -1363,8 +1402,7 @@ namespace MobileGL::MG_Remote::Client { // gets right, and only then do the transports go. m_producer.Detach(); m_link = Transport::CreateSharedLink(Transport::TransportRoleTag::ClientProducer); - m_cmd = &m_link->CommandsOut(); m_events = &m_link->EventsIn(); - m_link->Memory().Close(); + m_cmd = &m_link->CommandsOut(); m_events = &m_link->EventsIn(); Server::ServerSessionInstance().Close(); m_clientTransport.reset(); m_serverTransport.reset(); @@ -1379,8 +1417,7 @@ namespace MobileGL::MG_Remote::Client { // // 1. publish and let the server drain. Bounded: a lost record must be a red lane, not // a hung exit. - Transport::RingControl* control = m_link->Memory().CmdControl(); - if (control != nullptr) { + if (m_link->Attached()) { // The PRODUCER's own last-published seq, not RingControl::submittedSeq. Ring.h:72-77 // permits submittedSeq to be published lazily and Ring.h:243 encourages batching // the publish, so the shared watermark is allowed to lag the emitter - and a drain @@ -1403,6 +1440,26 @@ namespace MobileGL::MG_Remote::Client { // kWaitForever (CondVarDoorbell::Kill): a Notify is consumed by one Park, after which // Doorbell::Wait re-tests a condition nothing published, finds the bell alive and // parks again, forever. InProcessChannel::Close kills both bells. + // Request EOF starts remote backend destruction. Keep receiving until + // peer EOF, so the next client cannot overtake that session's cleanup. + if (m_socketTransport && !m_deviceLost.load(std::memory_order_acquire) && + m_socketTransport->ShutdownSend() == MOBILEGL_OK) { + bool closed = false; + if (m_controlInbox) closed = m_controlInbox->WaitClosed(kDrainTimeoutMs); + else { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kDrainTimeoutMs); + for (;;) { + const auto left = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()).count(); + if (left <= 0) break; + std::vector finalFrame; + const auto result = ReceiveControlFrame(finalFrame, static_cast(left)); + if (result == MOBILEGL_ERR_TRANSPORT_CLOSED) { closed = true; break; } + if (result != MOBILEGL_OK) break; + } + } + if (!closed) MGLOG_W("MG_Remote client: remote teardown did not reach EOF within %u ms", kDrainTimeoutMs); + } if (m_transport != nullptr) { m_transport->Shutdown(); } @@ -1530,12 +1587,17 @@ namespace MobileGL::MG_Remote::Client { Wire::WireOpName(op)); } + const auto declineCancelled = [&] { + if (statusOut) *statusOut = Wire::ReplySink::kStatusDeclined; + return Wire::kInvalidSeq; + }; + if (DeviceLost() || m_encoder.Cancelled()) return declineCancelled(); const Bool rowCarriesReplySlot = (MG_Pipe::MGPipeCallFlagsFor(op) & static_cast(MG_Pipe::kReplySlot)) != 0; const Bool ownsReplySlot = rowCarriesReplySlot && wantReply; - const auto replyMetricStart = Transport::LinkMetricsBeginReply(ownsReplySlot); Uint64 seq = m_encoder.EncodeRecord(op, payload, payloadBytes, tails, tailCount); if (seq == Wire::kInvalidSeq) { + if (DeviceLost() || m_encoder.Cancelled()) return declineCancelled(); // EncodeRecord already refused individually oversized records. This one fits, // but appliedSeq may be ahead of retiredTail: wait for actual reclaimable space, // including the wrap pad. Prior EmitAndWait calls have published every record. @@ -1545,6 +1607,10 @@ namespace MobileGL::MG_Remote::Client { const Uint64 needed = layout.TotalBytes + (toEnd < layout.TotalBytes ? toEnd : 0); const BarrierWaitScope waiting; const auto wait = m_producer.WaitForCmdSpace(needed, kBarrierTimeoutMs); + if (wait == Transport::SessionWait::ShutDown) { + (void)m_encoder.CheckCancellation(); + return declineCancelled(); + } if (wait != Transport::SessionWait::Reached) { SessionFail(MGFatalFamily::RetirementWaitFailed, "MGPipe: Fatal{RetirementWaitFailed, \"SEG_CMD\"} - %s needs %llu " "reclaimable bytes; the retirement wait ended on %s", @@ -1559,6 +1625,7 @@ namespace MobileGL::MG_Remote::Client { DrainEventRing(*m_events); seq = m_encoder.EncodeRecord(op, payload, payloadBytes, tails, tailCount); if (seq == Wire::kInvalidSeq) { + if (DeviceLost() || m_encoder.Cancelled()) return declineCancelled(); SessionFail(MGFatalFamily::RingOverrun, "MGPipe: Fatal{RingOverrun, \"SEG_CMD\"} - %s refused after sufficient " "space retired", Wire::WireOpName(op)); } @@ -1567,8 +1634,14 @@ namespace MobileGL::MG_Remote::Client { // Publish the head, record submittedSeq, THEN ring - in that order, which is // SessionProducer's one job and RingTest.cpp:446's pin. Notify-then-publish loses the // wakeup. + const auto replyMetricStart = Transport::LinkMetricsBeginReply(ownsReplySlot); m_producer.PublishAndNotify(seq); - if (op == MG_Pipe::MGPWireOp::Present) Transport::LinkMetricsPresent(); + if (op == MG_Pipe::MGPWireOp::Present) { + // A credit is permission to run ahead, not permission to retain the + // last frame locally until another frame or a wait happens to arrive. + Flush(); + Transport::LinkMetricsPresent(); + } // Does this row own a reply slot? kMGPipeCallFlags IS THE SINGLE SOURCE OF TRUTH // (R-16 / ID-31) - fourteen rows now, because the four Bool acceptance entry points @@ -1871,6 +1944,18 @@ namespace MobileGL::MG_Remote::Client { #endif } + void ClientSession::Flush() { + if (!m_started || !m_link) return; + const auto result = m_link->Flush(); + if (result == MOBILEGL_ERR_TRANSPORT_CLOSED) { + if (m_link->PeerHungUp()) LatchDeviceLost("published commands could not reach the disconnected peer"); + } else if (result != MOBILEGL_OK) { + SessionFail(MGFatalFamily::ProtocolCorruption, + "MGPipe: Fatal{ProtocolCorruption, link flush} published commands were not delivered (rc=%d)", + static_cast(result)); + } + } + void ClientSession::Finish() { // glFinish. Nothing goes on the wire - Flush and Finish have no record (ARCHITECTURE // §11) - and under lockstep there was nothing to do, because the client had already @@ -1909,9 +1994,9 @@ namespace MobileGL::MG_Remote::Client { // swapping, and this waiter holds the only drain. wait = m_producer.WaitForPresentAckOrEventBacklog(awaited, kBarrierTimeoutMs); if (wait != Transport::SessionWait::Reached) break; - Transport::RingControl* control = m_producer.Control(); - if (control == nullptr || - Transport::Watermark::Reached(control->Progress.presentAckSerial, awaited)) { + const auto* progress = m_producer.Progress(); + if (progress == nullptr || + Transport::Watermark::Reached(progress->presentAckSerial, awaited)) { break; } DrainEventRing(*m_events); diff --git a/MobileGL/MG_Remote/Client/ClientSession.h b/MobileGL/MG_Remote/Client/ClientSession.h index 177c39d5..899b627c 100644 --- a/MobileGL/MG_Remote/Client/ClientSession.h +++ b/MobileGL/MG_Remote/Client/ClientSession.h @@ -245,6 +245,9 @@ namespace MobileGL::MG_Remote::Client { // true while a run-ahead queue still holds them, so under run-ahead glFinish becomes // exactly WaitForApplyToCatchUp + the drain that comes with it. void Finish(); + // Submit published commands without waiting for application. This is + // glFlush's contract and the frame-submit boundary for Present. + void Flush(); // The last seq this session published, whether or not it waited for it. The forced // waits above are defined against it. diff --git a/MobileGL/MG_Remote/Client/WireTables.cpp b/MobileGL/MG_Remote/Client/WireTables.cpp index 73563868..fa153d65 100644 --- a/MobileGL/MG_Remote/Client/WireTables.cpp +++ b/MobileGL/MG_Remote/Client/WireTables.cpp @@ -44,6 +44,7 @@ // is legal under rule E (CONTRACT-P5C.md §5.2). #include #include +#include #include // P5e (pg): ID-87 asks this package for bytes-per-link measured rather than argued, and the // archive's byte count exists exactly once - here, where it is serialised. @@ -297,7 +298,8 @@ namespace MobileGL::MG_Remote::Client { const Uint64 seq = session.EmitAndWait(MGPWireOp::ResourceReadback, payload, sizeof(*payload), nullptr, 0, nullptr, 0, &status); - reply->Id = seq; + // Cancellation has no wire ordinal; retain the caller's nonzero local ticket. + if (seq != Wire::kInvalidSeq) reply->Id = seq; MG_Pipe::MGPipePostReply(*reply, status, 0); ++g_emitted; } @@ -317,7 +319,8 @@ namespace MobileGL::MG_Remote::Client { const Uint64 seq = session.EmitAndWait(MGPWireOp::ResourceCreate, payload, sizeof(*payload), nullptr, 0, nullptr, 0, &status); - reply->Id = seq; + // Cancellation has no wire ordinal; retain the caller's nonzero local ticket. + if (seq != Wire::kInvalidSeq) reply->Id = seq; MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u); ++g_emitted; if (status == 1) ++g_declined; @@ -330,7 +333,8 @@ namespace MobileGL::MG_Remote::Client { const Uint64 seq = session.EmitAndWait(MGPWireOp::SetTextureParams, payload, sizeof(*payload), nullptr, 0, nullptr, 0, &status); - reply->Id = seq; + // Cancellation has no wire ordinal; retain the caller's nonzero local ticket. + if (seq != Wire::kInvalidSeq) reply->Id = seq; MG_Pipe::MGPipePostReply(*reply, status, status == 0 ? 1u : 0u); ++g_emitted; if (status == 1) ++g_declined; @@ -369,11 +373,14 @@ namespace MobileGL::MG_Remote::Client { MGPWireOp::ResourceSubData, &record, sizeof(record), varTail != nullptr ? &tail : nullptr, varTail != nullptr ? 1u : 0u, nullptr, 0, &status, nullptr, wantReply); - reply->Id = seq; - MG_Pipe::MGPipePostReply(*reply, wantReply ? status : 0, - (wantReply ? status == 0 : true) ? 1u : 0u); - ++g_emitted; - if (wantReply && status == 1) ++g_declined; + // Cancellation has no wire ordinal; retain the caller's nonzero local ticket. + if (seq != Wire::kInvalidSeq) reply->Id = seq; + const Int32 postedStatus = (seq == Wire::kInvalidSeq || status == Wire::ReplySink::kStatusDeclined) + ? Wire::ReplySink::kStatusDeclined + : wantReply ? status : Wire::ReplySink::kStatusOk; + MG_Pipe::MGPipePostReply(*reply, postedStatus, postedStatus == Wire::ReplySink::kStatusOk ? 1u : 0u); + if (seq != Wire::kInvalidSeq) ++g_emitted; + if (postedStatus == Wire::ReplySink::kStatusDeclined) ++g_declined; } void Wire_BufferSubDataResident(const MG_Pipe::MGPSubData* payload, const void* blobBytes, @@ -712,9 +719,30 @@ namespace MobileGL::MG_Remote::Client { } } + namespace { + void WireStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + (void)EmitObjectDeathRecord(kind, lifetimeId); + } + + const MG_State::GLState::StateObjectDeathOps kClientStateObjectDeathOps = { + .OnDestroyed = &WireStateObjectDestroyed, + }; + } + void InstallClientWireTables() { using namespace MG_Pipe; + // DirectGLES normally installs this notice while creating its handle + // backend. An independent client never creates that backend: without a + // client emitter, NotifyAndFree silently drops object_death before + // freeing the slot. Keep inproc's existing backend dispatcher, and give + // the remote client the same wire delivery without a local twin table. + if (MG_Config::Transport == MG_Config::TransportMode::Spawn && + MG_Config::ActiveBackendType == BackendType::DirectGLES && + MG_State::GLState::GetStateObjectDeathOps() == nullptr) { + MG_State::GLState::SetStateObjectDeathOps(&kClientStateObjectDeathOps); + } + // A fresh install means the routed tables are live again: a Start after a previous // session's Stop clears the teardown-refusal flag so its own routed calls are not // refused. (Stop already puts the monolith adapters back and clears the flag; this is @@ -778,6 +806,9 @@ namespace MobileGL::MG_Remote::Client { // is complete, by ReinstallMonolithAfterTeardown, for the at-exit deletes that reach a // process with no session at all. Idempotent: safe to call when nothing was installed. g_clientTablesUninstalled.store(true, std::memory_order_release); + if (MG_State::GLState::GetStateObjectDeathOps() == &kClientStateObjectDeathOps) { + MG_State::GLState::SetStateObjectDeathOps(nullptr); + } } void ReinstallMonolithAfterTeardown() { diff --git a/MobileGL/MG_Remote/Server/ServerMain.cpp b/MobileGL/MG_Remote/Server/ServerMain.cpp index c541ad08..d1db31ed 100644 --- a/MobileGL/MG_Remote/Server/ServerMain.cpp +++ b/MobileGL/MG_Remote/Server/ServerMain.cpp @@ -4,6 +4,7 @@ #include "../Transport/SocketTransport.h" #include "../Transport/WireLog.h" #include "../Protocol/SurfaceOpCodec.h" +#include "../Handshake.h" #include "ServerLoop.h" #include "ServerSession.h" #include "SurfaceControlFrame.h" @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +48,17 @@ void ForwardLog(void* user, const char* text) { (void)transport.SendFrame({builder.GetBufferPointer(), builder.GetSize()}); } +void RefuseBusy(SocketTransport& transport, const char* detail) { + // Unread Hello bytes can make close send RST and discard our Refuse. + std::uint64_t bytes = 0; + if (transport.ReceiveFrame({nullptr, 0}, &bytes, 2000) == MOBILEGL_ERR_BUFFER_TOO_SMALL && + bytes <= 1024 * 1024) { + std::vector hello(static_cast(bytes)); + (void)transport.ReceiveFrame({hello.data(), hello.size()}, &bytes, 0); + } + Refuse(transport, Protocol::RefuseCode::Busy, detail); +} + struct FlushAck { ITransport* transport; std::uint64_t seq; }; void SendLogAck(void* pointer) { auto& ack = *static_cast(pointer); @@ -70,6 +83,10 @@ void SendLogAck(void* pointer) { if (!Protocol::VerifyCtrlEnvelopeBuffer(verifier)) ::_exit(67); const auto* hello = Protocol::GetCtrlEnvelope(helloFrame.data())->msg_as_Hello(); if (!hello) ::_exit(67); + // Reject incompatible wire before backend bring-up can obscure the cause. + if (ValidatePeerHandshake(*control, hello->abiMajor(), hello->abiMinor(), + hello->wireFingerprint(), hello->buildFingerprint() ? hello->buildFingerprint()->c_str() : nullptr, + hello->dialMode()) != MOBILEGL_OK) ::_exit(0); if (tcp) { const char* expected = std::getenv("MOBILEGL_IPC_TOKEN"); const std::string token = hello->token() ? hello->token()->str() : std::string(); @@ -178,6 +195,19 @@ void SendLogAck(void* pointer) { const auto* envelope = Protocol::GetCtrlEnvelope(buffer.data()); if (const auto* flush = envelope->msg_as_LogFlush()) { if (flush->ack()) break; + if (const char* inspect = std::getenv("MOBILEGL_TEST_SERVER_COUNTERS"); + inspect && std::strcmp(inspect, "1") == 0) { + // SyncPeerLog has fenced the client's complete command prefix. + // Acquire the applier's publication before reading its counters; + // this serial client sends no more records until this ack. + const auto applied = session.DataLink()->Progress()->appliedSeq.load(std::memory_order_acquire); + const auto& verbs = session.Applier().Verbs(); + MGLOG_I("MGPipe server counters: applied=%llu resets=%llu reset-serial=%llu deaths=%llu", + static_cast(applied), + static_cast(verbs.ApplierResets()), + static_cast(verbs.ExpectedApplierResetSerial()), + static_cast(verbs.ObjectDeaths())); + } FlushAck ack{control.get(), flush->seq()}; MobileGL::MG_Util::Debug::WithLogBarrier(&SendLogAck, &ack); continue; @@ -197,13 +227,23 @@ void SendLogAck(void* pointer) { MobileGL::MG_Util::PipeStats::Shutdown(); session.Close(); MobileGL::MG_Util::Debug::SetLogForwarder(nullptr, nullptr); - control->Shutdown(); + // _exit closes control after cleanup/flush; peer EOF is the client's fence. std::fflush(nullptr); ::_exit(0); } } extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int argc, char** argv) { + // The supervisor and every forked session child log under the server role. + // Set it here rather than trusting the launcher to have done so: log role is + // resolved from this env at first use, and a launcher that omits it would + // silently misfile this process's main-thread server lines as the client + // role and never forward them. Done before any logging so the value is cached + // as server and inherited by the fork children. DIAL/scrub stay hard checks. + if (const char* role = std::getenv("MOBILEGL_IPC_ROLE"); + !role || std::strcmp(role, "server") != 0) { + ::setenv("MOBILEGL_IPC_ROLE", "server", 1); + } const char* dial = std::getenv("MOBILEGL_IPC_DIAL"); if (!dial || std::strcmp(dial, "no") != 0) { std::fprintf(stderr, "MG_Remote server: MOBILEGL_IPC_DIAL=no anti-recursion catch (a) missing\n"); @@ -217,15 +257,15 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a } std::string endpoint; bool serve = false; - unsigned maxSessions = 0; for (int i = 1; i < argc; ++i) { const std::string arg(argv[i]); if (arg == "--serve") serve = true; else if (arg == "--max-sessions" && i + 1 < argc) { char* end = nullptr; const auto count = std::strtoul(argv[++i], &end, 10); - if (*end || count == 0 || count > 1000000) return 71; - maxSessions = static_cast(count); + // This is a concurrency limit, not a cumulative session budget. + // P6.5 deliberately supports one active rendering session. + if (*end || count != 1) return 71; } else if (endpoint.empty() && arg.compare(0, 2, "--") != 0) endpoint = arg; else return 71; } @@ -235,10 +275,8 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a if (SocketTransport::Listen(endpoint, &listener) != MOBILEGL_OK) return 72; WireLogError("MG_Remote server: pid=%d listening on %s", static_cast(::getpid()), endpoint.c_str()); pid_t active = -1; - unsigned sessions = 0; for (;;) { if (active > 0 && ::waitpid(active, nullptr, WNOHANG) == active) active = -1; - if (maxSessions && sessions >= maxSessions && active < 0) break; std::unique_ptr control; const auto accepted = SocketTransport::AcceptPair(listener, serve ? 250 : 30000, control); if (accepted == MOBILEGL_ERR_TIMEOUT && serve) continue; @@ -249,16 +287,22 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a RunSession(std::move(control)); } if (active > 0 && ::waitpid(active, nullptr, WNOHANG) == active) active = -1; - if (active > 0 || (maxSessions && sessions >= maxSessions)) { - Refuse(*control, Protocol::RefuseCode::Busy, "one session is already active"); + // exit_group closes files just before waitpid can observe the exit. + // Allow that small scheduling window; a live peer remains Busy. + const auto reapDeadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(50); + while (active > 0 && std::chrono::steady_clock::now() < reapDeadline) { + if (::waitpid(active, nullptr, WNOHANG) == active) { active = -1; break; } + ::usleep(1000); + } + if (active > 0) { + RefuseBusy(*control, "one session is already active"); continue; } std::fflush(nullptr); const pid_t child = ::fork(); if (child == 0) { ::close(listener); RunSession(std::move(control)); } - if (child < 0) { Refuse(*control, Protocol::RefuseCode::Busy, "could not create session child"); continue; } + if (child < 0) { RefuseBusy(*control, "could not create session child"); continue; } active = child; - ++sessions; control->CloseLocalCopy(); } ::close(listener); diff --git a/MobileGL/MG_Remote/Server/ServerSession.cpp b/MobileGL/MG_Remote/Server/ServerSession.cpp index cd1869a9..7a8cc8b5 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.cpp +++ b/MobileGL/MG_Remote/Server/ServerSession.cpp @@ -14,6 +14,7 @@ #include "../CapsCodec.h" #include "../Handshake.h" +#include "../Transport/LinkMetrics.h" #include "../Protocol/generated/protocol_generated.h" #include "../Transport/InProcessTransport.h" @@ -853,6 +854,7 @@ namespace MobileGL::MG_Remote::Server { if (!m_accepted) { return; } + Transport::LinkMetricsServerPresent(serial); m_consumer.ReturnPresentCredit(serial); FlushDataProgress(); } diff --git a/MobileGL/MG_Remote/Transport/ControlInbox.cpp b/MobileGL/MG_Remote/Transport/ControlInbox.cpp index 39b9fcb5..7e597199 100644 --- a/MobileGL/MG_Remote/Transport/ControlInbox.cpp +++ b/MobileGL/MG_Remote/Transport/ControlInbox.cpp @@ -74,4 +74,17 @@ namespace MobileGL::MG_Remote::Transport { std::lock_guard lock(m_mutex); return m_frames.empty() ? 0 : m_frames.front().size(); } + bool ControlInbox::WaitClosed(std::uint32_t timeoutMs) { + std::unique_lock lock(m_mutex); + // Teardown has no outstanding control caller; discard queued snapshots + // so the reader can always reach EOF through its bounded inbox. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + for (;;) { + m_frames.clear(); + m_bytes = 0; + m_cv.notify_all(); + if (m_result != MOBILEGL_OK || m_stop) return true; + if (m_cv.wait_until(lock, deadline) == std::cv_status::timeout) return m_result != MOBILEGL_OK; + } + } } diff --git a/MobileGL/MG_Remote/Transport/ControlInbox.h b/MobileGL/MG_Remote/Transport/ControlInbox.h index d32a602b..437b51b8 100644 --- a/MobileGL/MG_Remote/Transport/ControlInbox.h +++ b/MobileGL/MG_Remote/Transport/ControlInbox.h @@ -17,6 +17,7 @@ namespace MobileGL::MG_Remote::Transport { void Stop(); MobileGLResult Receive(std::vector& frame, std::uint32_t timeoutMs); std::uint64_t Peek(); + bool WaitClosed(std::uint32_t timeoutMs); private: void Read(); ITransport& m_transport; diff --git a/MobileGL/MG_Remote/Transport/Framing.h b/MobileGL/MG_Remote/Transport/Framing.h index f05239c4..7de27b7f 100644 --- a/MobileGL/MG_Remote/Transport/Framing.h +++ b/MobileGL/MG_Remote/Transport/Framing.h @@ -79,6 +79,8 @@ namespace MobileGL::MG_Remote::Transport { // Incremental frame extractor over a raw byte stream. class FrameReader { public: + explicit FrameReader(std::uint32_t expectedMagic = kFrameMagic) : m_expectedMagic(expectedMagic) {} + // Feeds raw stream bytes. Validates the frame header the moment enough // bytes for one exist - a bad magic or an oversized length is reported // here, not swallowed. @@ -163,11 +165,11 @@ namespace MobileGL::MG_Remote::Transport { std::uint32_t length = 0; std::memcpy(&magic, m_buffer.data() + m_readPos, sizeof(magic)); std::memcpy(&length, m_buffer.data() + m_readPos + 4, sizeof(length)); - if (magic != kFrameMagic) { + if (magic != m_expectedMagic) { m_failed = true; WireLogError("MG_Remote framing: bad frame magic 0x%08X (expected 0x%08X); the " "control stream is desynchronized and this transport is now dead", - magic, kFrameMagic); + magic, m_expectedMagic); return MOBILEGL_ERR_PROTOCOL_MISMATCH; } if (length > kMaxFramePayloadSize) { @@ -203,6 +205,7 @@ namespace MobileGL::MG_Remote::Transport { std::uint64_t m_pendingSize = 0; bool m_haveHeader = false; bool m_failed = false; + std::uint32_t m_expectedMagic = kFrameMagic; }; } // namespace MobileGL::MG_Remote::Transport diff --git a/MobileGL/MG_Remote/Transport/LinkMetrics.cpp b/MobileGL/MG_Remote/Transport/LinkMetrics.cpp index 149ef29a..923643f3 100644 --- a/MobileGL/MG_Remote/Transport/LinkMetrics.cpp +++ b/MobileGL/MG_Remote/Transport/LinkMetrics.cpp @@ -104,4 +104,22 @@ void LinkMetricsEnd() { Emit("summary", metrics.total, ClockNs() - metrics.started, ThreadCpuNs() - metrics.cpuStarted); metrics.active = false; } +void LinkMetricsServerPresent(std::uint64_t serial) { + // Independent of the client latch: spawn servers never call LinkMetricsBegin. + // The baseline belongs to the apply thread, and is discarded if that thread is replaced. + static thread_local const bool enabled = [] { + const char* value = std::getenv("MOBILEGL_PIPE_STATS"); + return value != nullptr && std::strcmp(value, "1") == 0; + }(); + if (!enabled) return; + static thread_local std::uint64_t previousSerial = 0, previousWall = 0, previousCpu = 0; + const auto wall = ClockNs(), cpu = ThreadCpuNs(); + const bool valid = previousWall != 0 && serial > previousSerial && cpu >= previousCpu; + MGLOG_I("P65ServerMetrics frame=%llu valid=%u wall_ns=%llu apply_thread_cpu_ns=%llu", + static_cast(serial), valid ? 1u : 0u, + static_cast(valid ? wall - previousWall : 0), + static_cast(valid ? cpu - previousCpu : 0)); + previousSerial = serial; previousWall = wall; previousCpu = cpu; +} + } diff --git a/MobileGL/MG_Remote/Transport/LinkMetrics.h b/MobileGL/MG_Remote/Transport/LinkMetrics.h index fde142ea..a4087f45 100644 --- a/MobileGL/MG_Remote/Transport/LinkMetrics.h +++ b/MobileGL/MG_Remote/Transport/LinkMetrics.h @@ -10,4 +10,5 @@ namespace MobileGL::MG_Remote::Transport { void LinkMetricsReplyApplied(std::uint64_t startedNs); void LinkMetricsStageBytes(std::uint64_t bytes); void LinkMetricsPresent(); + void LinkMetricsServerPresent(std::uint64_t serial); } diff --git a/MobileGL/MG_Remote/Transport/Ring.cpp b/MobileGL/MG_Remote/Transport/Ring.cpp index 866ebd2e..c7aaa76f 100644 --- a/MobileGL/MG_Remote/Transport/Ring.cpp +++ b/MobileGL/MG_Remote/Transport/Ring.cpp @@ -693,6 +693,8 @@ namespace MobileGL::MG_Remote::Transport { mix(inputs.FormatCapabilityFormats); mix(inputs.FormatCapabilitiesCodecVersion); mix(inputs.RendererInfoCodecVersion); + mix(inputs.ProgramArtifactsCodecVersion); + mix(inputs.ProgramArtifactsSchema); mix(inputs.OpCount); mix(inputs.AbiVersion); mix(inputs.PointerBits); diff --git a/MobileGL/MG_Remote/Transport/SessionRings.h b/MobileGL/MG_Remote/Transport/SessionRings.h index 4dd1cc5c..68bf4db1 100644 --- a/MobileGL/MG_Remote/Transport/SessionRings.h +++ b/MobileGL/MG_Remote/Transport/SessionRings.h @@ -570,6 +570,8 @@ namespace MobileGL::MG_Remote::Transport { std::uint64_t FormatCapabilityFormats = 0; std::uint64_t FormatCapabilitiesCodecVersion = 0; std::uint64_t RendererInfoCodecVersion = 0; + std::uint64_t ProgramArtifactsCodecVersion = 0; + std::uint64_t ProgramArtifactsSchema = 0; std::uint64_t OpCount = 0; std::uint32_t AbiVersion = 0; std::uint32_t PointerBits = 0; diff --git a/MobileGL/MG_Remote/Transport/ShmSegment.cpp b/MobileGL/MG_Remote/Transport/ShmSegment.cpp index 61873491..bbebf9e0 100644 --- a/MobileGL/MG_Remote/Transport/ShmSegment.cpp +++ b/MobileGL/MG_Remote/Transport/ShmSegment.cpp @@ -319,7 +319,7 @@ namespace MobileGL::MG_Remote::Transport { } m_private[i] = ::operator new(static_cast(bytes[i]), std::align_val_t{alignof(RingControl)}, std::nothrow); - if (!m_private[i]) { Close(); return MOBILEGL_ERR_INVALID_ARGUMENT; } + if (!m_private[i]) { Close(); return MOBILEGL_ERR_OUT_OF_MEMORY; } m_privateSizes[i] = bytes[i]; std::memset(m_private[i], 0, static_cast(bytes[i])); m_mappedBytes += bytes[i]; diff --git a/MobileGL/MG_Remote/Transport/SocketTransport.cpp b/MobileGL/MG_Remote/Transport/SocketTransport.cpp index 11c46ec2..f1fbabe8 100644 --- a/MobileGL/MG_Remote/Transport/SocketTransport.cpp +++ b/MobileGL/MG_Remote/Transport/SocketTransport.cpp @@ -354,20 +354,28 @@ namespace MobileGL::MG_Remote::Transport { } int accepted[2] = {-1, -1}; for (int index = 0; index < 2; ++index) { - const int ready = WaitReadable(listenFd, index == 0 ? timeoutMs : 2000); - if (ready <= 0) { - for (int fd : accepted) { - if (fd >= 0) ::close(fd); + const std::uint32_t budget = index == 0 ? timeoutMs : 2000; + for (;;) { + const int ready = WaitReadable(listenFd, budget); + if (ready <= 0) { + for (int fd : accepted) { + if (fd >= 0) ::close(fd); + } + // Half a client is not a client. Both connections or neither, so + // a peer that died between the two cannot leave a session that + // looks up but has no way to receive a descriptor. + if (index != 0 || ready < 0) WireLogError("MG_Remote SocketTransport: only %d of 2 connections arrived within " + "%u ms", index, timeoutMs); + return ready == 0 ? MOBILEGL_ERR_TIMEOUT : MOBILEGL_ERR_TRANSPORT_CLOSED; } - // Half a client is not a client. Both connections or neither, so - // a peer that died between the two cannot leave a session that - // looks up but has no way to receive a descriptor. - if (index != 0 || ready < 0) WireLogError("MG_Remote SocketTransport: only %d of 2 connections arrived within " - "%u ms", index, timeoutMs); - return ready == 0 ? MOBILEGL_ERR_TIMEOUT : MOBILEGL_ERR_TRANSPORT_CLOSED; - } - accepted[index] = ::accept(listenFd, nullptr, nullptr); - if (accepted[index] < 0) { + accepted[index] = ::accept(listenFd, nullptr, nullptr); + if (accepted[index] >= 0) break; + // EINTR (this process reaps SIGCHLD by construction) and ECONNABORTED + // (the peer reset between poll() and accept()) are not transport + // failures: the listener is still good. Re-poll within the budget + // rather than returning TRANSPORT_CLOSED, which the --serve + // supervisor turns into a full server exit (return 73). + if (errno == EINTR || errno == ECONNABORTED) continue; for (int fd : accepted) { if (fd >= 0) ::close(fd); } @@ -456,6 +464,12 @@ namespace MobileGL::MG_Remote::Transport { m_closed = true; } + MobileGLResult SocketTransport::ShutdownSend() { + std::lock_guard lock(m_sendMutex); + if (m_streamFd < 0 || m_closed) return MOBILEGL_ERR_TRANSPORT_CLOSED; + return ::shutdown(m_streamFd, SHUT_WR) == 0 ? MOBILEGL_OK : MOBILEGL_ERR_TRANSPORT_CLOSED; + } + SocketTransport::~SocketTransport() { Shutdown(); } MobileGLResult SocketTransport::SendFrame(MobileGLByteSpan bytes) { @@ -548,9 +562,9 @@ namespace MobileGL::MG_Remote::Transport { return MOBILEGL_OK; } if (n == 0) { - // Clean EOF. The peer is gone - but anything already reassembled - // stays readable, which ITransport promises in as many words. - m_closed = true; + // Request EOF can be a half-close: drain queued frames and keep + // the reverse direction usable for teardown diagnostics. + m_readClosed = true; return MOBILEGL_ERR_TRANSPORT_CLOSED; } if (errno == EINTR) { @@ -589,7 +603,7 @@ namespace MobileGL::MG_Remote::Transport { if (m_reader.HasMessage()) { return m_reader.TakeMessage(buffer, outSize); } - if (m_closed) { + if (m_closed || m_readClosed) { return MOBILEGL_ERR_TRANSPORT_CLOSED; } @@ -609,7 +623,7 @@ namespace MobileGL::MG_Remote::Transport { if (m_failed) { return 0; } - if (!m_reader.HasMessage() && !m_closed) { + if (!m_reader.HasMessage() && !m_closed && !m_readClosed) { // A non-blocking pump, so a caller that has not received yet can // still size its buffer. Errors are not reported here: PeekFrameSize // answers "how big is the next message", and 0 means "none buffered", @@ -677,6 +691,7 @@ namespace MobileGL::MG_Remote::Transport { SocketTransport::~SocketTransport() = default; int SocketTransport::TakeDataFd() { return -1; } void SocketTransport::CloseLocalCopy() {} + MobileGLResult SocketTransport::ShutdownSend() { return MOBILEGL_ERR_UNSUPPORTED; } MobileGLResult SocketTransport::SendFrame(MobileGLByteSpan) { return MOBILEGL_ERR_UNSUPPORTED; } MobileGLResult SocketTransport::ReceiveFrame(MobileGLMutableByteSpan, std::uint64_t*, diff --git a/MobileGL/MG_Remote/Transport/SocketTransport.h b/MobileGL/MG_Remote/Transport/SocketTransport.h index d921f276..65f73fbb 100644 --- a/MobileGL/MG_Remote/Transport/SocketTransport.h +++ b/MobileGL/MG_Remote/Transport/SocketTransport.h @@ -132,6 +132,8 @@ namespace MobileGL::MG_Remote::Transport { // Parent of a session child closes its duplicate without shutdown(2), // which would also disconnect the child's inherited socket. void CloseLocalCopy(); + // End requests while retaining the receive side for final diagnostics/EOF. + MobileGLResult ShutdownSend(); private: // Pulls whatever the socket has into the reassembler. Returns @@ -153,6 +155,7 @@ namespace MobileGL::MG_Remote::Transport { FrameReader m_reader; std::atomic m_closed{false}; + std::atomic m_readClosed{false}; bool m_failed = false; // framing violated: latched, never recovers }; diff --git a/MobileGL/MG_Remote/Transport/StreamLink.cpp b/MobileGL/MG_Remote/Transport/StreamLink.cpp index 130fae9d..ea0d30fa 100644 --- a/MobileGL/MG_Remote/Transport/StreamLink.cpp +++ b/MobileGL/MG_Remote/Transport/StreamLink.cpp @@ -15,6 +15,7 @@ #if !defined(_WIN32) #include #include +#include #include #if defined(__linux__) #include @@ -23,6 +24,8 @@ namespace MobileGL::MG_Remote::Transport { namespace { + constexpr std::uint64_t kDataEnvelopeBytes = 20; + constexpr std::uint64_t kMaxDataPayload = kMaxFramePayloadSize - kDataEnvelopeBytes; constexpr std::uint32_t kDataMagic = 0x444c474d; // MGLD, little endian enum Kind : unsigned { Cmd = 1, @@ -102,32 +105,17 @@ namespace MobileGL::MG_Remote::Transport { return memory->ReplyBytes() / memory->ReplySlotCount() - sizeof(ReplySlotHeader); } void Die(bool peer) { - closed.store(true, std::memory_order_release); + { + // Pair every reply-wait predicate transition with its mutex: + // an EOF between the predicate and cv.wait must not lose a wake. + std::lock_guard lock(replyMutex); + closed.store(true, std::memory_order_release); + } consumer.Kill(peer); producer.Kill(peer); replyReady.notify_all(); #if !defined(_WIN32) if (fd >= 0) ::shutdown(fd, SHUT_RDWR); -#endif - } - bool Write(const void* data, std::size_t size) { -#if !defined(_WIN32) - auto* p = static_cast(data); - while (size) { - const auto n = ::send(fd, p, size, MSG_NOSIGNAL); - if (n < 0 && errno == EINTR) continue; - if (n <= 0) { - Die(!stopping.load(std::memory_order_acquire)); - return false; - } - p += n; - size -= static_cast(n); - } - return true; -#else - (void)data; - (void)size; - return false; #endif } bool Read(void* data, std::size_t size) { @@ -151,20 +139,50 @@ namespace MobileGL::MG_Remote::Transport { #endif } bool Send(unsigned kind, std::uint64_t a, std::uint64_t b, const void* payload, std::uint64_t size) { - if (size > kMaxFramePayloadSize) Corrupt("outgoing frame exceeds limit"); + if (size > kMaxDataPayload) Corrupt("outgoing frame exceeds limit"); std::uint8_t h[28]{}; Put32(h, kDataMagic); - Put32(h + 4, static_cast(size)); + Put32(h + 4, static_cast(kDataEnvelopeBytes + size)); h[8] = static_cast(kind); Put64(h + 12, a); Put64(h + 20, b); - return Write(h, sizeof h) && (!size || Write(payload, static_cast(size))); +#if !defined(_WIN32) + // One gather write keeps a small command and its envelope in one + // TCP send (TCP_NODELAY is enabled). Partial writes retain the same + // frame lock and advance through both vectors without copying. + iovec vectors[2] = {{h, sizeof h}, {const_cast(payload), static_cast(size)}}; + msghdr message{}; + message.msg_iov = vectors; + message.msg_iovlen = size ? 2 : 1; + while (message.msg_iovlen) { + const auto sent = ::sendmsg(fd, &message, MSG_NOSIGNAL); + if (sent < 0 && errno == EINTR) continue; + if (sent <= 0) { + Die(!stopping.load(std::memory_order_acquire)); + return false; + } + auto consumed = static_cast(sent); + while (message.msg_iovlen && consumed >= message.msg_iov->iov_len) { + consumed -= message.msg_iov->iov_len; + ++message.msg_iov; + --message.msg_iovlen; + } + if (message.msg_iovlen && consumed) { + message.msg_iov->iov_base = static_cast(message.msg_iov->iov_base) + consumed; + message.msg_iov->iov_len -= consumed; + } + } + return true; +#else + (void)payload; + return false; +#endif } bool SendRing(unsigned kind, void* base, std::uint64_t capacity, std::uint64_t head, std::uint64_t& sent) { if (head < sent || head - sent > capacity) Corrupt("outgoing ring window invalid"); while (sent < head) { const auto at = sent % capacity; - const auto count = std::min({head - sent, capacity - at, kMaxFramePayloadSize}); + const auto count = std::min({head - sent, capacity - at, kMaxDataPayload}); if (!Send(kind, sent, 0, static_cast(base) + at, count)) return false; sent += count; } @@ -193,23 +211,31 @@ namespace MobileGL::MG_Remote::Transport { return Send(ProgressFrame, 0, 0, p, sizeof p); } void ReceiveRing(void* base, std::uint64_t capacity, std::uint64_t tail, std::uint64_t a, - const std::vector& p, std::uint64_t& received) { - if (a != received || a < tail || a - tail > capacity || p.size() > capacity - (a - tail) || - p.size() > capacity - a % capacity || p.empty()) + const std::uint8_t* payload, std::uint64_t size, std::uint64_t& received) { + if (a != received || a < tail || a - tail > capacity || size > capacity - (a - tail) || + size > capacity - a % capacity || size == 0) Corrupt("ring frame outside receive window"); - std::memcpy(static_cast(base) + a % capacity, p.data(), p.size()); - received += p.size(); + std::memcpy(static_cast(base) + a % capacity, payload, static_cast(size)); + received += size; } void Loop() { #if defined(__linux__) pthread_setname_np(pthread_self(), Client() ? "mgl-cli-io" : "mgl-srv-io"); #endif + FrameReader frames(kDataMagic); for (;;) { std::uint8_t h[28]; - if (!Read(h, sizeof h)) return; - const auto size = Get32(h + 4), kind = unsigned(h[8]); + if (!Read(h, kFrameHeaderSize)) return; + if (frames.Feed(h, kFrameHeaderSize) != MOBILEGL_OK) Corrupt("invalid data frame header"); + const auto framedSize = Get32(h + 4); + if (framedSize < kDataEnvelopeBytes) Corrupt("truncated data envelope"); + if (!Read(h + kFrameHeaderSize, kDataEnvelopeBytes)) return; + if (frames.Feed(h + kFrameHeaderSize, kDataEnvelopeBytes) != MOBILEGL_OK) + Corrupt("invalid data frame envelope"); + const auto size = framedSize - static_cast(kDataEnvelopeBytes); + const auto kind = unsigned(h[8]); const auto a = Get64(h + 12), b = Get64(h + 20); - if (Get32(h) != kDataMagic || h[9] || h[10] || h[11] || size > kMaxFramePayloadSize) + if (Get32(h) != kDataMagic || h[9] || h[10] || h[11] || size > kMaxDataPayload) Corrupt("invalid data frame header"); // Reject the size/window before allocating or reading a hostile payload. if ((kind == Stage && (Client() || a > memory->StageBytes() || size > memory->StageBytes() - a)) || @@ -219,16 +245,28 @@ namespace MobileGL::MG_Remote::Transport { (kind == ProgressFrame && (!Client() || size != 64)) || (kind == ClientProgress && (Client() || size != 8)) || kind < Cmd || kind > Event) Corrupt("frame kind or size outside negotiated window"); - std::vector p(size); - if (size && !Read(p.data(), size)) return; + if (((kind == Cmd || kind == Stage || kind == Event) && b != 0) || + (kind == ProgressFrame && (a != 0 || b != 0)) || (kind == Reply && b > UINT32_MAX)) + Corrupt("nonzero reserved data envelope field"); + std::uint8_t chunk[64 * 1024]; + std::uint64_t remaining = size; + while (remaining) { + const auto count = std::min(remaining, sizeof chunk); + if (!Read(chunk, static_cast(count))) return; + if (frames.Feed(chunk, count) != MOBILEGL_OK) Corrupt("invalid data frame payload"); + remaining -= count; + } + std::vector p; + if (frames.TakeMessage(p) != MOBILEGL_OK) Corrupt("incomplete data frame"); + const auto* payload = p.data() + kDataEnvelopeBytes; if (kind == Stage) { - std::memcpy(static_cast(memory->StageBase()) + a, p.data(), size); + std::memcpy(static_cast(memory->StageBase()) + a, payload, size); } else if (kind == Cmd) { ReceiveRing(memory->CmdRingBase(), memory->CmdRingCapacity(), - C().cmdRetiredTail.load(std::memory_order_acquire), a, p, receivedCmd); + C().cmdRetiredTail.load(std::memory_order_acquire), a, payload, size, receivedCmd); } else if (kind == ClientProgress) { if (a != receivedCmd) Corrupt("cmdHead does not match delivered bytes"); - const auto tail = Get64(p.data()); + const auto tail = Get64(payload); if (tail > E().cmdHead.load(std::memory_order_acquire)) Corrupt("event tail ahead of head"); const auto oldTail = E().cmdRetiredTail.load(std::memory_order_acquire); Advance(E().cmdAppliedTail, tail); @@ -241,11 +279,11 @@ namespace MobileGL::MG_Remote::Transport { consumer.Notify(); } else if (kind == Event) { ReceiveRing(memory->EventRingBase(), memory->EventRingCapacity(), - E().cmdRetiredTail.load(std::memory_order_acquire), a, p, receivedEvent); + E().cmdRetiredTail.load(std::memory_order_acquire), a, payload, size, receivedEvent); Advance(E().cmdHead, receivedEvent); producer.Notify(); } else if (kind == ProgressFrame) { - const auto* v = p.data(); + const auto* v = payload; if (Get64(v + 8) > Get64(v) || Get64(v) > C().submittedSeq.load(std::memory_order_acquire) || Get64(v + 40) > Get64(v + 32) || Get64(v + 32) > C().cmdHead.load(std::memory_order_acquire) || Get64(v + 48) > 1) @@ -254,10 +292,17 @@ namespace MobileGL::MG_Remote::Transport { Advance(C().cmdRetiredTail, Get64(v + 40)); C().eventRingFull.store(static_cast(Get64(v + 48)), std::memory_order_release); C().eventDropped.store(static_cast(Get64(v + 56)), std::memory_order_release); - Watermark::AdvanceRetired(C(), Get64(v + 8)); Watermark::AdvanceCompletedFrame(C(), Get64(v + 16)); Watermark::AdvancePresentAck(C(), Get64(v + 24)); - Watermark::AdvanceApplied(C(), Get64(v)); + { + std::lock_guard lock(replyMutex); + Watermark::AdvanceApplied(C(), Get64(v)); + // AdvanceRetired clamps to local appliedSeq. Apply this + // received snapshot in dependency order or the final + // retirement is silently clamped to the previous batch. + Watermark::AdvanceRetired(C(), Get64(v + 8)); + } + replyReady.notify_all(); producer.Notify(); } else if (kind == Reply) { { @@ -265,7 +310,7 @@ namespace MobileGL::MG_Remote::Transport { if (a <= replySeq) Corrupt("reply sequence moved backwards"); replySeq = a; replyStatus = static_cast(static_cast(b)); - reply = std::move(p); + reply.assign(payload, payload + size); } replyReady.notify_all(); producer.Notify(); @@ -327,7 +372,7 @@ namespace MobileGL::MG_Remote::Transport { m_impl->owned->InitializeEndpoints(); } SessionSegments& StreamLink::Memory() { - return m_impl->owned->Memory(); + return m_impl->memory ? *m_impl->memory : m_impl->owned->Memory(); } RingProducer& StreamLink::CommandsOut() { return m_impl->owned->CommandsOut(); @@ -416,7 +461,7 @@ namespace MobileGL::MG_Remote::Transport { for (const auto span : x.stage) { std::uint64_t sent = 0; while (sent < span.Size) { - const auto count = std::min(span.Size - sent, kMaxFramePayloadSize); + const auto count = std::min(span.Size - sent, kMaxDataPayload); if (!x.Send(Stage, span.Offset + sent, 0, static_cast(x.memory->StageBase()) + span.Offset + sent, count)) return MOBILEGL_ERR_TRANSPORT_CLOSED; @@ -459,11 +504,15 @@ namespace MobileGL::MG_Remote::Transport { } MobileGLResult StreamLink::ReadReply(std::uint64_t seq, std::int32_t* status, const void** p, std::uint64_t* size) { auto& x = *m_impl; - if (!status || !p || !size || !x.Client()) return MOBILEGL_ERR_INVALID_ARGUMENT; + if (!status || !p || !size || !seq || !x.Client()) return MOBILEGL_ERR_INVALID_ARGUMENT; + if (!x.memory) return MOBILEGL_ERR_NOT_INITIALIZED; std::unique_lock lock(x.replyMutex); // The applied watermark is sent after the reply, so normal callers never // wait here. A standalone reply reader may wait without polling. - x.replyReady.wait(lock, [&] { return x.replySeq >= seq || x.closed.load(std::memory_order_acquire); }); + x.replyReady.wait(lock, [&] { + return x.closed.load(std::memory_order_acquire) || x.replySeq >= seq || + x.C().Progress.appliedSeq.load(std::memory_order_acquire) >= seq; + }); if (x.replySeq != seq) return x.closed.load() ? MOBILEGL_ERR_TRANSPORT_CLOSED : MOBILEGL_ERR_PROTOCOL_MISMATCH; x.readReply = x.reply; *status = x.replyStatus; diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp index f1c12cdc..c031c5d6 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.cpp @@ -858,7 +858,16 @@ namespace MobileGL::MG_Remote::Wire { Bool PipeWireEncoder::Valid() const { return m_progress != nullptr && m_cmd != nullptr; } + Bool PipeWireEncoder::CheckCancellation() { + if (Cancelled() || (m_cancellationHook && m_cancellationHook(m_cancellationSelf)) || + (m_stageRetirementBell && m_stageRetirementBell->Dead())) { + m_cancelled = true; + } + return m_cancelled; + } + Uint8* PipeWireEncoder::StageAllocate(Uint64 size) { + if (CheckCancellation()) return nullptr; if (m_stageBase == nullptr) { const SegmentView view = m_segments != nullptr ? m_segments->Get(kSegStage) : SegmentView{}; @@ -912,6 +921,7 @@ namespace MobileGL::MG_Remote::Wire { bool reclaimed = false; bool waited = false; for (;;) { + if (CheckCancellation()) return nullptr; // THE WRAP SKIP MAY ONLY BE CHARGED AGAINST BYTES THAT ARE STILL IN FLIGHT. When // there are none the allocator starts over at offset zero, so a blob the segment // can hold whole is never refused (see RebaseEmptyStage). On retry this runs @@ -952,9 +962,13 @@ namespace MobileGL::MG_Remote::Wire { // therefore no reverse channel to drain. if (m_stageWaitHook != nullptr) m_stageWaitHook(m_stageWaitSelf); if (m_link) m_link->Flush(); - if (!ready() && !m_stageRetirementBell->Wait(*m_signals.ProducerParked, ready, 0, 5000)) { - SessionFail(MGFatalFamily::RetirementWaitFailed, "MGPipe: Fatal{RetirementWaitFailed, \"SEG_STAGE\"} producer wait " - "ended before the pending allocation retired (shutdown or timeout)"); + if (CheckCancellation()) return nullptr; + if (!ready() && !m_stageRetirementBell->Wait(*m_signals.ProducerParked, ready, 0, + m_stageWaitTimeoutMs)) { + if (CheckCancellation()) return nullptr; + SessionFail(MGFatalFamily::RetirementWaitFailed, + "MGPipe: Fatal{RetirementWaitFailed, \"SEG_STAGE\"} live peer did not retire " + "the pending allocation within %u ms", m_stageWaitTimeoutMs); } if (m_stageWaitHook != nullptr) m_stageWaitHook(m_stageWaitSelf); } @@ -972,6 +986,7 @@ namespace MobileGL::MG_Remote::Wire { } MGPBlobRef PipeWireEncoder::StageBytes(const void* bytes, Uint64 size) { + if (CheckCancellation()) return {}; if (!Valid() || m_segments == nullptr) { WireProtocolFatal("PipeWireEncoder::StageBytes", "no command producer or segment table installed"); @@ -988,6 +1003,7 @@ namespace MobileGL::MG_Remote::Wire { } Uint8* slot = StageAllocate(size); + if (slot == nullptr || CheckCancellation()) return {}; std::memcpy(slot, bytes, static_cast(size)); const Uint64 offset = static_cast(slot - m_stageBase); if (m_link) m_link->NoteStage({offset, size}); @@ -1017,6 +1033,7 @@ namespace MobileGL::MG_Remote::Wire { Uint64 PipeWireEncoder::EncodeRecord(MGPWireOp op, const void* payload, Uint64 payloadBytes, const WireTail* tails, Uint32 tailCount) { + if (Cancelled()) return kInvalidSeq; if (!Valid()) { WireProtocolFatal("PipeWireEncoder::EncodeRecord", "no SEG_CMD producer installed"); } @@ -1358,14 +1375,14 @@ namespace MobileGL::MG_Remote::Wire { // --------------------------------------------------------------------------------- PipeWireDecoder::PipeWireDecoder(Transport::ILink* link, SegmentTable* segments, ReplySink* replies) - : m_segments(segments), m_replies(replies), m_valid(link && link->Attached()) { + : m_valid(link && link->Attached()), m_segments(segments), m_replies(replies) { m_auditPoison = MG_Config::Ipc.Audit; InstallApplyHook(); } PipeWireDecoder::PipeWireDecoder(Transport::RingControl* control, SegmentTable* segments, ReplySink* replies) - : m_segments(segments), m_replies(replies), m_valid(control != nullptr) { + : m_valid(control != nullptr), m_segments(segments), m_replies(replies) { m_auditPoison = MG_Config::Ipc.Audit; // Once, at construction, beside the resolver it is modelled on - not per record from // the apply thread. The thunk is inert without a decoder on the calling thread, so an diff --git a/MobileGL/MG_Remote/Wire/PipeWireCodec.h b/MobileGL/MG_Remote/Wire/PipeWireCodec.h index efab82a6..1c961561 100644 --- a/MobileGL/MG_Remote/Wire/PipeWireCodec.h +++ b/MobileGL/MG_Remote/Wire/PipeWireCodec.h @@ -343,6 +343,18 @@ namespace MobileGL::MG_Remote::Wire { // without a consumer cannot wait for retirement and retain the named refusal. void SetLink(Transport::ILink* link) { m_link = link; } void SetStageRetirementDoorbell(Transport::Doorbell* bell) { m_stageRetirementBell = bell; } + using CancellationHook = Bool (*)(void* self); + void SetCancellationState(const std::atomic* lost, CancellationHook hook, void* self) { + m_sessionLost = lost; m_cancellationHook = hook; m_cancellationSelf = self; + } + // The fast check is used on encode; the doorbell probe only runs while + // staging or at an unsuccessful wait boundary. + Bool Cancelled() const { + return m_cancelled || (m_sessionLost && m_sessionLost->load(std::memory_order_acquire)); + } + Bool CheckCancellation(); + void SetStageWaitTimeoutMs(Uint32 timeoutMs) { m_stageWaitTimeoutMs = timeoutMs; } + // P5e (ra, CONTRACT-P5E §2.6): THE STAGE BELL IS A CLIENT WAIT, so it has to drain the // reverse channel like every other one. The encoder cannot drain it itself - SEG_EVENT @@ -403,6 +415,12 @@ namespace MobileGL::MG_Remote::Wire { Uint64 m_cmdWrapPads = 0; Uint64 m_stageReclaimWaits = 0; Transport::Doorbell* m_stageRetirementBell = nullptr; + const std::atomic* m_sessionLost = nullptr; + CancellationHook m_cancellationHook = nullptr; + void* m_cancellationSelf = nullptr; + Bool m_cancelled = false; + Uint32 m_stageWaitTimeoutMs = 120000; + // P5e (ra): the session's event drain, called around the stage-bell park (§2.6). StageWaitHook m_stageWaitHook = nullptr; void* m_stageWaitSelf = nullptr; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h index e45d03cb..6aa681e0 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifacts.h @@ -413,9 +413,9 @@ namespace MobileGL::MG_State::GLState { // {h,cpp}, beside this header rather than inside it so the check_include_closure.py // "artifacts-header" probe stays untouched. It is two visitors over the tables below - a // writer that appends to a Vector and a reader that consumes one - length-prefixed, - // little-endian, with a format-version word first and a MGL_LINKARTIFACTS_SIZE echo - // second, so a struct that gained a field and a codec that did not is a mismatch at READ - // time rather than a silent truncation. Adding a member to any struct above therefore + // little-endian, with a format-version word and (for disaggregated v2) a recursive + // wire-schema digest of these tables. Native container sizes are local maintenance + // assertions below, never cross-standard-library wire facts. Adding a member to any struct above therefore // means: add its VisitFields row here, update the sizeof number below, and bump // kProgramArtifactsCodecVersion. `LinkArtifacts::program` stays the one deliberate // omission, and the codec has no arm for it. diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp index 8d5c31e0..5ad158f9 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +71,88 @@ namespace MobileGL::MG_State::GLState { template concept ArchiveUniformInitializer = std::same_as; + // glslang supplies no VisitFields table for this aggregate. Keep its one explicit + // table shared by the payload writer, reader and wire-schema walk. + template + void VisitUniformInitializer(Self& value, Visitor&& visit) { + visit("name", value.name); + visit("basicType", value.basicType); + visit("vectorSize", value.vectorSize); + visit("matrixCols", value.matrixCols); + visit("matrixRows", value.matrixRows); + visit("arraySize", value.arraySize); + visit("intValues", value.intValues); + visit("floatValues", value.floatValues); + } + +#if MOBILEGL_BUILD_DISAGGREGATED + void SchemaWord(Uint64& hash, Uint64 word) { + for (unsigned i = 0; i < 8; ++i) { + hash ^= (word >> (i * 8)) & 255u; + hash *= 1099511628211ull; + } + } + void SchemaName(Uint64& hash, const char* text) { + do { + hash ^= static_cast(*text); + hash *= 1099511628211ull; + } while (*text++ != '\0'); + } + template + void DescribeWireType(Uint64& hash) { + using T = std::remove_cv_t; + if constexpr (ArchiveScalar) { + // These are the bytes PutRaw/TakeRaw actually carry, not the surrounding + // C++ object's layout. Equal-width signed/unsigned or float/int differ. + if constexpr (std::is_enum_v) { + SchemaName(hash, "enum"); + DescribeWireType>(hash); + } else { + SchemaName(hash, std::is_same_v ? "bool" : + std::is_floating_point_v ? "ieee-float" : + std::is_signed_v ? "signed" : "unsigned"); + SchemaWord(hash, sizeof(T)); + if constexpr (std::is_floating_point_v) { + static_assert(std::numeric_limits::is_iec559); + SchemaWord(hash, std::numeric_limits::digits); + SchemaWord(hash, std::numeric_limits::max_exponent); + } + } + } else if constexpr (ArchiveString) { + SchemaName(hash, "string-u64-count-byte-content"); + } else if constexpr (ArchiveMap) { + SchemaName(hash, "map-u64-count"); + DescribeWireType(hash); + DescribeWireType(hash); + } else if constexpr (ArchiveSet) { + SchemaName(hash, "set-u64-count"); + DescribeWireType(hash); + } else if constexpr (ArchiveFixedArray) { + SchemaName(hash, "fixed-array"); + SchemaWord(hash, std::tuple_size::value); + DescribeWireType(hash); + } else if constexpr (ArchiveVector) { + SchemaName(hash, "vector-u64-count"); + DescribeWireType(hash); + } else { + SchemaName(hash, ArchiveUniformInitializer ? "uniform-initializer" : "record"); + // Visit only types/names from default records. Container contents are never + // traversed, and no sizeof(string/vector/map), offset or allocator enters it. + T value{}; + Uint64 fields = 0; + const auto field = [&](const char* name, const auto& member) { + ++fields; + SchemaName(hash, name); + DescribeWireType>(hash); + }; + if constexpr (ArchiveUniformInitializer) VisitUniformInitializer(value, field); + else VisitFields(value, field); + SchemaWord(hash, fields); + SchemaName(hash, "end-record"); + } + } +#endif + // ---- the writer ---- template @@ -117,6 +200,11 @@ namespace MobileGL::MG_State::GLState { } else if constexpr (ArchiveVector) { WriteSequence(out, value); } else if constexpr (ArchiveUniformInitializer) { +#if MOBILEGL_BUILD_DISAGGREGATED + VisitUniformInitializer(value, [&out](const char*, const auto& member) { + WriteValue(out, member); + }); +#else WriteValue(out, value.name); WriteValue(out, value.basicType); WriteValue(out, value.vectorSize); @@ -125,6 +213,7 @@ namespace MobileGL::MG_State::GLState { WriteValue(out, value.arraySize); WriteValue(out, value.intValues); WriteValue(out, value.floatValues); +#endif } else { // The archive's own structs: TypeFacts, ResourceReflection, XfbVarying. ONE // table serves both directions, so a member added to any of them is carried by @@ -223,6 +312,11 @@ namespace MobileGL::MG_State::GLState { if (!in.Ok) return; } } else if constexpr (ArchiveUniformInitializer) { +#if MOBILEGL_BUILD_DISAGGREGATED + VisitUniformInitializer(value, [&in](const char*, auto& member) { + if (in.Ok) ReadValue(in, member); + }); +#else ReadValue(in, value.name); ReadValue(in, value.basicType); ReadValue(in, value.vectorSize); @@ -231,6 +325,7 @@ namespace MobileGL::MG_State::GLState { ReadValue(in, value.arraySize); ReadValue(in, value.intValues); ReadValue(in, value.floatValues); +#endif } else { VisitFields(value, [&in](const char*, auto& field) { if (in.Ok) ReadValue(in, field); @@ -238,29 +333,41 @@ namespace MobileGL::MG_State::GLState { } } - // The struct-size echo. Under a toolchain whose sizes are not pinned yet - // (ProgramArtifacts.h's libc++ branch until the integrator fills it in) this is 0, - // which still round-trips within one build - the echo compares what THIS build wrote - // against what THIS build expects - and stops mattering the moment the pin lands. - // - // P5e (pg) READ THIS LINE AS THE CONTRACT ASKED (CONTRACT-P5E §5.5) and its verdict is - // in ProgramArtifactsCodec.h's header comment: the caveat is about the ECHO WORD, not - // about a member the codec skips - the one member VisitFields omits is - // `LinkArtifacts::program`, and no backend twin reads it. A 0 echo is exact for an - // in-process split, where the writer and the reader are one build, and becomes a real - // cross-toolchain check when P6 puts a client and a server in different processes - // built by different compilers. + // v1 is retained for local monolith verification. Its native size echo is not a + // wire compatibility fact: libstdc++ writes 1056 while unpinned libc++ writes zero. +#if !MOBILEGL_BUILD_DISAGGREGATED #ifdef MGL_LINKARTIFACTS_SIZE inline constexpr Uint64 kLinkArtifactsSizeEcho = MGL_LINKARTIFACTS_SIZE; #else inline constexpr Uint64 kLinkArtifactsSizeEcho = 0; +#endif #endif } // namespace +#if MOBILEGL_BUILD_DISAGGREGATED + Uint64 ProgramArtifactsSchemaFingerprint() { + static const Uint64 fingerprint = [] { + Uint64 hash = 1469598103934665603ull; + SchemaName(hash, "MobileGL.ProgramArchive.v2.little-endian"); + // The outer stage framing is part of the same wire contract. + SchemaName(hash, "stage-list-u32-count-u32-elements"); + SchemaWord(hash, kProgramArchiveMaxStages); + DescribeWireType(hash); + DescribeWireType(hash); + return hash == 0 ? Uint64{1} : hash; + }(); + return fingerprint; + } +#endif + void EncodeProgramArtifacts(const LinkArtifacts& link, const SpirvArtifacts& spirv, Vector& out) { PutRaw(out, kProgramArtifactsCodecVersion); +#if MOBILEGL_BUILD_DISAGGREGATED + PutRaw(out, ProgramArtifactsSchemaFingerprint()); +#else PutRaw(out, kLinkArtifactsSizeEcho); +#endif // `link` is walked through its own VisitFields table, which omits the live // SharedPtr: 57 of the 58 members. There is no arm here for it and // there must not be one - it points into a glslang arena that no archived instance @@ -280,14 +387,16 @@ namespace MobileGL::MG_State::GLState { ReadCursor in{bytes, size, 0, true}; Uint32 version = 0; - Uint64 sizeEcho = 0; - if (!TakeRaw(in, version) || !TakeRaw(in, sizeEcho)) return false; - // REFUSED, NOT GUESSED. A different version word or a struct that changed width means - // the bytes describe a layout this build does not have; deserialising them anyway - // writes garbage into the tail of a reflection table, which is exactly the failure the - // two words exist to turn into a clean false. + Uint64 schema = 0; + if (!TakeRaw(in, version) || !TakeRaw(in, schema)) return false; + // Refuse the declared wire shape before reading any field. In v2 the second word + // follows serialization types/order; C++ container object sizes are irrelevant. if (version != kProgramArtifactsCodecVersion) return false; - if (sizeEcho != kLinkArtifactsSizeEcho) return false; +#if MOBILEGL_BUILD_DISAGGREGATED + if (schema != ProgramArtifactsSchemaFingerprint()) return false; +#else + if (schema != kLinkArtifactsSizeEcho) return false; +#endif ReadValue(in, link); ReadValue(in, spirv); diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.h b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.h index d02980ba..3391cc45 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.h @@ -32,9 +32,9 @@ // path. // // THE FORMAT, and every part of it is a refusal rather than a guess: -// * a VERSION word first, and a MGL_LINKARTIFACTS_SIZE echo second, so a struct that gained -// a field and a codec that did not is a MISMATCH AT READ TIME rather than a silent -// truncation that deserialises garbage into the tail of a reflection table; +// * a VERSION word first, and a wire-schema fingerprint second (v2), so member order, +// type or count-layout drift is a MISMATCH before decoding. The local-only v1 form +// retains its legacy native-size echo; // * length-prefixed everything - strings, vectors, maps, sets - with the count checked // against the bytes that remain before a single element is allocated, so a corrupt count // cannot turn into a four-billion-element resize; @@ -54,19 +54,20 @@ // member it does not carry is one no twin has ever asked for. It could not be carried in any // case: it points into a glslang arena that no archived instance owns. // -// AND THE OTHER HALF OF THAT CONTRACT LINE - `ProgramArtifactsCodec.cpp`'s libc++ caveat - is -// about the STRUCT-SIZE ECHO, not about a member: under a toolchain whose `MGL_LINKARTIFACTS_SIZE` -// is not pinned yet (the NDK's libc++ branch of `ProgramArtifacts.h`, inert until the integrator -// fills it in) the echo word is 0. That is SAFE FOR P5e AND NOT A GAP: the echo compares what -// this build wrote against what this build expects, so a round trip within one build is exact, -// and P5e is an in-process split where the writer and the reader ARE one build. It becomes a -// real check the day a client and a server built by different toolchains share a wire, which is -// P6's boundary and P6's problem - stated here so it cannot be discovered instead. +// P6.5: native STL object sizes never describe this field-wise byte stream. Disaggregated +// peers use v2's recursive wire-schema fingerprint; monolith verification retains v1. namespace MobileGL::MG_State::GLState { // Bumped whenever the byte format changes in a way a previous reader would misread. A // reader that sees a different word REFUSES; it never tries to guess a layout. +#if MOBILEGL_BUILD_DISAGGREGATED + inline constexpr Uint32 kProgramArtifactsCodecVersion = 2; + // Derived from the actual VisitFields order/names, container element schemas and scalar + // representations. No native container size/offset is included. Also checked in Hello. + Uint64 ProgramArtifactsSchemaFingerprint(); +#else inline constexpr Uint32 kProgramArtifactsCodecVersion = 1; +#endif // Appends the archive to `out` (which is not cleared, so a caller may frame it). Never // fails: everything it walks is owned plain data. @@ -75,7 +76,7 @@ namespace MobileGL::MG_State::GLState { // Replaces `link` and `spirv` with what `bytes` describes. Returns false - with both // outputs left in a defined, default state - for a truncated stream, a version mismatch, a - // struct-size mismatch, or trailing bytes the format does not account for. `link.program` + // wire-schema mismatch (native-size echo for local v1), or trailing bytes. `link.program` // is always null on return. Bool DecodeProgramArtifacts(const Uint8* bytes, SizeT size, LinkArtifacts& link, SpirvArtifacts& spirv); diff --git a/MobileGL/MG_Test/Pipe/CMakeLists.txt b/MobileGL/MG_Test/Pipe/CMakeLists.txt index bc595c7f..41038a27 100644 --- a/MobileGL/MG_Test/Pipe/CMakeLists.txt +++ b/MobileGL/MG_Test/Pipe/CMakeLists.txt @@ -213,6 +213,17 @@ endif() include(GoogleTest) gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +# This source scanner must inspect the configured checkout, including in out-of-tree builds. +# Apply the override after GoogleTest's deferred discovery, and only to this one test. +file(GENERATE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/PipeCatalogueSourceRoot.cmake" CONTENT +"if(\"PipeCatalogue.FrontendNeverTakesAnApplierAddress\" IN_LIST PipeCatalogueTest_TESTS) + set_tests_properties(PipeCatalogue.FrontendNeverTakesAnApplierAddress PROPERTIES + WORKING_DIRECTORY [==[${MGL_ROOT}]==]) +endif() +") +set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES + "${CMAKE_CURRENT_BINARY_DIR}/PipeCatalogueSourceRoot.cmake") + gtest_discover_tests(FieldOwnershipTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) foreach(pipeTest ResourceEmitTest VertexInputEmitTest FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest diff --git a/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp b/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp index ea352e50..a5f71488 100644 --- a/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.cpp @@ -18,8 +18,8 @@ // and every one of TypeFacts' twenty members - a codec that dropped one would be invisible // until a backend read a reflection answer that had quietly become zero; // * a TRUNCATED stream is refused rather than guessed at; -// * a VERSION or struct-size mismatch is refused rather than deserialised into a layout this -// build does not have. +// * a VERSION or wire-schema mismatch is refused before decoding; native STL object +// sizes do not describe a cross-platform serialized archive. // // AND ONE PROPERTY THAT IS NOT ABOUT BYTES AT ALL: LinkArtifacts has 58 members and its // VisitFields table visits 57. The 58th is the live SharedPtr, which is @@ -32,6 +32,7 @@ // stays name-for-name identical between the pull and the push trees. #include +#include #include "Includes.h" #if MOBILEGL_PIPE_PUSH @@ -325,9 +326,8 @@ TEST(ProgramArtifactsCodec, ATruncatedStreamIsRefusedNotGuessed) { #endif } -// Negative control 2. The version word and the struct-size echo are the two things a compiler -// cannot check: a struct that gained a field and a codec that did not would otherwise -// deserialise garbage into the tail of a reflection table. Both have to REFUSE. +// Negative control 2: both the version and schema word must refuse mismatches. +// Monolith v1 retains the old native-size echo in that second word. TEST(ProgramArtifactsCodec, AVersionMismatchIsRefused) { #if MOBILEGL_PIPE_PUSH Vector bytes; @@ -344,9 +344,7 @@ TEST(ProgramArtifactsCodec, AVersionMismatchIsRefused) { ++wrongVersion[0]; EXPECT_FALSE(DecodeProgramArtifacts(wrongVersion.data(), wrongVersion.size(), link, spirv)); - // Then the MGL_LINKARTIFACTS_SIZE echo, which is the half that catches a struct that grew - // under a codec that did not - the failure the four sizeof trip wires in - // ProgramArtifacts.h send an author here to fix. + // Then the wire schema (v2) or local native-size echo (v1). Neither may be ignored. Vector wrongSize = bytes; ++wrongSize[4]; EXPECT_FALSE(DecodeProgramArtifacts(wrongSize.data(), wrongSize.size(), link, spirv)); @@ -373,3 +371,39 @@ TEST(ProgramArtifactsCodec, TheTablesVisitEveryMemberExceptTheLiveProgram) { GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only"; #endif } + +TEST(ProgramArtifactsCodec, PortableHeaderUsesWireSchemaRatherThanNativeContainerSize) { +#if MOBILEGL_PIPE_PUSH && MOBILEGL_BUILD_DISAGGREGATED + Vector bytes; + EncodeProgramArtifacts(MakeLinkArtifacts(), MakeSpirvArtifacts(), bytes); + ASSERT_GT(bytes.size(), 12u); + Uint32 version = 0; + Uint64 schema = 0; + std::memcpy(&version, bytes.data(), sizeof(version)); + std::memcpy(&schema, bytes.data() + sizeof(version), sizeof(schema)); + EXPECT_EQ(version, 2u); + EXPECT_EQ(schema, ProgramArtifactsSchemaFingerprint()); + EXPECT_NE(schema, 0u); + EXPECT_NE(schema, sizeof(LinkArtifacts)); + RecordProperty("program_archive_wire_schema", std::to_string(schema)); + + // The old Linux/native echo (1056) and unpinned libc++ echo (0) both fail. + // This control distinguishes a portable schema from simply deleting the check. + for (const Uint64 legacyEcho : {Uint64{1056}, Uint64{0}}) { + auto legacy = bytes; + std::memcpy(legacy.data() + sizeof(version), &legacyEcho, sizeof(legacyEcho)); + LinkArtifacts link; + SpirvArtifacts spirv; + EXPECT_FALSE(DecodeProgramArtifacts(legacy.data(), legacy.size(), link, spirv)); + EXPECT_TRUE(link.uniformReflection.empty()); + EXPECT_TRUE(spirv.generatedSpirv.empty()); + } + auto v1 = bytes; + v1[0] = 1; + LinkArtifacts link; + SpirvArtifacts spirv; + EXPECT_FALSE(DecodeProgramArtifacts(v1.data(), v1.size(), link, spirv)); +#else + GTEST_SKIP() << "portable archive headers apply to the disaggregated wire"; +#endif +} diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index 6fa7cdf6..4277047d 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -22,7 +22,7 @@ if (NOT WIN32) # P6 so: the SAME nine ITransport-contract cases InProcessTransportTest runs, over a real # socketpair with real threads, plus the three a stream has and a message queue does not. # POSIX for the same reason as FdPassingTest - and because CONTRACT-P6 §2.6 lands POSIX only. - list(APPEND MOBILEGL_WIRE_TESTS SocketTransportTest StreamLinkTest) + list(APPEND MOBILEGL_WIRE_TESTS SocketTransportTest StreamLinkTest StreamClientPublicationTest InitialCapsStartupTest) # P6 sm: two MobileGL processes. Needs the real server image beside it, so # the dependency is declared after the loop rather than assumed. list(APPEND MOBILEGL_WIRE_TESTS ServerSpawnTest) @@ -141,3 +141,15 @@ foreach (wiretest IN ITEMS RemoteClientTest ServerLoopTest SessionHandshakeTest gtest_discover_tests(${wiretest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) endforeach () + +# P6.5 manual fault-injection probe: requires an explicit live peer and is never +# registered with ctest, so a device-less unit run cannot silently skip this gate. +if (NOT WIN32) + add_executable(MobileGLPeerLossProbe PeerLossProbe.cpp) + target_include_directories(MobileGLPeerLossProbe PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/flatbuffers/include + ) + target_link_libraries(MobileGLPeerLossProbe PRIVATE ${LINK_LIBRARIES}) +endif () diff --git a/MobileGL/MG_Test/Wire/FramingTest.cpp b/MobileGL/MG_Test/Wire/FramingTest.cpp index 6632825b..69eb9fb3 100644 --- a/MobileGL/MG_Test/Wire/FramingTest.cpp +++ b/MobileGL/MG_Test/Wire/FramingTest.cpp @@ -195,3 +195,21 @@ TEST(FramingTest, ManyMessagesCompactTheBufferInsteadOfGrowing) { } EXPECT_EQ(reader.BufferedBytes(), 0u); } + +TEST(Framing, ADataMagicReaderKeepsTheSameLengthAndReassemblyRules) { + using namespace MobileGL::MG_Remote::Transport; + constexpr std::uint32_t dataMagic = 0x444c474d; + std::vector payload(20 + 9, 0x5a), framed; + ASSERT_EQ(AppendFrame(framed, payload.data(), payload.size()), MOBILEGL_OK); + std::memcpy(framed.data(), &dataMagic, sizeof dataMagic); + FrameReader reader(dataMagic); + ASSERT_EQ(reader.Feed(framed.data(), 7), MOBILEGL_OK); + EXPECT_FALSE(reader.HasMessage()); + ASSERT_EQ(reader.Feed(framed.data() + 7, framed.size() - 7), MOBILEGL_OK); + EXPECT_EQ(reader.PendingMessageSize(), payload.size()); + std::vector received; + ASSERT_EQ(reader.TakeMessage(received), MOBILEGL_OK); + EXPECT_EQ(received, payload); + FrameReader controlReader; + EXPECT_EQ(controlReader.Feed(framed.data(), framed.size()), MOBILEGL_ERR_PROTOCOL_MISMATCH); +} diff --git a/MobileGL/MG_Test/Wire/InitialCapsStartupTest.cpp b/MobileGL/MG_Test/Wire/InitialCapsStartupTest.cpp new file mode 100644 index 00000000..2dac0bee --- /dev/null +++ b/MobileGL/MG_Test/Wire/InitialCapsStartupTest.cpp @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// Starts at the post-Welcome boundary with real TCP endpoints and ControlInbox. +// The peer publishes real CapsSnapshot bytes; no private run-ahead flag is set. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Remote; +namespace { + template + struct StartupPeerAccess { + friend typename Tag::Type StartupPeerMember(Tag) { return member; } + }; + struct StartupControlTag { + using Type = Transport::ITransport* Client::ClientSession::*; + friend Type StartupPeerMember(StartupControlTag); + }; + template struct StartupPeerAccess; + struct StartupInboxTag { + using Type = std::unique_ptr Client::ClientSession::*; + friend Type StartupPeerMember(StartupInboxTag); + }; + template struct StartupPeerAccess; + + class InitialCapsStartup : public testing::Test { + protected: + Client::ClientSession client; + std::unique_ptr controlClient, controlPeer; + std::unique_ptr dataPeer; + const decltype(MG_Config::Ipc) savedIpc = MG_Config::Ipc; + const MG_Config::TransportMode savedTransport = MG_Config::Transport; + + void SetUp() override { + ::alarm(10); + MG_Config::Transport = MG_Config::TransportMode::Spawn; + MG_Config::Ipc.RunAhead = 1; + MG_Config::Ipc.VerbBarrier = 1; + int reserve = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(reserve, 0); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + ASSERT_EQ(::bind(reserve, reinterpret_cast(&address), sizeof address), 0); + socklen_t length = sizeof address; + ASSERT_EQ(::getsockname(reserve, reinterpret_cast(&address), &length), 0); + const std::string endpoint = "tcp://127.0.0.1:" + std::to_string(ntohs(address.sin_port)); + ::close(reserve); + int listener = -1; + ASSERT_EQ(Transport::SocketTransport::Listen(endpoint, &listener), MOBILEGL_OK); + MobileGLResult accepted = MOBILEGL_ERR_TIMEOUT; + std::thread accepting( + [&] { accepted = Transport::SocketTransport::AcceptPair(listener, 2000, controlPeer); }); + const auto connected = Transport::SocketTransport::ConnectTo(endpoint, 2000, controlClient); + accepting.join(); + ::close(listener); + ASSERT_EQ(connected, MOBILEGL_OK); + ASSERT_EQ(accepted, MOBILEGL_OK); + ASSERT_TRUE(controlClient->IsTcp()); + ASSERT_TRUE(controlPeer->IsTcp()); + + Transport::SessionSegmentSizes sizes; + sizes.CmdRingBytes = 4096; + sizes.StageBytes = 4096; + sizes.EventRingBytes = 4096; + sizes.ReplyBytes = 4096; + ASSERT_EQ(client.AttachStreamLink(controlClient->TakeDataFd(), sizes), MOBILEGL_OK); + ASSERT_EQ(Transport::CreateStreamLink(controlPeer->TakeDataFd(), sizes, + Transport::TransportRoleTag::ServerConsumer, dataPeer), + MOBILEGL_OK); + dataPeer->InitializeEndpoints(); + client.*StartupPeerMember(StartupControlTag{}) = controlClient.get(); + client.*StartupPeerMember(StartupInboxTag{}) = std::make_unique(*controlClient); + } + void TearDown() override { + if (client.DataLink()) client.DataLink()->Detach(); + if (dataPeer) dataPeer->Detach(); + client.Stop(); + MG_Config::Ipc = savedIpc; + MG_Config::Transport = savedTransport; + ::alarm(0); + } + MobileGLResult Start() { + return client.FinishStartup(&client.DataLink()->ConsumerBell(), &client.DataLink()->ProducerBell(), false); + } + MobileGLResult SendCaps(Uint64 bits) { + MG_Backend::DynamicBackendParameters dynamic{}; + MG_Backend::FormatCapabilityCache formats{}; + RendererInfo renderer{}; + Vector encodedFormats, encodedRenderer; + if (!EncodeFormatCapabilities(formats, encodedFormats) || !EncodeRendererInfo(renderer, encodedRenderer)) + return MOBILEGL_ERR_PROTOCOL_MISMATCH; + flatbuffers::FlatBufferBuilder builder; + const auto parameters = builder.CreateVector(reinterpret_cast(&dynamic), sizeof dynamic); + const auto rendererBytes = builder.CreateVector(encodedRenderer.data(), encodedRenderer.size()); + const auto formatBytes = builder.CreateVector(encodedFormats.data(), encodedFormats.size()); + const auto version = builder.CreateString("4.6"); + const auto caps = + ::MobileGL::Wire::CreateCapsSnapshot(builder, parameters, rendererBytes, formatBytes, 0, version, bits, + static_cast(BackendType::DirectGLES)); + const auto envelope = + ::MobileGL::Wire::CreateCtrlEnvelope(builder, ::MobileGL::Wire::CtrlMsg::CapsSnapshot, caps.Union()); + ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, envelope); + return controlPeer->SendFrame({builder.GetBufferPointer(), builder.GetSize()}); + } + MobileGLResult SendLog() { + flatbuffers::FlatBufferBuilder builder; + const auto line = ::MobileGL::Wire::CreateLogLine( + builder, ::MobileGL::Wire::LogLevel::Info, + builder.CreateString("initial caps intentionally delayed by test peer")); + const auto envelope = + ::MobileGL::Wire::CreateCtrlEnvelope(builder, ::MobileGL::Wire::CtrlMsg::LogLine, line.Union()); + ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, envelope); + return controlPeer->SendFrame({builder.GetBufferPointer(), builder.GetSize()}); + } + bool PumpSnapshot() { + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(1); + do { + if (client.PumpControlPlane()) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } while (std::chrono::steady_clock::now() < end); + return false; + } + }; + + TEST_F(InitialCapsStartup, DelayedTcpSnapshotArmsRunAheadAndLaterSnapshotsCanOnlyDemote) { + std::atomic startupReturned{false}; + bool returnedBeforeCaps = false; + MobileGLResult logResult = MOBILEGL_ERR_TIMEOUT, capsResult = MOBILEGL_ERR_TIMEOUT; + std::thread sender([&] { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + returnedBeforeCaps = startupReturned.load(std::memory_order_acquire); + logResult = SendLog(); + capsResult = SendCaps(MG_Pipe::kCapRunAheadApply); + }); + const auto result = Start(); + startupReturned.store(true, std::memory_order_release); + sender.join(); + ASSERT_EQ(logResult, MOBILEGL_OK); + ASSERT_EQ(capsResult, MOBILEGL_OK); + EXPECT_FALSE(returnedBeforeCaps) << "startup returned without a real initial snapshot"; + ASSERT_EQ(result, MOBILEGL_OK); + EXPECT_TRUE(client.Started()); + EXPECT_TRUE(client.RunAheadArmed()); + ASSERT_EQ(SendCaps(0), MOBILEGL_OK); + ASSERT_TRUE(PumpSnapshot()); + EXPECT_FALSE(client.RunAheadArmed()); + ASSERT_EQ(SendCaps(MG_Pipe::kCapRunAheadApply), MOBILEGL_OK); + ASSERT_TRUE(PumpSnapshot()); + EXPECT_FALSE(client.RunAheadArmed()) << "a later cap must not re-promote this session"; + } + + TEST_F(InitialCapsStartup, AHealthyPeerWithoutInitialCapsFailsStartupWithoutDeviceLoss) { + const auto before = std::chrono::steady_clock::now(); + EXPECT_EQ(Start(), MOBILEGL_ERR_TIMEOUT); + const auto elapsed = std::chrono::steady_clock::now() - before; + EXPECT_GE(elapsed, std::chrono::seconds(4)); + EXPECT_LT(elapsed, std::chrono::seconds(8)); + EXPECT_FALSE(client.Started()); + EXPECT_FALSE(client.RunAheadArmed()); + EXPECT_FALSE(Client::ClientSession::DeviceLost()); + } + + TEST_F(InitialCapsStartup, ANullInitialSnapshotFailsInsteadOfStartingWithAPlaceholder) { + flatbuffers::FlatBufferBuilder builder; + const auto envelope = ::MobileGL::Wire::CreateCtrlEnvelope(builder, ::MobileGL::Wire::CtrlMsg::CapsSnapshot, + flatbuffers::Offset{}); + ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, envelope); + ASSERT_EQ(controlPeer->SendFrame({builder.GetBufferPointer(), builder.GetSize()}), MOBILEGL_OK); + EXPECT_EQ(Start(), MOBILEGL_ERR_PROTOCOL_MISMATCH); + EXPECT_FALSE(client.Started()); + EXPECT_FALSE(Client::ClientSession::DeviceLost()); + } +} // namespace diff --git a/MobileGL/MG_Test/Wire/PeerLossProbe.cpp b/MobileGL/MG_Test/Wire/PeerLossProbe.cpp new file mode 100644 index 00000000..ecdce853 --- /dev/null +++ b/MobileGL/MG_Test/Wire/PeerLossProbe.cpp @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// Manual P6.5 S2/half-open probe. Never registered as a device-less unit test. +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Remote; +namespace { + struct Options { + bool kill = false; + bool external = false; + unsigned deadlineMs = 10000; + unsigned armTimeoutMs = 120000; + std::string readyFile; + std::string startFile; + }; + void Usage() { + std::fprintf(stderr, "MobileGLPeerLossProbe (--kill | --external) [--deadline-ms 10000]\n" + " [--ready-file PATH] [--start-file PATH] [--arm-timeout-ms 120000]\n" + "MOBILEGL_IPC_CONTROL=tcp://host:port is required; token/build policy use normal env.\n" + "--kill executes MGITEST_PEER_KILL_CMD with {pid} replaced by Welcome.serverPid.\n" + "--external sends no fault command. After READY, create --start-file immediately\n" + "before the parent's Wi-Fi cut; without a start file, type GO on stdin to arm.\n" + "Only descriptor hangup passes. A deadline never sets the device-lost latch.\n"); + } + bool Number(const char* text, unsigned& result) { + char* end = nullptr; + const auto value = std::strtoul(text, &end, 10); + if (!text[0] || !end || *end || !value || value > 600000) return false; + result = static_cast(value); + return true; + } + bool Parse(int argc, char** argv, Options& options) { + for (int i = 1; i < argc; ++i) { + const std::string argument = argv[i]; + if (argument == "--kill") + options.kill = true; + else if (argument == "--external") + options.external = true; + else if (i + 1 < argc && argument == "--deadline-ms") { + if (!Number(argv[++i], options.deadlineMs)) return false; + } else if (i + 1 < argc && argument == "--arm-timeout-ms") { + if (!Number(argv[++i], options.armTimeoutMs)) return false; + } else if (i + 1 < argc && argument == "--ready-file") + options.readyFile = argv[++i]; + else if (i + 1 < argc && argument == "--start-file") + options.startFile = argv[++i]; + else + return false; + } + return options.kill != options.external && (!options.kill || options.startFile.empty()); + } + bool Exists(const std::string& path) { + std::error_code error; + return std::filesystem::exists(path, error); + } + struct SessionCleanup { + Client::ClientSession& session; + ~SessionCleanup() { session.Stop(); } + }; + // The fault helper gets its own process group. It only execs the caller's + // explicit template; killing/reaping this helper never touches a supervisor. + class FaultCommand { + public: + bool Start(const std::string& command) { + m_pid = ::fork(); + if (m_pid == 0) { + if (::setpgid(0, 0) != 0) ::_exit(126); + ::execl("/bin/sh", "sh", "-c", command.c_str(), static_cast(nullptr)); + ::_exit(127); + } + if (m_pid < 0) return false; + (void)::setpgid(m_pid, m_pid); + return true; + } + bool Finished() { + if (m_pid <= 0) return true; + const auto result = ::waitpid(m_pid, &m_status, WNOHANG); + if (result == m_pid) { + m_pid = -1; + m_finished = true; + } + return m_finished; + } + bool Succeeded() const { return m_finished && WIFEXITED(m_status) && WEXITSTATUS(m_status) == 0; } + ~FaultCommand() { + if (m_pid <= 0 || Finished()) return; + (void)::kill(-m_pid, SIGTERM); + for (int i = 0; i != 20 && !Finished(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + if (m_pid > 0) { + (void)::kill(-m_pid, SIGKILL); + (void)::waitpid(m_pid, &m_status, 0); + } + } + + private: + pid_t m_pid = -1; + int m_status = -1; + bool m_finished = false; + }; + bool ArmExternal(const Options& options) { + if (options.startFile.empty()) { + std::string line; + return std::getline(std::cin, line) && line == "GO"; + } + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(options.armTimeoutMs); + while (!Exists(options.startFile)) { + if (std::chrono::steady_clock::now() >= deadline) return false; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + return true; + } +} // namespace + +int main(int argc, char** argv) { + if (argc == 2 && std::strcmp(argv[1], "--help") == 0) { + Usage(); + return 0; + } + Options options; + if (!Parse(argc, argv, options)) { + Usage(); + return 64; + } + const char* endpoint = std::getenv("MOBILEGL_IPC_CONTROL"); + if (!endpoint || std::strncmp(endpoint, "tcp://", 6) != 0) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: MOBILEGL_IPC_CONTROL must name an existing TCP server\n"); + return 64; + } + if ((!options.readyFile.empty() && Exists(options.readyFile)) || + (!options.startFile.empty() && Exists(options.startFile))) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: readiness/start files must be fresh for this run\n"); + return 64; + } + const char* killTemplate = std::getenv("MGITEST_PEER_KILL_CMD"); + if (options.kill && (!killTemplate || !std::strstr(killTemplate, "{pid}"))) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: --kill requires MGITEST_PEER_KILL_CMD containing {pid}\n"); + return 64; + } + // No server is forked by the client. StartSpawned resolves Connect from the + // ordinary configuration, exactly as the integration TCP lane does. + ::setenv("MOBILEGL_TRANSPORT", "spawn", 1); + ::setenv("MOBILEGL_IPC_DATA", "stream", 1); + ::setenv("MOBILEGL_IPC_ROLE", "client", 1); + ::setenv("MOBILEGL_BACKEND_TYPE", "DirectGLES", 0); + MG_ConfigLoader::Init(); + auto& session = Client::ClientSessionInstance(); + SessionCleanup cleanup{session}; + const auto started = session.StartSpawned(); + if (started != MOBILEGL_OK || !session.ConnectDial() || !session.DataLink() || + session.DataLink()->Capabilities().SharedMapping) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: TCP+stream bringup rc=%d\n", started); + return 2; + } + const auto peerPid = session.PeerServerPid(); + auto* bell = session.SelfDoorbellForTest(); + if (peerPid <= 1 || !bell || bell->PeerHungUp() || Client::ClientSession::DeviceLost() || + MG_Impl::GLImpl::GetGraphicsResetStatus() != GL_NO_ERROR) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: no healthy Welcome peer before arming\n"); + return 2; + } + // A real control round-trip finishes before the idle measurement. No GL + // command or application heartbeat is sent during the detection interval. + session.SyncPeerLog(); + if (bell->PeerHungUp() || Client::ClientSession::DeviceLost()) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: peer disappeared before readiness\n"); + return 2; + } + if (!options.readyFile.empty()) { + std::ofstream ready(options.readyFile, std::ios::out | std::ios::trunc); + ready << peerPid << '\n'; + ready.flush(); + if (!ready) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: cannot publish ready file\n"); + return 2; + } + } + std::printf("P65_PEER_LOSS_READY pid=%u mode=%s control=%s data=stream deadline_ms=%u\n", peerPid, + options.kill ? "kill" : "external", endpoint, options.deadlineMs); + std::fflush(stdout); + if (options.external && !ArmExternal(options)) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: external trigger was not armed\n"); + return 3; + } + if (bell->PeerHungUp()) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: peer disappeared before timer arm; timing is invalid\n"); + return 3; + } + std::string command; + if (options.kill) { + command = killTemplate; + const auto pidText = std::to_string(peerPid); + for (std::size_t at = 0; (at = command.find("{pid}", at)) != std::string::npos; at += pidText.size()) + command.replace(at, 5, pidText); + } + const auto start = std::chrono::steady_clock::now(); + const auto deadline = start + std::chrono::milliseconds(options.deadlineMs); + FaultCommand fault; + if (options.kill && !fault.Start(command)) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: could not start the caller's kill command\n"); + return 4; + } + std::printf("P65_PEER_LOSS_ARMED pid=%u mode=%s\n", peerPid, options.kill ? "kill" : "external"); + std::fflush(stdout); + while (!bell->PeerHungUp() && std::chrono::steady_clock::now() < deadline) { + if (options.kill && fault.Finished() && !fault.Succeeded()) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: caller's kill command failed\n"); + return 4; + } + (void)bell->Park(10); + } + const auto hangupAt = std::chrono::steady_clock::now(); + const auto hangupMs = std::chrono::duration_cast(hangupAt - start).count(); + if (!bell->PeerHungUp() || !bell->Dead() || hangupAt >= deadline) { + std::fprintf(stderr, + "P65_PEER_LOSS_FAIL: deadline_ms=%u elapsed_ms=%lld peer_hung_up=%d dead=%d device_lost=%d\n", + options.deadlineMs, static_cast(hangupMs), bell->PeerHungUp(), bell->Dead(), + Client::ClientSession::DeviceLost()); + return 3; + } + if (options.kill) { + while (!fault.Finished() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + if (!fault.Succeeded()) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: kill command did not complete successfully within budget\n"); + return 4; + } + } + // Exercise the production barrier's ShutDown branch, not a test-written + // latch. The peer is already proven gone, so this valid reply-owning record + // cannot reach a server or inspect an invented fence handle there. + const MG_Pipe::MGPHandleOnly fence{{1, 1}, static_cast(MG_Pipe::MGPipeKind::Fence), 0}; + Uint32 reply = 0; + Int32 status = Wire::ReplySink::kStatusError; + session.EmitAndWait(MG_Pipe::MGPWireOp::FenceStatus, &fence, sizeof fence, nullptr, 0, &reply, sizeof reply, + &status); + const auto lostMs = + std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); + const bool lost = Client::ClientSession::DeviceLost(); + const auto reset = MG_Impl::GLImpl::GetGraphicsResetStatus(); + if (!lost || reset != GL_UNKNOWN_CONTEXT_RESET || status != Wire::ReplySink::kStatusDeclined || + lostMs >= options.deadlineMs) { + std::fprintf(stderr, "P65_PEER_LOSS_FAIL: production latch=%d gl_reset=0x%x reply_status=%d elapsed_ms=%lld\n", + lost, reset, status, static_cast(lostMs)); + return 5; + } + std::printf("P65_PEER_LOSS_PASS pid=%u mode=%s peer_hung_up_ms=%lld device_lost_ms=%lld " + "deadline_ms=%u device_lost=1 gl_reset=0x%x source=production-barrier\n", + peerPid, options.kill ? "kill" : "external", static_cast(hangupMs), + static_cast(lostMs), options.deadlineMs, reset); + std::fflush(stdout); + return 0; +} diff --git a/MobileGL/MG_Test/Wire/RemoteClientControls.inc b/MobileGL/MG_Test/Wire/RemoteClientControls.inc index b63fd9d3..d2118fb2 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientControls.inc +++ b/MobileGL/MG_Test/Wire/RemoteClientControls.inc @@ -74,9 +74,12 @@ struct BackendTag { }; template struct PeerAccess; -void StartControlSession() { +MG_Backend::BackendObject* ControlCapsPeer(); + +void StartControlSession(MG_Backend::BackendObject* capsSource = nullptr) { ::alarm(15); MG_Config::Transport = MG_Config::TransportMode::InProcess; + Srv::ServerSessionInstance().SetBackend(capsSource ? capsSource : ControlCapsPeer()); Srv::ServerSessionInstance().SetCapabilityBits(0); Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a); if (ClientSessionInstance().Start(MG_Config::TransportMode::InProcess, {}) != MOBILEGL_OK) @@ -135,6 +138,13 @@ struct CapsPeer : MG_Backend::BackendObject { BackendType GetBackendType() const override { return BackendType::DirectGLES; } }; +MG_Backend::BackendObject* ControlCapsPeer() { + // A real encoded initial snapshot is required even when the apply fixture + // substitutes every verb and has no GPU backend of its own. + static CapsPeer backend; + return &backend; +} + void ExpectNamedAbort(const ChildResult& child, const char* diagnostic) { EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << '\n' << child.Log; EXPECT_NE(child.Log.find(diagnostic), std::string::npos) << child.Log; @@ -387,8 +397,7 @@ TEST(RemoteClientControls, BounceReadPixelsShortReplyRefusesBeforeScatter) { TEST(RemoteClientControls, DeleteTexturesAdvancesTheWireOrdinal) { const auto child = RunInChild([] { CapsPeer backend; - Srv::ServerSessionInstance().SetBackend(&backend); - StartControlSession(); + StartControlSession(&backend); ErrorPeer replies; replies.status = 0; // The peer accepts the resource; the client must publish and delete it. replies.Install(); @@ -442,8 +451,7 @@ TEST(RemoteClientControls, ServerRoleRespecifyDoesNotEmitClientInitialBytes) { // SEG_EVENT producers are what replaces the mechanism this case used to drive. const auto child = RunInChild([] { CapsPeer backend; - Srv::ServerSessionInstance().SetBackend(&backend); - StartControlSession(); + StartControlSession(&backend); MG_Config::Features.PipePush |= kMGPipeSubsystemResources; Srv::ServerLoopInstance().RunProbeOnApplyThreadForTesting([](void*) { MG_State::GLState::BufferObject buffer(701); @@ -475,8 +483,7 @@ TEST(RemoteClientControls, ServerRoleFlushDoesNotRunTheClientSubDataFollowup) { // (Fatal{RoleViolation, "MGPipeSlots"}; ev's events replace the mechanism). const auto child = RunInChild([] { CapsPeer backend; - Srv::ServerSessionInstance().SetBackend(&backend); - StartControlSession(); + StartControlSession(&backend); MG_Config::Features.PipePush |= kMGPipeSubsystemResources; Srv::ServerLoopInstance().RunProbeOnApplyThreadForTesting([](void*) { MGPipeResourceOps ops{}; diff --git a/MobileGL/MG_Test/Wire/RemoteClientE1Controls.inc b/MobileGL/MG_Test/Wire/RemoteClientE1Controls.inc index 47139920..3b41206d 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientE1Controls.inc +++ b/MobileGL/MG_Test/Wire/RemoteClientE1Controls.inc @@ -40,7 +40,7 @@ void CheckE1Boundary(Bool serverCap, MGPWireOp op, Bool expectWait, const char* const Uint64 caps = serverCap ? static_cast(kCapRunAheadApply) : 0; Srv::ServerSessionInstance().SetCapabilityBits(caps); Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a); - AdoptSnapshot(MakeSnapshot(kMGPipeSubsystemsMigratedAtP4a, caps)); + Srv::ServerSessionInstance().SetBackend(ControlCapsPeer()); auto& session = ClientSessionInstance(); if (session.Start(MG_Config::TransportMode::InProcess, {}) != MOBILEGL_OK) ::_exit(81); auto& producer = session.Producer(); diff --git a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp index 59ba97d4..23271f39 100644 --- a/MobileGL/MG_Test/Wire/RemoteClientTest.cpp +++ b/MobileGL/MG_Test/Wire/RemoteClientTest.cpp @@ -2338,19 +2338,8 @@ void StartRunAheadSession() { Srv::ServerSessionInstance().SetCapabilityBits( static_cast(MG_Pipe::kCapRunAheadApply)); Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a); - // THE MIRROR IS ADOPTED BEFORE Start, AND THAT IS THE LATCH'S RULE MADE VISIBLE. This - // process has no server backend, so PublishCapsSnapshot is deferred and the handshake - // carries no snapshot at all - and §1 says a later snapshot may only turn run-ahead OFF, - // never on. So the arm has to be true at the FIRST adoption, which for a fixture means - // before the session takes its latch. A test that adopted afterwards and found the client - // still lockstep would be reading the contract's own rule as a failure. - { - MG_Pipe::MGPCaps caps{}; - caps.CallMask = static_cast(MG_Pipe::kCapRunAheadApply) | - MG_Remote::MGCapsConsumerBits(kMGPipeSubsystemsMigratedAtP4a); - CapsMirrorInstance().Adopt(caps, MG_Backend::FormatCapabilityCache{}, RendererInfo{}, - String{"4.6"}, BackendType::DirectGLES); - } + // The test peer publishes the first snapshot through the real handshake. + Srv::ServerSessionInstance().SetBackend(ControlCapsPeer()); if (ClientSessionInstance().Start(MG_Config::TransportMode::InProcess, {}) != MOBILEGL_OK) { ::_exit(81); } @@ -2411,11 +2400,7 @@ TEST(RemoteRunAhead, ClearingTheVerbBarrierDisarmsRunAheadEvenWithTheCapBit) { Srv::ServerSessionInstance().SetCapabilityBits( static_cast(MG_Pipe::kCapRunAheadApply)); Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a); - MG_Pipe::MGPCaps caps{}; - caps.CallMask = static_cast(MG_Pipe::kCapRunAheadApply) | - MG_Remote::MGCapsConsumerBits(kMGPipeSubsystemsMigratedAtP4a); - CapsMirrorInstance().Adopt(caps, MG_Backend::FormatCapabilityCache{}, RendererInfo{}, - String{"4.6"}, BackendType::DirectGLES); + Srv::ServerSessionInstance().SetBackend(ControlCapsPeer()); if (ClientSessionInstance().Start(MG_Config::TransportMode::InProcess, {}) != MOBILEGL_OK) { ::_exit(81); } diff --git a/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp b/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp index c203ce67..f27f02db 100644 --- a/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp +++ b/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +127,8 @@ TEST(SessionHandshakeTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) { EXPECT_EQ(inputs.MemberLayout, MG_Pipe::kMGPipeWireMemberLayoutDigest); EXPECT_EQ(inputs.CatalogueLayout, MG_Pipe::kMGPipeWireCatalogueDigest); EXPECT_EQ(inputs.RenderStateLayout, MG_Pipe::WireRenderStateDigest()); + EXPECT_EQ(inputs.ProgramArtifactsCodecVersion, MG_State::GLState::kProgramArtifactsCodecVersion); + EXPECT_EQ(inputs.ProgramArtifactsSchema, MG_State::GLState::ProgramArtifactsSchemaFingerprint()); EXPECT_EQ(inputs.OpCount, static_cast(MG_Pipe::MGPWireOp::kOpCount)); EXPECT_EQ(inputs.PointerBits, sizeof(void*) * 8); const auto perturbed = [&](auto mutate) { @@ -142,6 +145,8 @@ TEST(SessionHandshakeTest, TheAbiFingerprintChangesWhenAnyOfItsInputsDoes) { EXPECT_NE(production, perturbed([](auto& i) { ++i.FormatCapabilityFormats; })) << "FormatCapabilityFormats"; EXPECT_NE(production, perturbed([](auto& i) { ++i.FormatCapabilitiesCodecVersion; })) << "FormatCapabilitiesCodecVersion"; EXPECT_NE(production, perturbed([](auto& i) { ++i.RendererInfoCodecVersion; })) << "RendererInfoCodecVersion"; + EXPECT_NE(production, perturbed([](auto& i) { ++i.ProgramArtifactsCodecVersion; })) << "ProgramArtifactsCodecVersion"; + EXPECT_NE(production, perturbed([](auto& i) { ++i.ProgramArtifactsSchema; })) << "ProgramArtifactsSchema"; EXPECT_NE(production, perturbed([](auto& i) { ++i.OpCount; })) << "OpCount"; EXPECT_NE(production, perturbed([](auto& i) { ++i.AbiVersion; })) << "AbiVersion"; EXPECT_NE(production, perturbed([](auto& i) { ++i.PointerBits; })) << "PointerBits"; diff --git a/MobileGL/MG_Test/Wire/SocketTransportTest.cpp b/MobileGL/MG_Test/Wire/SocketTransportTest.cpp index 7af882d2..a626d5c5 100644 --- a/MobileGL/MG_Test/Wire/SocketTransportTest.cpp +++ b/MobileGL/MG_Test/Wire/SocketTransportTest.cpp @@ -174,6 +174,21 @@ TEST(SocketTransportTest, CarriesFramesInBothDirections) { EXPECT_EQ(Receive(*pair.client), welcome); } +TEST(SocketTransportTest, RequestHalfCloseStillDeliversTheFinalReply) { + std::unique_ptr client, server; + ASSERT_EQ(SocketTransport::CreatePair(client, server), MOBILEGL_OK); + ASSERT_EQ(client->SendFrame(Span("last request")), MOBILEGL_OK); + EXPECT_EQ(Receive(*server), "last request"); + ASSERT_EQ(client->ShutdownSend(), MOBILEGL_OK); + std::uint64_t bytes = 0; + char buffer[32]{}; + EXPECT_EQ(server->ReceiveFrame({buffer, sizeof buffer}, &bytes, 1000), MOBILEGL_ERR_TRANSPORT_CLOSED); + ASSERT_EQ(server->SendFrame(Span("cleanup complete")), MOBILEGL_OK); + EXPECT_EQ(Receive(*client), "cleanup complete"); + server->Shutdown(); + EXPECT_EQ(client->ReceiveFrame({buffer, sizeof buffer}, &bytes, 1000), MOBILEGL_ERR_TRANSPORT_CLOSED); +} + TEST(SocketTransportTest, PreservesOrder) { Pair pair = MakePair(); diff --git a/MobileGL/MG_Test/Wire/StreamClientPublicationTest.cpp b/MobileGL/MG_Test/Wire/StreamClientPublicationTest.cpp new file mode 100644 index 00000000..1694c852 --- /dev/null +++ b/MobileGL/MG_Test/Wire/StreamClientPublicationTest.cpp @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// Production session submission with a real stream reader and a deliberately +// non-applying peer. A passing test cannot be supplied by a later client wait. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Remote; +namespace { + // Only the test's control endpoint is injected. A queued real CapsSnapshot + // arms the production latch without a backend, EGL context, or private toggle. + template + struct PublicationPeerAccess { + friend typename Tag::Type PublicationPeerMember(Tag) { return member; } + }; + struct ControlEndpointTag { + using Type = Transport::ITransport* Client::ClientSession::*; + friend Type PublicationPeerMember(ControlEndpointTag); + }; + template struct PublicationPeerAccess; + + class StreamClientPublication : public testing::Test { + protected: + Client::ClientSession client; + std::unique_ptr peer; + std::unique_ptr controlClient, controlPeer; + const decltype(MG_Config::Ipc) savedIpc = MG_Config::Ipc; + const MG_Config::TransportMode savedTransport = MG_Config::Transport; + + void SetUp() override { + // A regression that turns submission into an applied wait fails + // promptly instead of consuming the production 120-second budget. + ::alarm(5); + MG_Config::Transport = MG_Config::TransportMode::Spawn; + MG_Config::Ipc.RunAhead = 1; + MG_Config::Ipc.VerbBarrier = 1; + MG_Config::Ipc.BatchWaits = 1; + MG_Config::Ipc.PresentCredit = 3; + Transport::SessionSegmentSizes sizes; + sizes.CmdRingBytes = 4096; + sizes.StageBytes = 4096; + sizes.EventRingBytes = 4096; + sizes.ReplyBytes = 4096; + int sockets[2]; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets), 0); + ASSERT_EQ(AttachData(sockets, sizes), MOBILEGL_OK); + Transport::InProcessTransport::CreatePair(controlClient, controlPeer); + client.*PublicationPeerMember(ControlEndpointTag{}) = controlClient.get(); + + MG_Backend::DynamicBackendParameters dynamic{}; + MG_Backend::FormatCapabilityCache formats{}; + RendererInfo renderer{}; + Vector encodedFormats, encodedRenderer; + ASSERT_TRUE(EncodeFormatCapabilities(formats, encodedFormats)); + ASSERT_TRUE(EncodeRendererInfo(renderer, encodedRenderer)); + flatbuffers::FlatBufferBuilder builder; + const auto dynamicBytes = builder.CreateVector(reinterpret_cast(&dynamic), sizeof dynamic); + const auto rendererBytes = builder.CreateVector(encodedRenderer.data(), encodedRenderer.size()); + const auto formatBytes = builder.CreateVector(encodedFormats.data(), encodedFormats.size()); + const auto version = builder.CreateString("4.6"); + const auto caps = ::MobileGL::Wire::CreateCapsSnapshot(builder, dynamicBytes, rendererBytes, formatBytes, 0, + version, MG_Pipe::kCapRunAheadApply, + static_cast(BackendType::DirectGLES)); + const auto envelope = + ::MobileGL::Wire::CreateCtrlEnvelope(builder, ::MobileGL::Wire::CtrlMsg::CapsSnapshot, caps.Union()); + ::MobileGL::Wire::FinishCtrlEnvelopeBuffer(builder, envelope); + ASSERT_EQ(controlPeer->SendFrame({builder.GetBufferPointer(), builder.GetSize()}), MOBILEGL_OK); + ASSERT_EQ( + client.FinishStartup(&client.DataLink()->ConsumerBell(), &client.DataLink()->ProducerBell(), false), + MOBILEGL_OK); + ASSERT_TRUE(client.RunAheadArmed()); + } + void TearDown() override { + // The peer intentionally never applies. Closing delivery before + // teardown prevents its drain from supplying the assertion. + if (client.DataLink()) client.DataLink()->Detach(); + if (peer) peer->Detach(); + client.Stop(); + MG_Config::Ipc = savedIpc; + MG_Config::Transport = savedTransport; + ::alarm(0); + } + virtual MobileGLResult AttachData(int sockets[2], const Transport::SessionSegmentSizes& sizes) { + const auto result = client.AttachStreamLink(sockets[0], sizes); + if (result != MOBILEGL_OK) { + ::close(sockets[1]); + return result; + } + const auto attached = + Transport::CreateStreamLink(sockets[1], sizes, Transport::TransportRoleTag::ServerConsumer, peer); + if (attached == MOBILEGL_OK) peer->InitializeEndpoints(); + return attached; + } + bool Upload(std::size_t count, unsigned char fill) { + std::vector bytes(count, fill); + MG_Pipe::MGPSubData record{}; + record.Res = {1, 1}; + record.Target = MG_Pipe::kMGPipeResourceTargetBuffer; + record.UnionBox.W = static_cast(count); + record.UnionBox.H = record.UnionBox.D = 1; + return MG_Pipe::MGPipeRouteResourceSubData(record, bytes.data(), bytes.size()); + } + bool PeerGone() { + auto& bell = client.DataLink()->ProducerBell(); + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(1); + while (!bell.PeerHungUp() && std::chrono::steady_clock::now() < end) + (void)bell.Park(1); + return bell.PeerHungUp(); + } + bool StageIs(unsigned char fill) { + const auto& memory = client.DataLink()->Memory(); + const auto* bytes = static_cast(memory.StageBase()); + for (Uint64 i = 0; i < memory.StageBytes(); ++i) + if (bytes[i] != fill) return false; + return true; + } + bool ReceivedPublishedPrefix() { + const auto head = client.DataLink()->Signals().CmdHead->load(std::memory_order_acquire); + const auto signals = peer->Signals(); + return peer->ConsumerBell().Wait( + *signals.ConsumerParked, [&] { return signals.CmdHead->load(std::memory_order_acquire) >= head; }, 0, + 1000); + } + }; + + TEST_F(StreamClientPublication, AllThreePresentCreditsReachAnIdlePeerWithoutAnyClientWait) { + for (Uint64 serial = 1; serial <= 3; ++serial) { + MG_Pipe::MGPPresent present{}; + present.FrameSerial = client.AcquirePresentCredit(); + ASSERT_EQ(present.FrameSerial, serial); + client.EmitAndWait(MG_Pipe::MGPWireOp::Present, &present, sizeof present, nullptr, 0, nullptr, 0, nullptr); + ASSERT_TRUE(ReceivedPublishedPrefix()) << "present " << serial << " remained in the local batch"; + } + EXPECT_EQ(client.Producer().Waits(), 0u); + EXPECT_EQ(client.PresentCreditWaits(), 0u); + EXPECT_EQ(client.DataLink()->Progress()->appliedSeq.load(), 0u); + for (int record = 0; record != 3; ++record) { + Transport::RingRecordView view{}; + ASSERT_TRUE(peer->CommandsIn().Pop(view)); + EXPECT_EQ(view.kind, static_cast(MG_Pipe::MGPWireOp::Present)); + } + } + + TEST_F(StreamClientPublication, ExportedGlFlushDeliversPublishedCommandsWithoutWaitingForApplied) { + MG_Pipe::MGPMemoryBarrier barrier{}; + client.EmitAndWait(MG_Pipe::MGPWireOp::MemoryBarrier, &barrier, sizeof barrier, nullptr, 0, nullptr, 0, + nullptr); + ASSERT_EQ(client.LastPublishedSeq(), 1u); + EXPECT_EQ(peer->Signals().CmdHead->load(), 0u); + ::glFlush(); + ASSERT_TRUE(ReceivedPublishedPrefix()); + EXPECT_EQ(client.Producer().Waits(), 0u); + EXPECT_EQ(client.DataLink()->Progress()->appliedSeq.load(), 0u); + Transport::RingRecordView view{}; + ASSERT_TRUE(peer->CommandsIn().Pop(view)); + EXPECT_EQ(view.kind, static_cast(MG_Pipe::MGPWireOp::MemoryBarrier)); + } + + TEST_F(StreamClientPublication, ADisconnectedPeerDeclinesALargeUploadWithoutCopyOrWireOrdinal) { + std::memset(client.DataLink()->Memory().StageBase(), 0x33, 4096); + peer->Detach(); + ASSERT_TRUE(PeerGone()); + // Larger than the whole window: cancellation must precede allocation, + // capacity checks and memcpy. The live path would refuse this request. + EXPECT_FALSE(Upload(32768, 0x99)); + EXPECT_TRUE(Client::ClientSession::DeviceLost()); + EXPECT_EQ(MG_Impl::GLImpl::GetGraphicsResetStatus(), GL_UNKNOWN_CONTEXT_RESET); + EXPECT_TRUE(client.Encoder().Cancelled()); + EXPECT_EQ(client.Encoder().EmitSeq(), 0u); + EXPECT_EQ(client.LastPublishedSeq(), 0u); + EXPECT_TRUE(StageIs(0x33)); + // A second local decline still consumes a nonzero MintReplySlot ticket. + EXPECT_FALSE(Upload(16, 0x88)); + EXPECT_EQ(client.Encoder().EmitSeq(), 0u); + MG_Pipe::MGPMemoryBarrier record{}; + EXPECT_EQ(client.Encoder().EncodeRecord(MG_Pipe::MGPWireOp::MemoryBarrier, &record, sizeof record, + static_cast(nullptr), Uint64{0}), + MG_Remote::Wire::kInvalidSeq); + EXPECT_TRUE(StageIs(0x33)); + } + + class StreamClientStagingProcess : public StreamClientPublication { + protected: + pid_t peerPid = -1; + MobileGLResult AttachData(int sockets[2], const Transport::SessionSegmentSizes& sizes) override { + int ready[2]; + if (::pipe(ready) != 0) { + ::close(sockets[0]); + ::close(sockets[1]); + return MOBILEGL_ERR_NOT_INITIALIZED; + } + // Fork before attaching either stream, so the child starts its own + // real reader and inherits no running thread or live link mutex. + peerPid = ::fork(); + if (peerPid == 0) { + ::close(ready[0]); + ::close(sockets[0]); + std::unique_ptr link; + const auto result = + Transport::CreateStreamLink(sockets[1], sizes, Transport::TransportRoleTag::ServerConsumer, link); + if (result == MOBILEGL_OK) link->InitializeEndpoints(); + const unsigned char success = result == MOBILEGL_OK; + (void)::write(ready[1], &success, 1); + ::close(ready[1]); + if (!success) ::_exit(81); + for (;;) + ::pause(); // Reader receives; the peer deliberately never applies. + } + ::close(ready[1]); + ::close(sockets[1]); + unsigned char success = 0; + const auto received = peerPid > 0 ? ::read(ready[0], &success, 1) : -1; + ::close(ready[0]); + if (received != 1 || !success) { + ::close(sockets[0]); + return MOBILEGL_ERR_NOT_INITIALIZED; + } + return client.AttachStreamLink(sockets[0], sizes); + } + void TearDown() override { + if (peerPid > 0) { + (void)::kill(peerPid, SIGKILL); + (void)::waitpid(peerPid, nullptr, 0); + peerPid = -1; + } + StreamClientPublication::TearDown(); + } + }; + + TEST_F(StreamClientStagingProcess, APeerKilledDuringAFullStageWaitDeclinesWithoutCopyingTheNextBlob) { + ASSERT_TRUE(Upload(4096, 0x5a)); + ASSERT_EQ(client.Encoder().EmitSeq(), 1u); + client.Flush(); + auto* parked = client.DataLink()->Signals().ProducerParked; + std::atomic killedWhileParked{false}; + std::thread killer([&] { + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (!parked->load(std::memory_order_acquire) && std::chrono::steady_clock::now() < end) + std::this_thread::yield(); + killedWhileParked.store(parked->load(std::memory_order_acquire) != 0, std::memory_order_release); + (void)::kill(peerPid, SIGKILL); + }); + const bool accepted = Upload(64, 0xa7); + killer.join(); + EXPECT_TRUE(killedWhileParked.load()); + EXPECT_FALSE(accepted); + EXPECT_TRUE(Client::ClientSession::DeviceLost()); + EXPECT_EQ(MG_Impl::GLImpl::GetGraphicsResetStatus(), GL_UNKNOWN_CONTEXT_RESET); + EXPECT_GT(client.Encoder().StageReclaimWaits(), 0u); + EXPECT_EQ(client.Encoder().EmitSeq(), 1u); + EXPECT_EQ(client.LastPublishedSeq(), 1u); + EXPECT_TRUE(StageIs(0x5a)); + } + + class HealthyStageTimeoutHarness : public StreamClientPublication { + public: + void TestBody() override {} + void Run() { + SetUp(); + client.Encoder().SetStageWaitTimeoutMs(5); + if (!Upload(4096, 0x5a)) ::_exit(82); + (void)Upload(64, 0xa7); + ::_exit(83); + } + }; + + TEST(StreamClientCancellationDeath, AHealthyPeerStageTimeoutDoesNotInventDeviceLoss) { + EXPECT_EXIT( + { + ::signal( + SIGABRT, +[](int) { + const bool lost = Client::ClientSession::DeviceLost(); + const char* text = lost ? "stage-timeout-device-lost=1\n" : "stage-timeout-device-lost=0\n"; + (void)::write(STDERR_FILENO, text, sizeof("stage-timeout-device-lost=0\n") - 1); + ::_exit(lost ? 91 : 72); + }); + HealthyStageTimeoutHarness harness; + harness.Run(); + }, + testing::ExitedWithCode(72), "RetirementWaitFailed(.|\n)*stage-timeout-device-lost=0"); + } +} // namespace diff --git a/MobileGL/MG_Test/Wire/StreamLinkTest.cpp b/MobileGL/MG_Test/Wire/StreamLinkTest.cpp index 47800bc5..32b13ff4 100644 --- a/MobileGL/MG_Test/Wire/StreamLinkTest.cpp +++ b/MobileGL/MG_Test/Wire/StreamLinkTest.cpp @@ -8,6 +8,9 @@ #include #include #include +#include +#include +#include using namespace MobileGL::MG_Remote::Transport; namespace { @@ -64,18 +67,28 @@ namespace { Queue(1, 24); ASSERT_EQ(client.Flush(), MOBILEGL_OK); ASSERT_EQ(apply.WaitForWork(2000), SessionWait::Reached); - ASSERT_TRUE(apply.ApplyOne([&](const RingRecordView& v) { + RingRecordView view; + ASSERT_TRUE(consumer.Pop(view)); + [&](const RingRecordView& v) { std::uint64_t offset; std::memcpy(&offset, v.payload, sizeof offset); const void* p = nullptr; ASSERT_EQ(server.ResolveSpan(LinkSegment::Stage, {offset, bytes.size()}, &p), MOBILEGL_OK); EXPECT_EQ(std::memcmp(p, bytes.data(), bytes.size()), 0); - })); - apply.RetireThrough(1); + }(view); + // Advance locally without a periodic-progress callback: this isolates + // flush-on-idle so removing it cannot be masked by the one-ms timer. + Watermark::AdvanceApplied(*serverMemory.CmdControl(), 1); + Watermark::AdvanceRetired(*serverMemory.CmdControl(), 1); + consumer.PublishRetired(); // This is the production idle discipline, not a polling sender thread. EXPECT_EQ(apply.WaitForWork(1), SessionWait::TimedOut); EXPECT_EQ(publish.WaitForApplied(1, 2000), SessionWait::Reached); - EXPECT_EQ(client.Progress()->retiredSeq.load(), 1u); + // applied and retired are independent watermarks. The reader publishes + // them in dependency order, and an applied waiter can run between stores. + EXPECT_TRUE(client.ProducerBell().Wait( + clientMemory.CmdControl()->producerParked, + [&] { return client.Progress()->retiredSeq.load(std::memory_order_acquire) >= 1; }, 0, 2000)); } TEST_F(StreamPair, RetiredByteCursorCrossesManyWrapsWithoutStageRingCursors) { @@ -152,6 +165,109 @@ namespace { EXPECT_EQ(publish.WaitForApplied(10, 2000), SessionWait::ShutDown); } + TEST_F(StreamPair, PeerEofReleasesAnAlreadyWaitingReplyReader) { + std::atomic finished{false}; + MobileGLResult result = MOBILEGL_OK; + std::thread waiting([&] { + std::int32_t status; + const void* payload; + std::uint64_t size; + result = client.ReadReply(1, &status, &payload, &size); + finished.store(true, std::memory_order_release); + }); + server.Detach(); + EXPECT_TRUE(Eventually([&] { return finished.load(std::memory_order_acquire); })); + waiting.join(); + EXPECT_EQ(result, MOBILEGL_ERR_TRANSPORT_CLOSED); + } + + TEST_F(StreamPair, AppliedWithoutItsReplyIsRefusedWithoutAnUnboundedWait) { + Queue(1, 1); + ASSERT_EQ(client.Flush(), MOBILEGL_OK); + ASSERT_EQ(apply.WaitForWork(2000), SessionWait::Reached); + ASSERT_TRUE(apply.ApplyOne([](const RingRecordView&) {})); + apply.RetireThrough(1); + ASSERT_EQ(server.FlushProgress(), MOBILEGL_OK); + ASSERT_EQ(publish.WaitForApplied(1, 2000), SessionWait::Reached); + std::int32_t status; + const void* p; + std::uint64_t size; + EXPECT_EQ(client.ReadReply(1, &status, &p, &size), MOBILEGL_ERR_PROTOCOL_MISMATCH); + } + + TEST_F(StreamPair, ControlFenceWaitsForEventBytesOnTheIndependentDataConnection) { + EventRingProducer events(serverMemory.EventControl(), serverMemory.CmdControl(), serverMemory.EventRingBase(), + 1024); + auto* payload = events.Reserve(kEventGlError, 8); + ASSERT_NE(payload, nullptr); + std::memset(payload, 0, 8); + events.Ring().Publish(); + const auto fence = server.EventPublishedHead(); + EXPECT_EQ(client.WaitForEventDelivery(fence, 1), MOBILEGL_ERR_TIMEOUT); + ASSERT_EQ(server.Flush(), MOBILEGL_OK); + EXPECT_EQ(client.WaitForEventDelivery(fence, 2000), MOBILEGL_OK); + EXPECT_GE(client.EventPublishedHead(), fence); + } + + TEST(StreamLinkTcp, RecordsSixtyFourMiBStageBurstThroughputOnRealLoopbackTcp) { + int listener = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(listener, 0); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + ASSERT_EQ(::bind(listener, reinterpret_cast(&address), sizeof address), 0); + ASSERT_EQ(::listen(listener, 1), 0); + socklen_t addressBytes = sizeof address; + ASSERT_EQ(::getsockname(listener, reinterpret_cast(&address), &addressBytes), 0); + int clientFd = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_GE(clientFd, 0); + ASSERT_EQ(::connect(clientFd, reinterpret_cast(&address), sizeof address), 0); + int serverFd = ::accept(listener, nullptr, nullptr); + ASSERT_GE(serverFd, 0); + ::close(listener); + int enabled = 1; + ASSERT_EQ(::setsockopt(clientFd, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof enabled), 0); + ASSERT_EQ(::setsockopt(serverFd, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof enabled), 0); + SessionSegmentSizes sizes; + sizes.CmdRingBytes = 1024; + sizes.EventRingBytes = 1024; + sizes.StageBytes = 64ull * 1024 * 1024; + sizes.ReplyBytes = 4096; + std::unique_ptr client, server; + ASSERT_EQ(CreateStreamLink(clientFd, sizes, TransportRoleTag::ClientProducer, client), MOBILEGL_OK); + ASSERT_EQ(CreateStreamLink(serverFd, sizes, TransportRoleTag::ServerConsumer, server), MOBILEGL_OK); + client->InitializeEndpoints(); + server->InitializeEndpoints(); + std::memset(client->Memory().StageBase(), 0x5a, static_cast(sizes.StageBytes)); + client->NoteStage({0, sizes.StageBytes}); + SessionProducer producer; + producer.Attach(*client, 0); + SessionConsumer consumer; + consumer.Attach(*server, 0); + auto* record = client->CommandsOut().Reserve(1, 0, sizeof(std::uint64_t)); + ASSERT_NE(record, nullptr); + std::memcpy(record, &sizes.StageBytes, sizeof(std::uint64_t)); + producer.PublishAndNotify(1); + const auto start = std::chrono::steady_clock::now(); + ASSERT_EQ(client->Flush(), MOBILEGL_OK); + ASSERT_EQ(consumer.WaitForWork(10000), SessionWait::Reached); + ASSERT_TRUE(consumer.ApplyOne([&](const RingRecordView&) { + const auto* bytes = static_cast(server->Memory().StageBase()); + for (std::uint64_t offset = 0; offset < sizes.StageBytes; offset += 4096) + EXPECT_EQ(bytes[offset], 0x5a); + EXPECT_EQ(bytes[sizes.StageBytes - 1], 0x5a); + })); + consumer.RetireThrough(1); + ASSERT_EQ(server->FlushProgress(), MOBILEGL_OK); + ASSERT_EQ(producer.WaitForApplied(1, 10000), SessionWait::Reached); + const double elapsed = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + const double mibPerSecond = 64.0 / elapsed; + RecordProperty("tcp_stage_bytes", std::to_string(sizes.StageBytes)); + RecordProperty("tcp_stage_mib_per_second", std::to_string(mibPerSecond)); + std::printf("P6.5 Stage burst control=none data=tcp-loopback bytes=%llu seconds=%.6f MiB/s=%.3f\n", + static_cast(sizes.StageBytes), elapsed, mibPerSecond); + } + void WriteFrame(int fd, unsigned kind, std::uint64_t a, const void* payload, std::uint32_t size) { unsigned char h[28]{}; auto put = [&](unsigned at, std::uint64_t value, unsigned width) { @@ -159,7 +275,7 @@ namespace { h[at + i] = value >> (8 * i); }; put(0, 0x444c474d, 4); - put(4, size, 4); + put(4, size + 20, 4); h[8] = kind; put(12, a, 8); ASSERT_EQ(::send(fd, h, sizeof h, MSG_NOSIGNAL), sizeof h); diff --git a/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java b/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java index 7285c7e6..7b0ddeb9 100644 --- a/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java +++ b/android-plugin/app/src/trace/java/top/mobilegl/plugin/MobileGLServerService.java @@ -65,7 +65,10 @@ public final class MobileGLServerService extends Service { env.put("MOBILEGL_IPC_ROLE", "server"); env.put("MOBILEGL_IPC_DIAL", "no"); env.put("MOBILEGL_IPC_LOG_FORWARD", "1"); - env.remove("MOBILEGL_IPC_CONTROL"); + for (String name : new String[]{"MOBILEGL_TRANSPORT", "MOBILEGL_IPC_CONTROL", "MOBILEGL_IPC_ENDPOINT", + "MOBILEGL_IPC_SERVER_PATH", "MOBILEGL_IPC_RING_MB", "MOBILEGL_IPC_STAGE_MB"}) { + env.remove(name); + } env.put("MOBILEGL_IPC_TOKEN", intent == null || intent.getStringExtra("token") == null ? "" : intent.getStringExtra("token")); env.putIfAbsent("MOBILEGL_LOG_FILE_PATH", new File(getFilesDir(), "mgl.log").getAbsolutePath()); diff --git a/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md b/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md index c470d741..dfc9fab7 100644 --- a/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md +++ b/docs/Disaggregated/CURRENT_STAGE_PROGRESS.md @@ -1,4 +1,28 @@ -# 当前阶段进度 — P6:backend 跑在第二个进程里 +# 当前阶段进度 — P6.5:全 TCP 传输 + +## P6.5 第一波(2026-09-22) + +实现与主要正确性门已完成,完整 retrace 与余下链路性能数仍在采证,**尚未宣布阶段收官**。 +当前工作基于远端 `e6c51d07`,在 `codex/p65-all-tcp` 工作树保留后续未提交修改。 + +- TCP 控制、ShmLink/StreamLink 数据面、布局指纹、portable program archive v2、令牌/Refuse、 + 设备 supervisor/前台服务、日志前送与有序关闭均已接线。 +- 初始 caps 到达前误锁为 lockstep 的启动竞态已修复。READY7 主机 TCP **102/102**、 + 优化构建 Redmi TCP **102/102**,零 skip;两臂逐例确认 **run-ahead ARMED**,无 fallback/demotion。 + 保留车道此前为 spawn **102/102**、inproc **186/186**,零 skip。 +- G1 符号 **0/0/0/0**,`.text` 逐字一致;真实远端 kill **133 ms**、真实 Wi-Fi 中断 **5053 ms** 闩住 device loss。 +- 原有单角色日志读取的 CI 缺陷已与上游修复合并;模拟器掉线分类另有回归验证。 +- 正式 retrace 以 CI loader 的 **39 项** split 子集为准;`ci:false` 的 rd12 单列作性能测量。 + 早期 lockstep 样本中 OpenRA SSIM **1.0**、startup **0.999999511**,不替代修复后的完整矩阵。 + 正在以支持进度超时、进程组清理、实际 run-ahead 证明和可核验 checkpoint 的跑器完成矩阵。 + +契约见 [`CONTRACT-P65.md`](../../MobileGL/MG_Remote/CONTRACT-P65.md), +制品身份与证据总索引见 [`evidence-index.md`](notes/p65/evidence-index.md); +具体结果与未决项见 [`validation-status.md`](notes/p65/validation-status.md)、 +[`wire-and-validation.md`](notes/p65/wire-and-validation.md)、 +[`performance.md`](notes/p65/performance.md)。以下保留 P6 收官时的原始进度快照。 + +## P6 收官快照 > **这份文档只记当前阶段(P6)。** 已收官阶段(P0…P5f)的逐门数字与证据在 > [`ROADMAP.md`](ROADMAP.md) 的阶段表与各 `P5*` 报告里,不在这里重复。规范契约见 diff --git a/docs/Disaggregated/ROADMAP.md b/docs/Disaggregated/ROADMAP.md index 4d378246..4b6f210a 100644 --- a/docs/Disaggregated/ROADMAP.md +++ b/docs/Disaggregated/ROADMAP.md @@ -50,9 +50,11 @@ CTS 周转单独计价(`gl44to46` 约 56,271 例):逐阶段只跑该阶段 ## 立即的下一项:P6.5 第一波 —— 全 TCP 传输(client 在 WSL,server 在 adb 设备) -> 状态:**设计已定(2026-09-22),未实现。** 目标形态:x86_64 的 WSL 进程作为 client,Redmi `2f7cbe2e`(aarch64,Adreno)上的 server 进程作为 server,两者在同一局域网内经 **TCP** 连接;`integration` 全部用例与 CI retrace 的 split 子集在这条链路上跑通并对上 golden。本节是 P6.5 行的第一波,只做「控制面 TCP + 数据面 stream」这一对;同机的「TCP 控制 + 共享段数据」混搭(nd `auto` / sd)是第二波,本节不做。 +> 状态:**实现已接线,验收采证中(2026-09-22)。** 主机三臂与 Redmi TCP 102 项已全通过,G1 与真实断线门通过;完整 retrace 与余下链路性能数见 [`CURRENT_STAGE_PROGRESS.md`](CURRENT_STAGE_PROGRESS.md),尚未宣布阶段收官。目标形态:x86_64 的 WSL 进程作为 client,Redmi `2f7cbe2e`(aarch64,Adreno)上的 server 进程作为 server,两者在同一局域网内经 **TCP** 连接;`integration` 全部用例与 CI retrace 的 split 子集在这条链路上跑通并对上 golden。本节是 P6.5 行的第一波,只做「控制面 TCP + 数据面 stream」这一对;同机的「TCP 控制 + 共享段数据」混搭(nd `auto` / sd)是第二波,本节不做。 -### 1 树上已有的(不重写)与必须先补的(不能绕) +### 1 设计时的树上基线(实现前快照) + +以下表格保留本波开工前的缺口定位;当前实现与契约更正见 `MG_Remote/CONTRACT-P65.md` 和阶段进度,不以历史行号描述今天的树。 | 已有 | 出处 | 对本波的意义 | |---|---|---| @@ -116,7 +118,7 @@ server 在手机上**监听**,client 从 WSL **拨出**。理由不是偏好 6. red-once:wf 三条(P6.5 行);ct——错令牌 → Refuse、`wireMajor` 不匹配 → 两端具名拒绝、`adb shell svc wifi disable` → 10 s 内闩住(而不是 120 s `BarrierTimeout`);sl——`Progress` 倒退 → `Fatal{ProtocolCorruption}`、越窗的 `Stage` 帧 → 拒绝、去掉 flush-on-idle 后「帧内最后一条 `kWaitReply`」用例必须挂住并被超时抓到;lf——关掉前送后四个 arming 场景必须**按名红**;sv——第二个并发连接 → `Refuse{Busy}`。 7. **必测数(记录)**:逐帧 `kWaitReply` 次数与 RTT 分布(OpenRA、MC in-world 各一);`SEG_STAGE` 字节/帧对链路实测吞吐(车道自己量一次 64 MiB `Stage` 突发);`PRESENT_CREDIT` 1 / 2 / 3 的帧率;loopback tcp+stream 对 spawn(unix+shm)的逐线程 CPU 差。 -### 6 两边怎么配(落地后的操作手册;今天还跑不了) +### 6 两边怎么配(第一波操作手册) **手机(server)** ```bash @@ -125,7 +127,7 @@ server 在手机上**监听**,client 从 WSL **拨出**。理由不是偏好 # 2. 手机 IP adb shell ip -f inet addr show wlan0 # 3. 起 server(前台 Service → supervisor 监听) -adb shell am start-foreground-service -n top.mobilegl.plugin.trace/.MobileGLServerService \ +adb shell am start-foreground-service -n top.mobilegl.plugin.trace/top.mobilegl.plugin.MobileGLServerService \ --es listen tcp://0.0.0.0:40613 --es token devtoken \ --es env "MOBILEGL_BACKEND_TYPE=DirectGLES;MOBILEGL_PIPE_STATS=1;MOBILEGL_PIPE_STATS_PERIOD=1;MOBILEGL_LOG_FILE_PATH=/data/data/top.mobilegl.plugin.trace/files/mgl.log" adb logcat -s MobileGL | grep -m1 'listening on tcp://' diff --git a/docs/Disaggregated/notes/p65/code-review-findings.md b/docs/Disaggregated/notes/p65/code-review-findings.md new file mode 100644 index 00000000..84634c7e --- /dev/null +++ b/docs/Disaggregated/notes/p65/code-review-findings.md @@ -0,0 +1,48 @@ +# P6.5 第一波 代码审查(4 切片,2026-09-22) + +审查对象:`codex/p65-all-tcp` HEAD `52ffc6a7` + 未提交工作树(含 archive v2 / Present-glFlush 尾批 / 取消 / half-close 修复)。 +四个并行 Opus 审查各读设计(ROADMAP §51–116)后逐文件核验。**结论:数据面、wire 布局、握手三块无硬正确性缺陷;实现整体谨慎、扎实。** 下表按严重度排序,两项已修,其余记为跟进。 + +| 严重度 | 发现 | 出处 | 处置 | +|---|---|---|---| +| **Medium** | `AcceptPair` 把可重试的 `accept()` 错误(`EINTR` / `ECONNABORTED`)当致命 → supervisor 映射为 `return 73`,整台常驻 server 退出。LAN 对端在 accept 窗口内 reset、或信号(本进程按构造收 SIGCHLD)打断 `accept()` 都会拆掉 server;与兄弟函数 `WaitReadable` 刻意重试 `EINTR` 自相矛盾。潜伏——设备 102 条顺序会话已过,未触发。 | `SocketTransport.cpp` AcceptPair | **已修**:`EINTR`/`ECONNABORTED` 在 budget 内重新 poll,不返回 `TRANSPORT_CLOSED` | +| **Moderate** | `MobileGLServerService.onDestroy()` 只 `destroy()` supervisor PID;在飞的 fork 会话 child(无新 session/group)被 reparent 到 init,泄漏进程 + EGL 上下文。仅 `am force-stop`(按 UID 杀)能清。验收走 force-stop、CI 走 killpg,故门路径干净;属 Android 生命周期 / P12。 | `MobileGLServerService.java:96-102`, `ServerMain.cpp:292` | 跟进(P12 生命周期);本波验收不受影响 | +| Low | supervisor 强检 `MOBILEGL_IPC_DIAL=no` 却不设/不检 `MOBILEGL_IPC_ROLE`;launcher 漏设会把 supervisor 主线程 server 日志误记为 client 且不前送。潜伏(四个 launcher 都设了)。 | `ServerMain.cpp` | **已修**:`mobilegl_server_main` 顶部自设 `MOBILEGL_IPC_ROLE=server`(首日志前,fork 继承) | +| Low | 设计要求的裸 `char` 符号性普查未在 `gen_pipe.py` 实现;成员摘要抓不到(`sizeof`/`offsetof` 两 ABI 相同)。**当前无实活风险**——所有 wire 子-int 成员都是定符号 `Int8`/`Uint8`。 | `scripts/gen_pipe.py` | 跟进:加 census 守卫 | +| Low | `DynamicBackendParameters` / `RenderStateParameters` 只按整体 `sizeof` + 分块边界 offset 入指纹,非逐成员。安全——成员全定宽(无 `long`/`size_t`/指针/位域)。 | `MGPipeRenderStateSpans.h`, `CapsCodec.cpp` | 跟进(可选):逐成员 offset 入摘要 | +| Low | client `eventRingFull` 在 drain 后可能被在飞 `ProgressFrame` 短暂重置;自愈(下个 progress 带 `full=0`,1ms timer 兜底),非挂起。 | `StreamLink.cpp:293` | 跟进:加 latch-only 注释/守卫 | +| Low | `StreamLink` 的 `RecordArena/RecordCursor/EventArena/EventCursor` attach 时冻结 `LocalHead=0`,与 ILink 性能契约不符;当前无 cpp 消费者,无害。 | `StreamLink.cpp:342-345,399-409` | 跟进:随首个消费者刷新或删访问器 | +| 测试缺口 | 无测试断言 keepalive 调优值(KEEPIDLE/INTVL/CNT/USER_TIMEOUT)实际生效;`StreamLinkTest` 不强制跨 recv 边界的帧头拆分,且漏 Cmd 越窗 / reply-seq 倒退 / ProgressFrame 一致性三条 Fatal。 | `SocketTransportTest.cpp`, `StreamLinkTest.cpp` | 跟进:补断言 | + +## 已核验干净(各切片焦点) + +- 数据面:帧重组 / 短读 / iov 回绕 / EOF;水位单调 + late-never-early 接收侧强制(倒退 `Fatal{ProtocolCorruption}`);单邮箱 reply seq 匹配(applied 越过无 reply → `PROTOCOL_MISMATCH`);park 前 `Flush` + flush-on-idle 进度;event 背压(`head − 对端 tail > cap` 阻塞);`ResolveSpan` 指向持久镜像段(非瞬态重组 buffer,无悬垂);单 reader vs apply vs encode 线程安全;EOF→`Die(peer)`→`PeerHungUp`→device-lost 闩(clean detach 不误闩)。 +- 控制面:`tcp://` 解析(IPv6 `[h]:p`、缺/坏端口、port 0/>65535 全拒);四个 socket 选项全设且查返回值,控制与数据连接都设 keepalive;掉线经 keepalive/USER_TIMEOUT(~5s)→挂断(非 120s barrier);握手零 abort(每条拒绝走具名 `Refuse`,server `_exit(0)`,client 不误回落 monolith);token loopback-only-when-no-token;指纹策略按 Dial(Fork 拒 / Connect 警告 / REQUIRE_SAME_BUILD 拒);fd 泄漏各失败分支已闭。 +- wire:`gen_pipe.py` 生成与 `PipeFields.def` 同步由 CI `git diff --exit-code` 守;指纹成员齐、删 `GLFunctionsTable`;两端同一 endian-无关计算(非结构体 memcpy 哈希);定宽确认。 +- server:fork 循环 fd 卫生(child 关 listen、parent 关 accepted、无跨迭代泄漏);僵尸回收 + max-sessions=1 + 第二连接 `Refuse{Busy}` 且干净关闭;child 得 `DIAL=no`;日志前送在 `LogMutex` 下 sink+forward、`LogFlush` 控制线程应答不经 apply 线程(无死锁);exec/argv-env;INTERNET 权限;wake lock 平衡。 + +## 已应用修复(本次) + +1. `SocketTransport::AcceptPair`:`EINTR`/`ECONNABORTED` 在 budget 内重新 poll,不再 `TRANSPORT_CLOSED`。防常驻 server 因 accept 瞬态整体退出。 +2. `mobilegl_server_main`:顶部自设 `MOBILEGL_IPC_ROLE=server`(若未设),不再盲信 launcher。 + +两处均在 `MG_Remote/`(`MOBILEGL_BUILD_DISAGGREGATED` 门内),故 G1 monolith `.text` 不受影响(构造性保证)。 + +## 修后再验收(固定制品,2026-09-22) + +固定制品:WSL client `libMobileGL.so` SHA16 `e17f82f1`、设备 APK SHA256 `780bb00e…`(`assembleTraceDebug`,`-O0 -g`),**两端 build stamp 同为 `p65-acceptfix-20260922`**(同源同戳,`REQUIRE_SAME_BUILD=1` 真实通过)。 + +| 检查 | 结果 | 证据 | +|---|---|---| +| G1 | monolith `.text` 不变(构造性:改动全在 `MG_Remote/`,非 split 构建不编译) | CMakeLists §497 门 | +| 主机 full unit | 2399/2399,0 fail | `/tmp/p65-fullunit2.log` | +| P6.5 子集单测 | 147/147(含 wf/ct red-once:同宽字段重排改摘要、wireMajor/layout 不匹配→具名 Refuse 不 abort、Connect 接不同 build、Fork 拒、REQUIRE_SAME_BUILD 拒) | ctest -R 输出 | +| integration-tcp **loopback**(Release 固定制品) | 102/102(一条 `ClientVertexArray…GpuWrittenIndices…` 会话槽竞态 flake,单独重跑绿——ci-false 类) | `/tmp/p65-tcp-loopback-fix2.log` | +| **device-102** integration(Redmi,固定 APK) | **102/102,0 fail、0 Fatal、0 eglInit 失败;102/102 client 日志 `run-ahead ARMED`** | `/tmp/p65-devlane-fix2.log` | +| 代表性 device retrace(Redmi,固定制品,`--require-run-ahead`) | OpenRA ssim=1.0 / startup 0.999999511 / in-world 0.999995438,**三条全 ARMED**(非 lockstep/disarmed) | `/home/swung/p65-repr-device/checkpoint.json` | + +排障留痕(勿重犯):restamp 后只 `ninja libMobileGL.so MobileGLServer` 的**局部构建**会让 `MobileGLIntegrationTest` 与生产库不一致,服务端报 `Fatal{ProtocolCorruption, PipeApplier::Attach missing link}`、客户端 `eglInitialize 0x3001 remote TCP bring-up failed`——**全量 `ninja` 后即消失**,不是 wave 缺陷也不是 debug/release 差异。设备车道无 wake keepalive 会因 Doze 冻结全挂,retrace 用 `--wake-adb-serial` 已规避,device-102 补了 `KEYCODE_WAKEUP` 循环。 + +代表性 scope 依用户偏好(本地代表性、CI 兜底全量);此前另有一条完整 39 例 device 矩阵在跑,14 passed / 1 ci-false(`minecraft-1.21.11-main-menu` 的 Redmi 32B UBO 对齐 golden 伪差),因与固定制品身份不一致且 APK 需重装而停,其部分结果 `p65-final-matrix-gles*` 作旁证保留。 + +临时环境已恢复:设备 server 停、idle 白名单还原、WSL→手机 host route 删除(adb forward 与本地 fixture 早已清)。设备现装固定 APK `780bb00e`(含本次两修复)。 diff --git a/docs/Disaggregated/notes/p65/data-link-implementation.md b/docs/Disaggregated/notes/p65/data-link-implementation.md new file mode 100644 index 00000000..776ae550 --- /dev/null +++ b/docs/Disaggregated/notes/p65/data-link-implementation.md @@ -0,0 +1,57 @@ +# P6.5 数据面实现记录 + +## 已落的边界 + +`ILink` 工厂创建共享段或 stream 链路。`ShmLink` 持有段、command/event 环端点;`StreamLink` 持有独立私有镜像及专用 reader。session 只缓存链路借出的 endpoint 指针;Reserve/Pop 与 spin 内的原子读取仍非虚。生产 encoder 保存 `LinkProgress` / `LinkSignals`,decoder 与 writeback 的 span 解析经 `ILink::ResolveSpan`。 + +共享车道不装 stream publication hook;共享段 reply 的 `ReadView` 借出已经发布的 payload,由 client 只拷一次。两种 link 均经过 `ILink::PostReply/ReadReply`。旧 raw-ring constructor 与 `Session::Control/Shm` accessor 仍供既有 white-box 测试及共享段 bootstrap 使用;这些兼容点未被冒充成字面 grep 的零命中。 + +纯度入口:`python3 scripts/ci/link_seam_purity.py --self-test`。检查物理段/环/slot 池不得回到 session 所有权、上层不得实例化具体 link、apply loop 不得直接读 RingControl、codec 不得持有 RingControl、writeback 不得绕开 link;七个具名负控随脚本执行。cached endpoint 类型与旧测试 constructor 是明确保留的兼容边界。 + +## 数据帧 + +共用 `Framing.h::FrameReader`;默认 MGLF,data reader 指定 MGLD。长度为 20 字节 data envelope 加 payload,最大 64 MiB;envelope 是 kind:u8、reserved:u8[3]、a:u64、b:u64。所有多字节 data 字段按小端编码。Stage/Cmd/Event 的单帧 payload 上限因而为 64 MiB − 20;64 MiB Stage burst 会拆两帧。 + +reader 先验证 framing header 与 data envelope 的角色、长度和窗口,再接收 payload;一条专用 reader 始终排空连接,writer 在调用线程串行发送完整帧。没有无界消息队列。 + +Progress 除四个 seq/frame 水位,还携带 **cmdAppliedTail 与 cmdRetiredTail**。原 roadmap 简写只有 applied tail,但现有 `RingProducer::FreeBytes` 只读 retired tail;遗漏会在首次回绕挂死,混成 applied tail 则提前释放 borrowed slot。stage 的三个旧 ring cursor 保持零;原 encoder allocator 保持退休 mark/rebase 规则,链路用 `NoteStage` 记录新字节并先于 Cmd 发送。 + +单槽 reply 接受按 seq 前进的最新回复:`wantReply=false` 的 ResourceSubData 仍回复时不会堵住 reader。同步调用只在自身 applied 后读取,Reply 帧严格先于该 applied 进度发送。若 applied 已到而 reply 不存在,立即返回 protocol mismatch;EOF/错误在同一 reply mutex 下唤醒等待者。stream 不读 shared reply slot,不做取模或 stamp 自检。 + +## 发布与跨连接顺序 + +每次 producer park / stage retirement wait 前 Flush;server apply 的真正 park 点(`ServerLoop`)及 transport 的 `SessionConsumer::WaitForWork` 都 FlushProgress。另在 PostReply、present/completed-frame 变化和每 64 条或 1 ms 发布。水位只晚不早、比较用 >=、倒退具名 Fatal。 + +event bytes 先于宣布其完成的 Progress;client drain 返回 event tail,server 只在真实 tail 前进时清除 full latch。控制连接的 SurfaceReply 不能替数据连接保证先后,因此它携 eventHead fence;client 等本地数据 reader 收到该 head 后再排空事件。RPC 前也冲刷并等此前 command 完成,等待过程中继续排空反向事件。 + +## 测试与证据边界 + +`StreamLinkTest` 覆盖 Stage-before-Cmd、idle 尾水位、160 次记录/多次回绕、无用 reply 后的同步 reply、event credit、EOF、等待中的 reply 被 EOF 释放、缺失 reply 拒绝、独立连接的 event fence、Progress 倒退与 Stage 越窗。另以真实 IPv4 loopback TCP 传送 64 MiB Stage 并记录耗时/MiB/s;socketpair correctness 结果不充作 TCP 吞吐。 + +2026-09-22 开发中 build4 曾产生 server `NoteStage` fatal。只读 objdump 证明是编辑 virtual 接口与同轮并行构建交叠:旧 `Ring.cpp.o` 的 NotifyClient 跳 vptr+0xc0,新 StreamLink 此 slot 已为 NoteStage,ProgressChanged 已挪一格。该产物不作验收证据;接口冻结后重建所有受头影响的 object 才可验证。 + +## 最后一次退休进度的 red-once + +核心 `StreamPair.StagingArrivesBeforeItsCommandAndLastRecordProgressFlushesOnIdle` 在稳定产物上曾真红:收到 applied=1 后 retired 仍为 0。原因不是网络可见性,而是 receiver 先调用 `AdvanceRetired`;此 helper 会按本地 applied(仍为旧值)夹紧退休值。若此后再无 apply,最后一次退休永远丢失,大 stage 窗口回收可因此互等。 + +修复是按依赖顺序先应用 incoming applied,再应用 incoming retired,随后通知。测试分别等待两种语义,不假设 applied 是 retired 的同步屏障;它只有一次 idle progress,不能靠后续批次掩盖这个错误。 + +WSL 真 TCP 64 MiB stage 记录(该退休修复前、传输字节已核对的测量):0.069763 秒,917.397 MiB/s。它只代表本机 loopback 的框架与复制吞吐,不代替 Redmi/Wi-Fi 测量。 + +## Present 与 glFlush 的提交边界 + +阶段审查的真实负控发现:本地 `PublishAndNotify` 不是 stream delivery;run-ahead Present 可以在没有任何后续 wait 的情况下返回,最后一个 present 因此永远留在 client。credit=3 的前三帧尤其能暴露它。 + +修复把传输 Flush 放在每次 Present publish 后,并给 exported `glFlush` 接入只提交、不等待 applied 的 client 路径。run-ahead、present credit 和一般记录的批处理均保留;纯 pull 的 glFlush 经预处理仍是原空函数。 + +`StreamClientPublicationTest` 用真实 CapsSnapshot 启动 run-ahead ClientSession 和 StreamLink reader,peer 故意不 apply。它断言前三个 Present 各自到达 peer、以及 exported glFlush 发送之前发布的 MemoryBarrier,同时 client waits=0、applied=0。测试没有调用 client wait、事件 drain 或日志同步来暗中冲刷;五秒 alarm 会抓住误加同步等待的回归。 + +## 挂断发生在 staging 时的取消 + +设备日志曾记录真实顺序:DrawVbo 等待已报告 DEVICE LOST,随后大 BufferSubData 仍进入 stage allocator,并以 `RetirementWaitFailed` 终止 client。现有 codec 与 emitter 没有传播这个已确认的会话取消。 + +取消现在是 encoder 本地状态,借用 session 的 lost atomic,并只在 staging/失败等待边界询问生产 doorbell。只有 `PeerHungUp` 会触发 session latch;普通 Dead 可取消 teardown,但不伪造设备丢失。`StageAllocate` 返回 null 时,`StageBytes` 不执行 memcpy 或 NoteStage;session/encoder 在 EncodeRecord 前拒绝取消请求,不产生新 seq 或无效 blobref。stage 的生产等待预算对齐 120 s barrier,给真实 TCP keepalive 检测留下余量;健康 peer 超时仍保持具名 Fatal,不把 deadline 解释成 loss。 + +没有发出记录时 seq=0;四个 mailbox emitter 保留调用者已 minted 的非零本地 ticket,避免 ReplyToNoSlot。ResourceSubData 的 buffer 快路径也保留取消的 DECLINED,不再在 wire emitter 或 route 中强制改成 OK。 + +回归共三条:已断后大于 stage window 的上传不拷贝/不发记录;填满 stage 后在 producer 确认 park 时 SIGKILL 一个真实 reader child,第二个 blob 不覆盖窗口且无新 seq;健康 peer 的 5 ms 测试预算仍以 RetirementWaitFailed 结束,signal 侧确认 device_lost=0。生产预算不受该单测缩短。 diff --git a/docs/Disaggregated/notes/p65/evidence-index.md b/docs/Disaggregated/notes/p65/evidence-index.md new file mode 100644 index 00000000..9bba1bab --- /dev/null +++ b/docs/Disaggregated/notes/p65/evidence-index.md @@ -0,0 +1,83 @@ +# P6.5 第一波证据索引 + +本次实现与验收的总入口。完整 trace 和性能表仍在采集,不能将本索引理解为阶段收官。 + +> **2026-09-22 收尾追加**:四切片代码审查完成(数据面/wire/握手无硬缺陷),两项修复已落地并再验收——见 +> [code-review-findings.md](code-review-findings.md)。修复:`SocketTransport::AcceptPair` 重试 `EINTR`/`ECONNABORTED` +> (Medium,防常驻 server 因 accept 瞬态整体退出)、supervisor 自设 `MOBILEGL_IPC_ROLE=server`(Low);均在 +> `MG_Remote/` 门内,G1 不受影响。固定制品(client `e17f82f1` + APK `780bb00e`,同 stamp `p65-acceptfix-20260922`) +> 再验收:主机 unit 2399/2399、integration-tcp loopback 102/102、**device-102 102/102 全 ARMED**、代表性 device +> retrace 3/3 ARMED golden(OpenRA 1.0)。**残余**:完整 39 例 device golden 矩阵与全套必测数按用户「本地代表性、 +> CI 兜底」定为 CI/后续;本收尾未宣布 P6.5 阶段收官。临时环境(host route / idle 白名单 / server)已恢复。 + +## 源码与制品身份 + +- 基线:远端 `feat/disaggregated` 的 `e6c51d0711595f579decfaf78c1f5b0040ccd7fe`。 + 最后一次 fetch 已确认没有更新;该提交是当前分支的祖先。 +- 开发工作树:`codex/p65-all-tcp`,HEAD `52ffc6a7` 加未提交修改。 + 用户纠正提交偏好之前已有的两个本地提交随后只做了 rebase;之后未创建开发提交、未 push。 +- 两端显式 build stamp:`410dfa94c3d92241e9ec01ddbba1bb911eb60d4b`。 + 它是本轮共享的构建标识,不代表当前 HEAD 或一份已提交的最终源码。 +- Android:NDK 27.3、arm64-v8a、debuggable traceRelease,实际 native `-O2 -DNDEBUG`。 + +| 制品 | SHA-256 | 使用它的证据 | +|---|---|---| +| READY7 host libMobileGL.so | `29fcff6b23557be6b1cc3765db382b3422ed0780643c719937616259298d3116` | host/device 102 与修后 TCP CPU | +| 优化 APK | `f2a957c3c6ce1c3c77dd0b99f233f40eed9e47896830ee7767af6b7fbb738f50` | READY7 真机与正式 trace | +| 最终 ALL 后 host libMobileGL.so | `25ca45d756bc5a4e5bd56ad4ce231e4ef89d45ba41131f389575cda0842cd6be` | 全 unit 与正式 trace | + +最后一次 ALL 增量只重编测试和 SPIRV-Cross 的 `spirv_cross_c.cpp`;未重编 MobileGL 生产 object。 +其 CMake 每次 configure 都生成 gitversion 时间戳,这次值为 `2026-09-22T08:02:51`, +导致重新链接后 SHA 改变。保留两份身份,不能把较早样本改标为新制品。 + +## 已完成的验证 + +所有 `/home/swung/` 路径位于 WSL Arch,日志/XML/原始样本保留在对应位置。 + +| 检查 | 实际结果 | 证据 | +|---|---|---| +| 最终 ALL | exit 0 | `/home/swung/p65-final-tests-build.log` | +| 全 unit | 2389 PASS、0 FAIL、10 个原有条件性 skip | `/home/swung/p65-ready7-unit.{log,xml}` | +| TCP loopback | 102 PASS、0 FAIL、0 SKIP;102 条实际 run-ahead ARMED | `/home/swung/p65-ready7-tcp.{log,xml}`、`p65-ready7-tcp-lane.json` | +| Redmi TCP | 102 PASS、0 FAIL、0 SKIP;102 条实际 run-ahead ARMED,72.23 s | `/home/swung/p65-armed-device.{log,xml}`、`p65-armed-device-lane.json`、204 份 `p65-armed-device-rolelogs/` | +| 名集合 | split 105 中可比 102 = spawn 102 = TCP 102 = device 102 | `/home/swung/p65-ready7-lane-parity.log` | +| G14 | 4016 → 4257,0 删名、241 新增(含可选 device 102) | `/home/swung/p65-ready7-test-name-parity.json` | +| 最终 G1 | 符号 0 增/删/resize/rename;`.text` 10,822,147 B 逐字节相同 | `/home/swung/p65-g1-ready7-{report.md,symbols.json,verification.json}` | +| 真机 SIGKILL | 133 ms,生产挂断路径闩 device loss | `/home/swung/p65-device-peerloss/kill.log` | +| 真机 Wi-Fi 半开 | 5053 ms,低于 10 s;Wi-Fi 已恢复 | `/home/swung/p65-device-wifi-loss/{probe.log,timing.json}` | +| 真实生产 idle flush 负控 | 原库绿 → 仅删除 idle publication 后 8 s 抓到停滞 → 恢复绿 | `/tmp/p65-idle-redonce-c_n59aqs/results.json` | +| 日志前送负控 | 三个 server marker 各 ON 绿 / OFF 缺指定 marker 红 | `/home/swung/p65-lf-controls2/summary.json` | +| CI 修复 | role-log 正反控通过;真实两后端 RSP 2 PASS;设备离线分类 5/5 | `/home/swung/p65-ci-runtime-proof.{log,xml,json}`、`scripts/ci/test_trace_infrastructure.py` | + +初始 caps 的三个新回归包含在全 unit 中:真实 TCP/ControlInbox 延迟快照正确武装, +健康但不发快照的 peer 在 5 s 内具名启动失败且不伪造 device loss,坏快照拒绝。 +之后的能力更新仍只能 demote,不能重新 promote。 + +此前的保留车道是 spawn 102/102、inproc 186/186、零 skip,见 `p65-final-{spawn,inproc}.{log,xml}`。 +它们与 G1 细节见 [wire-and-validation.md](wire-and-validation.md)。早期 TCP/device 的 lockstep 样本 +仍保留作历史证据,不能替代上表的实际 run-ahead 结果。 + +最终 G1 更新独立 source snapshot 后只增量构建 after;before 制品与固定的 hash 头未变。 +双方 `.text` SHA-256 为 `35115edd2c265162c7f845a87d1a2cb9849609a40ba0febac317b91370b9163c`。 + +## trace、测量与审查 + +- 正式 GLES 39 项:`/home/swung/p65-final-matrix-gles/`。使用原有 golden 与逐例实际 ARMED 门;进行中。 +- 已完成条目中 `minecraft-1.21.11-main-menu` 为 FAIL(SSIM 0.890161)。原样主机 TCP 对照通过; + 只从临时 trace 删除 3,878 个不满足 Redmi 32 字节 UBO 对齐的 bind,便能在主机复现缺失, + 与设备实际图 SSIM 0.998729。该诊断不计正式通过,设备本地对照尚待; + 详见 [mainmenu-trace-difference.md](mainmenu-trace-difference.md)。 +- CPU 前后与正式 TCP/spawn 对比:[performance.md](performance.md)。保留非零差值、线程测量范围、 + 样本散布和 before 制品身份限制;没有把性能“只记录”解释成可以不测。 +- 阶段审查发现 Present/glFlush 尾批可能留在本地;已补真实传输边界并验证前三个 present credit + 各自到达 idle peer,client waits=0、applied=0。后续窄查未发现正常关闭路径死锁/UAF; + `WaitClosed=true` 也可能表示 terminal protocol error,不能独自作为正常 EOF 证明。 +- link seam 明确保留 cached endpoint、旧白盒 raw-ring constructor 和共享段 bootstrap accessor。 + ownership/dispatch gate 与七个负控通过;没有声称所有历史 accessor 或字面 grep 命中归零。 + +唯一已知的 verify+split 前置债仍为 `PipeRespecifyScope`,inproc/spawn/TCP 同因复现。 +LF 第四个 PipeVerify marker 属于 client,本来就不能成为 server forwarding 的负控; +未执行到的断言不计通过。说明见 [validation-status.md](validation-status.md)。 + +临时设备 idle 豁免与 WSL 手机 host route 要在全部真机工作结束后恢复;当前仍在使用。 +闲置的本机 40614 supervisor 与 adb forward 40615 已清理。 diff --git a/docs/Disaggregated/notes/p65/mainmenu-trace-difference.md b/docs/Disaggregated/notes/p65/mainmenu-trace-difference.md new file mode 100644 index 00000000..f7e7cf37 --- /dev/null +++ b/docs/Disaggregated/notes/p65/mainmenu-trace-difference.md @@ -0,0 +1,141 @@ +# P6.5:1.21.11 主菜单 GLES 像素差异诊断 + +**状态:正式设备矩阵仍记 FAIL;独立主机负控已复现同位置缺失,强烈支持 trace 不满足目标 GPU 的 UBO 对齐要求;相同 APK 的设备本地对照仍待补。** +本记录不修改源码、trace fixture、golden 或 SSIM 阈值,也不把该失败归入既有 Vulkan 债务。 + +## 正式失败与可比阳性 + +| 对照 | 结果 | SSIM / 阈值 | 用时 | 实际 arming | +|---|---|---|---|---| +| Linux client → Android/Adreno TCP+stream,credit=2 | FAIL,完成回放后图像不符 | 0.890161090 / 0.99 | 272.684 s | ARMED,无 lockstep/DISARMED | +| WSL 同 ABI/驱动 TCP+stream loopback,credit=2 | PASS | 0.993474922 / 0.99 | 14.018 s | ARMED,无 lockstep/DISARMED | + +两次目标 call 均为 `205347`,分辨率 `854×480`。正式失败的 `statusCode=6`、 +`returncode=1`、`timeout_kind=null`,不是 watchdog 超时;其 mismatchPixels 为 79,246。 +客户端和服务端没有 `Fatal{`、device loss、Refuse 或握手失败证据。客户端完成 38,884 次 reply wait; +服务端最后一帧计 11,551 draws。没有遗留进程。 + +目视检查可见背景、标题与文字仍在,主要缺失 Singleplayer、Multiplayer、Realms、Options、Quit +的灰色按钮底图,以及语言/右侧辅助功能按钮的图标或底图。loopback 中这些元素正常。 +图中 Mali/Tensor 文字属于捕获的游戏画面,不能据此判定当前服务端 GPU;实际服务端日志为 Adreno。 + +正式 attempt: +`/home/swung/p65-final-matrix-gles/DirectGLES/credit-2/minecraft-1.21.11-main-menu/attempt-a1c1263d9b814620b04ff4c8b8d6cbf1`。 +原始结果、图像和角色日志位于其 `work/output/`,golden 副本位于 `artifacts/`。 +loopback 证据索引为 `/home/swung/p65-mainmenu-loopback-control/control-summary.json`。 +该索引记录主机 library SHA256 为 +`25ca45d756bc5a4e5bd56ad4ce231e4ef89d45ba41131f389575cda0842cd6be`。 +仅 loopback 阳性还不能单独区分 ABI/wire 与设备驱动差异。 + +## 只读 trace 与源码证据 + +设备 `mobilegl.server.log:145` 及 `:358` 报告 +`GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT: 32`(SSBO 为 64)。 +trace 的 UBO offset 有 16 字节粒度,但未找到录制时 alignment 查询值;不能声称捕获日志明确报告 16。 + +`MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp:1553` 的 +`ValidateBufferRangeOffsetAndSize` 查询当前 context 的 alignment;`:1580` 判断 offset 整除性, +不符合时记录 `InvalidValue` 并返回 false。`:1627` 在 `TouchBufferBindingPoint` 和修改绑定之前返回, +因此保留之前合法的 buffer/range。远端 client 采用远端能力,不能用 WSL 原生 GPU 的较小对齐要求解释该分支。 + +`MobileGL/MG_State/GLState/ErrorState/Error.cpp:20` 仅以 `MGLOG_D` 打印 GL error。 +现有 INFO 日志没有逐次 `RecordError` 文本;下列“应被拒绝”是调用参数加现行代码得到的预测, +不是声称已有逐 call 动态错误日志。 + +完整只读 `apitrace dump` 中有 14,149 次 `glBindBufferRange`,没有 `glBindBuffersRange`。 +其中 **3,878** 次满足 `target=GL_UNIFORM_BUFFER && offset % 32 != 0`,首 call `9683`, +末 call `205116`;全部发生在 program 158(2,808 次)或 program 15(1,070 次)。 +program 158/15 的 uniform block 0 都映射到 UBO binding 0(call `6734`/`6661`)。 + +这两个 program 的顶点 shader 13(call `6651`)明确声明 `SpriteAnimationInfo`,内含 +`ProjectionMatrix`、`SpriteMatrix`、`UPadding`、`VPadding`、`MipMapLevel`,并计算 +`gl_Position = ProjectionMatrix * SpriteMatrix * vec4(positions[index], 0, 1)`。 +program 158 的 fragment shader 157(call `6727`)通过 `textureLod(Sprite, texCoord0, MipMapLevel)` +填充 sprite;program 15 通过两个 sprite 采样做动画插值。由此可将不合对齐的绑定定位到 atlas 生成, +而非笼统归因于最终主菜单 draw。 + +与缺失按钮直接相关的调用链如下: + +| call | 事实 | +|---|---| +| `80415` | FBO 16 的 COLOR_ATTACHMENT0 设为 texture 868,后续 viewport 1024×1024 | +| `80426`, `80428` | UBO buffer 44 / offset 0 / size 140,随后 atlas draw | +| `80434`, `80436` | UBO buffer 44 / offset 144 / size 140(不满足 32),随后 atlas draw | +| `80442`, `80444` | UBO buffer 44 / offset 288 / size 140,随后 atlas draw | +| `205215`, `205221` | program 181 采样 texture 868,DrawElementsBaseVertex count 90 / basevertex 4 | +| `205254`, `205262` | program 181 再采样 texture 868,count 12 / basevertex 68 | + +FBO 16 一共 607 次 atlas draw,其中 286 次前置 UBO bind 不满足 32 字节对齐; +该 FBO 最后一次不合对齐绑定为 `197198`(buffer 43 / offset 144 / size 144)。 +最终 GUI program 181 使用的 UBO offset 是 0 或 160,本身满足 32。 +它的 fragment shader 27 对 `texture(Sampler0, texCoord0)` 的零 alpha 执行 discard。 + +**获得独立负控支持的机制:** atlas 生成时,非法绑定保留旧 `SpriteMatrix`,新 sprite 仍写入旧区域,目标格留空或错误; +最终 GUI 从 texture 868 采样时因零 alpha 丢弃,从而出现按钮文字正常、底图/图标缺失。 +下面的 blob/UV 对应进一步把缺失区域映射到具体 atlas draw。独立负控已复现主要视觉缺陷, +与设备失败图 SSIM 为 0.9987292763;这不声称两个驱动的全部像素完全一致。 + +只读 `apitrace dump --blobs --calls=80413,205170,205177` 把诊断副本写到 +`/tmp/p65-mainmenu-blobs-ub31dw3f`。`80413` 为 buffer 44 的 69,264 字节初始化内容, +每个 `SpriteAnimationInfo` record 以 144 字节步进;`205170`/`205177` 分别是最终 buffer 39 +中 15 个按钮底图 quad 和两个图标 quad 的顶点内容(每顶点 24 字节)。 +将顶点 UV 乘 atlas 尺寸 1024,再与 UBO 内的 SpriteMatrix 比较,得到: + +| GUI 元素 / 最终采样像素区域 | buffer 44 record / atlas 矩形 | 生成调用与对齐 | +|---|---|---| +| 15 个底图 quad,包括三行主按钮及底部四个按钮切片;x=723…923, y=1…21 | record 279,矩形 (722, 0, 202, 22) | bind `82658` offset=40176,余数 16;draw `82660`,源 texture 1168 | +| 语言图标;x=659…674, y=122…137 | record 125,矩形 (658, 121, 17, 17) | bind `81426` offset=18000,余数 16;draw `81428`,源 texture 1014 | +| 右侧辅助功能图标;x=659…674, y=88…103 | record 148,矩形 (658, 87, 17, 17) | bind `81610` offset=21312 合法;draw `81612` 后紧跟 record 149 | + +record 149 原定矩形为 (658, 155, 17, 17),bind `81618` offset=21456 余数 16, +draw `81620` 的源 texture 1038。如果该 bind 被拒绝,它沿用 record 148 的合法矩阵, +因而会覆盖右侧图标刚生成的位置。按钮背景被拒绝时沿用 record 278 的矩形 (376, 194, 22, 22), +语言图标则沿用 record 124 的矩形 (610, 287, 18, 18),都不是最终 GUI 预期采样的区域。 + +## 独立控制与证据边界 + +原 trace 的同 ABI TCP loopback 已通过。独立代理根据 +`/home/swung/p65-mainmenu-ubo32-rejected-calls.json` 的 3,878 个 call 编号, +仅在临时 trace 副本中移除这些本应被设备 client 拒绝的绑定,保留对应 draw,然后做主机 TCP 负控。 +该主机结果复现了设备的按钮矩形、语言按钮和右侧图标缺失。 + +| 图像比较 | 全图 RGB SSIM | 对原 golden 的判断 | +|---|---:|---| +| 原 trace 主机 loopback vs 原 golden | 0.9934749217 | PASS | +| 原 trace 设备 TCP vs 原 golden | 0.8901610899 | FAIL | +| 临时删绑定 trace 主机 TCP vs 原 golden | 0.8892457298 | FAIL;仅诊断负控 | +| 临时删绑定 trace 主机 TCP vs 原 trace 设备 TCP | 0.9987292763 | 比较两张失败图,不是 golden 验收 | + +比较使用项目 `trace_replay_core.cpp` 的全图 RGB SSIM(C1=6.5025、C2=58.5225), +不 crop、忽略 alpha,且已校验原设备和原 loopback 的 runner 结果。四张图像的路径与 SHA256 +均保存在 `/home/swung/p65-mainmenu-ubo32-diagnostic/comparison-summary.json`。 + +处理过程和逐 call 验证保存在 +`/home/swung/p65-mainmenu-ubo32-diagnostic/preparation-summary.json`。 +安装的 apitrace 14.0 不支持 `--exact`;实际使用显式保留 call 补集的 +`trim --calls=@keep-calls.txt`,并核实该版本直接写出所选调用,没有增加依赖闭包。 +随后完整 dump 两份 trace,逐项比较所有保留调用的顺序、thread、参数和每个 blob 的 SHA; +仅规范化 call 编号前缀和 blob 文件名,确认除名单中的 3,878 个绑定之外没有变化,headers 也一致。 +原 trace 共 205,348 个 call,临时 trace 共 201,470 个,最后的 `eglSwapBuffers` 从 +`205347` 重编号为 `201469`。临时 trace SHA256 为 +`92e47f93ddcb7b5f3eaece05229fa8980e92adc5d24bbff3d6fe1c1b948ef6b4`。 + +同目录 `replay-summary.json` 记录负控用时 12.188 秒、returncode=6,实际 ARMED, +无 lockstep/DISARMED,双角色均为 TCP+stream 且 Fatal=0;结束后 supervisor 正常停止、 +state 移除且端口释放。该负控使用与原 loopback 相同 SHA256 的主机库。 +`apitrace-tool-evidence.json` 保留版本和 `--exact` 不支持的实际输出,`README.md` 汇总复现步骤。 +原 trace 与 golden 在负控回放后重新计算 SHA256,确认均未改变。 + +这一负控、远端 alignment=32、frontend 拒绝路径以及精确 UV 对应共同强烈支持 +**捕获 trace 对目标 GPU 的 UBO 对齐有效性问题**。它是独立于原设备 TCP 回放的机制复现, +不能把修改后的 trace 当作正式 GLES 阳性,也不能将正式 case 改记 PASS。 +**相同 APK/后端的设备本地 inproc/spawn 原 trace 对照尚未执行**;需待正式设备矩阵释放设备后再补, +本记录没有将它写成已完成,也不把负控宣称为完全排除所有跨机器差异。 + +原 trace 保持不动,SHA256: +`7abd871cce098e93dd15a5e799fdfe093d9c85784716b00fd97c94833b8b83af`。 +原 golden SHA256:`ea8db1c1c1414d3b564d65b1559a708277417b06895900bc947644d2f658bb86`。 + +两条 `GL_STENCIL_INDEX8` NormalizePixelFormat ERROR 及 layered-blit 探测 warning 也见于通过的 +1.21.4 startup/main-menu,因此不把它们单独作为此失败原因。历史 P4a 的 1.21.11 depth-sampler +失败有独立的 sampler refusal 证据,现日志没有同一 refusal;不能把旧原因直接套用到本次。 diff --git a/docs/Disaggregated/notes/p65/peer-loss-probe.md b/docs/Disaggregated/notes/p65/peer-loss-probe.md new file mode 100644 index 00000000..22be0df8 --- /dev/null +++ b/docs/Disaggregated/notes/p65/peer-loss-probe.md @@ -0,0 +1,60 @@ +# P6.5 真实 peer-loss 探针 + +构建目标 `MobileGLPeerLossProbe`,产物位于 `MobileGL/MG_Test/Wire/MobileGLPeerLossProbe`。它是手动工具,不注册为无设备时自动 skip 的 unit。 + +探针复用生产 `ClientSession::StartSpawned` 的 Connect 路径及 Hello/Welcome;计时期间不发送 GL 命令、应用 heartbeat 或人为 ACK。它先观察生产 data doorbell 的 `PeerHungUp()`,随后走真实 `FenceStatus -> EmitAndWait -> ShutDown` 路径触发 session latch,并调用生产 `GetGraphicsResetStatus` handler 检查 `GL_UNKNOWN_CONTEXT_RESET`。没有直接调用 `LatchDeviceLost`。 + +## S2:真 kill server child + +先启动与 client 同源码的 supervisor。以下杀的是 Welcome 声明的会话 child,不是 supervisor: + +```bash +cmake --build /home/swung/p65-split --target MobileGLPeerLossProbe -j 12 +export MOBILEGL_IPC_CONTROL=tcp://PHONE_IP:40613 +export MOBILEGL_IPC_TOKEN=devtoken +export MOBILEGL_IPC_REQUIRE_SAME_BUILD=1 +export MGITEST_PEER_KILL_CMD='adb -s 2f7cbe2e shell run-as top.mobilegl.plugin.trace kill -9 {pid}' +/home/swung/p65-split/MobileGL/MG_Test/Wire/MobileGLPeerLossProbe --kill --deadline-ms 10000 +``` + +loopback supervisor 的模板可以是 `MGITEST_PEER_KILL_CMD='kill -9 {pid}'`。模板必须含 `{pid}`;probe 只把它替换成经 Welcome 获取的正数 PID。命令跑在 probe 自己的 helper process group,超时会清理并回收 helper。 + +## Wi-Fi 半开:由父侧执行断网 + +该门必须用直连手机 IP;`adb forward` 走 USB,不会因为手机 Wi-Fi 关闭而断开,不能充作 Wi-Fi 门。 + +```bash +export MOBILEGL_IPC_CONTROL=tcp://PHONE_IP:40613 +export MOBILEGL_IPC_TOKEN=devtoken +export MOBILEGL_IPC_REQUIRE_SAME_BUILD=1 +run=$(mktemp -d /tmp/p65-wifi.XXXXXX) +probe=/home/swung/p65-split/MobileGL/MG_Test/Wire/MobileGLPeerLossProbe +"$probe" --external --deadline-ms 10000 \ + --ready-file "$run/ready" --start-file "$run/start" >"$run/probe.log" 2>&1 & +probe_pid=$! +# READY 文件只有 Welcome PID;等待它出现后才允许注入故障。 +while [ ! -s "$run/ready" ]; do kill -0 "$probe_pid" || exit 1; sleep 0.05; done +trap 'adb -s 2f7cbe2e shell svc wifi enable' EXIT +# 同一个父侧操作内先打时间起点标记,随即关闭手机 Wi-Fi。 +touch "$run/start" +while ! grep -q P65_PEER_LOSS_ARMED "$run/probe.log"; do kill -0 "$probe_pid" || exit 1; sleep 0.005; done +adb -s 2f7cbe2e shell svc wifi disable +wait "$probe_pid" +cat "$run/probe.log" +``` + +marker 的观测周期是 5 ms。计时从 probe 观察 marker 开始;父侧写 marker 后等待 ARMED 行,再立即断网,避免故障抢在计时开始之前。该数包含少量父侧与 adb 启动延迟,对 10 s 上限是保守测量。也可省略 start-file,在 READY 后向 stdin 输入 `GO`,适合手动终端。 + +成功行形如: + +```text +P65_PEER_LOSS_PASS pid=N mode=external peer_hung_up_ms=N device_lost_ms=N deadline_ms=10000 device_lost=1 gl_reset=0x8255 source=production-barrier +``` + +PASS 要求 descriptor hangup、dead bell、生产 latch、GL reset 状态与 DECLINED 回复全部成立,且总时间严格小于 deadline。只有超时不会设置 latch;没有故障时 deadline 结束应返回非零,这本身可用作阴性对照。 + +退出码:0 成功;2 会话/READY 失败;3 计时或触发失败;4 kill 模板失败;5 生产 latch/GL 状态失败;64 参数错误。探针没有创建真实 EGL/GL context,因此不实现 `--stage-burst`,不把人为传输 ACK 伪装成上传完成。 + +## 本机验证 + +2026-09-22 独立临时 TCP supervisor 的 S2 真 `kill -9`:`peer_hung_up_ms=4`、`device_lost_ms=4`、`gl_reset=0x8255`,exit 0。无故障的 150 ms 阴性对照:exit 3、`peer_hung_up=0 dead=0 device_lost=0`。证据目录 `/home/swung/p65-peerloss-l2ktmltm`;未用手机,这个数不代替设备/Wi-Fi 门。 diff --git a/docs/Disaggregated/notes/p65/performance.md b/docs/Disaggregated/notes/p65/performance.md new file mode 100644 index 00000000..07ea8990 --- /dev/null +++ b/docs/Disaggregated/notes/p65/performance.md @@ -0,0 +1,184 @@ +# P6.5 主机逐线程 CPU 前后记录 + +**已经取得前后数字,但不能称为“逐字不变”。** 本次记录覆盖本机 inproc 与 unix+shm spawn, +用于观察整波改动对保留车道的影响;性能按路线图通用纪律只记录,不据此阻塞正确性验收。 +它不代表 TCP 吞吐,也不代表 Android Release 性能。 + +## 定义与条件 + +- WSL Arch、DirectGLES + llvmpipe;两臂均使用现有 Release 制品,同一个 retrace 可执行文件。 +- before:`/home/swung/lk-split/libMobileGL.so`;after:READY6 的 + `/home/swung/p65-split/libMobileGL.so`。各自启动同目录下的 server。 + before 是既存的 P6.5 前制品,以实际 SHA-256 定身份;旧 CMake 缓存中的 build stamp + 不足以声称它恰好对应当前远端源码头。 +- OpenRA 完整 trace 128 帧,选择最后 **100 帧**;rd12 in-world 完整 trace 251 帧,选择最后 **200 帧**。 + 均排除起始资源准备窗口,不把全部 replay 时间除以帧数充作稳态结果。 +- 每个 case × transport × before/after 均为 **3 次完整 replay**,共 24 个样本。 + 每组记录按 repeat 交替 inproc/spawn;全部 before 完成后等待构建结束,再采 after。 + 不是版本间随机交错试验,也没有 CPU 定频,因此不据此宣称统计显著性或唯一因果。 +- 采集窗口内没有并发重型构建或另一条 host retrace。此前带 + `before-pilot-not-comparable` 标签的试跑仅用于校验采集器,不进入表格。 +- `PRESENT_CREDIT=1`、run-ahead=1、verb-barrier=1、batch-waits=1、spin=50us; + PipeStats=0、strict=0、role-split-state=0、audit=0。完整有效环境随每条样本保存。 +- `--benchmark-finish=0`:runner 不额外在每帧插入 glFinish;trace 自己的同步行为仍保留。 + +**client CPU** 来自已有 runner 的 `CLOCK_THREAD_CPUTIME_ID` 与 `frameCpuTimesMs`, +取选中尾窗口的平均值。它包括该 replay 线程上的解码与 MobileGL 调用,不是整个进程 CPU, +也不是只计 MobileGL 函数的采样 profile。 + +**apply CPU** 来自 `/proc//task//schedstat` 的 execution runtime 纳秒计数。 +选择最早的、仍存活的 `mgl-srv-apply` TID;后建的 driver helpers 会继承该名字,不能相加冒充 +一个 apply 线程的 CPU。每条原始采样保留 pid、tid、线程名、采样时刻和累计 CPU。 +如果没有唯一线程完整覆盖选中窗口,采集器报无效,不填 0。 + +### apply 窗口的精度限制 + +runner 现有帧数组没有绝对 monotonic 起点,因此外部 apply 采样使用 benchmark JSON 的文件 mtime +近似定位 `benchmark::End()`,再减去已有 `totalSeconds - sum(frameTimesMs)` 的非帧尾段, +最后回退选中尾窗口的 wall 时间。两个边界在线程 CPU 采样点间线性插值。 + +这存在**未单独测量的 summary/JSON 序列化延迟**,不能写成严格帧边界同步或纳秒精度的结果。 +请求采样周期为 5 ms;before 实际最大间隔 **7.159784 ms**,after 为 **15.230268 ms**。 +实际间隔与原始点均保留,后续可重新归约。下面多保留的小数位用于复算,不提高测量精度。 + +## 结果 + +每项是三次 replay 的“尾窗口 CPU 均值”的中位数,单位 **ms/帧**。 +百分比为 `(after / before - 1) × 100`;完整未舍入值在 comparison JSON 中。 + +| trace | transport | 线程 | before | after | 差值 ms/帧 | 差值 % | +|---|---|---|---:|---:|---:|---:| +| OpenRA | inproc | client | 0.681840 | 0.705350 | +0.023510 | +3.448023% | +| OpenRA | inproc | apply | 3.040668 | 3.108000 | +0.067333 | +2.214402% | +| OpenRA | spawn | client | 0.806240 | 0.786330 | -0.019910 | -2.469488% | +| OpenRA | spawn | apply | 3.242036 | 3.198453 | -0.043583 | -1.344321% | +| rd12 | inproc | client | 8.287390 | 8.362680 | +0.075290 | +0.908489% | +| rd12 | inproc | apply | 64.884249 | 64.917254 | +0.033006 | +0.050868% | +| rd12 | spawn | client | 10.276510 | 10.285330 | +0.008820 | +0.085827% | +| rd12 | spawn | apply | 67.614988 | 67.059490 | -0.555499 | -0.821561% | + +逐轮均值的范围也必须保留,避免只给一个点: + +| trace / transport / 线程 | before 三轮范围 | after 三轮范围 | +|---|---:|---:| +| OpenRA / inproc / client | 0.678730–0.688320 | 0.690650–0.715600 | +| OpenRA / inproc / apply | 3.038111–3.112060 | 3.064185–3.172090 | +| OpenRA / spawn / client | 0.799590–0.810700 | 0.780370–0.792480 | +| OpenRA / spawn / apply | 3.239807–3.299042 | 3.187598–3.211357 | +| rd12 / inproc / client | 8.195700–8.325585 | 8.311060–8.510350 | +| rd12 / inproc / apply | 64.826829–64.939705 | 64.915537–65.039914 | +| rd12 / spawn / client | 10.274680–10.279320 | 10.250475–10.306380 | +| rd12 / spawn / apply | 67.489521–67.649413 | 67.010901–67.281044 | + +存在双向变化:inproc client 的两个样本组上升,spawn client 一降一基本持平; +apply 也不完全相同。没有删除正增量,没有扩大阈值把它改写成“零开销”。 +整波还补齐了独立 client 的死亡记录等正确性行为,故不能把全部差值唯一归因于 lk2 的调用形式。 + +## 制品与复现 + +共同 runner: +`/home/swung/tr-split/tools/trace_replay/mobilegl_trace_replay` + +| 制品 | SHA-256 | +|---|---| +| runner(前后相同) | `700907be83c2c1c1c83eec8de37300a6fdab4c7f06387116fa8aaee0169b7291` | +| before libMobileGL.so | `a84d0c09b78b312c600e172c03bd9a6e4ce804585661859d888b20992b4e516d` | +| after libMobileGL.so | `d783080c5e1cb7db209a362db4ad742634d7525ae0c9683c4f6e9c5c54803361` | +| server launcher(前后相同) | `7a95cb4d276b40e4843c9d9f8c4d0f3486bfa6b5fc3e3b9a0dd842e1e9403ed6` | + +server 是薄 launcher,`NEEDED libMobileGL.so`、`RUNPATH=$ORIGIN`;它自身字节相同并不表示加载了同一库, +两臂分别从各自目录解析生产库。 + +```bash +python3 /tools/trace_replay/measure_transport_cpu.py \ + --runner /home/swung/tr-split/tools/trace_replay/mobilegl_trace_replay \ + --library /home/swung/lk-split/libMobileGL.so \ + --server /home/swung/lk-split/libMobileGLServer.so \ + --fixtures /home/swung/w7/pipe/tools/trace_replay/fixtures \ + --inputs /home/swung/p65-host-cpu-input \ + --out /home/swung/p65-host-cpu-before --label before-p65 --repeats 3 +``` + +after 只将 library/server 换成 `p65-split` 目录,out/label 换成对应 after 名。 +脚本拒绝覆盖已存在的 benchmark 证据;重新测量应换新的 out。 +新工作树中的某些 fixture 是 LFS 指针,故本次使用已核实的 Linux fixture 实体,并保存每条 trace SHA-256。 + +证据位置: + +- `/home/swung/p65-host-cpu-before/summary.json` +- `/home/swung/p65-host-cpu-after/summary.json` +- `/home/swung/p65-host-cpu-comparison.json` +- 各目录的 `//run-N/{benchmark.json,cpu-summary.json,apply-samples.json,runner.log}` + +这些数关闭了“必须真的量到逐线程 CPU”这一记录缺口,不关闭其他尚缺的 P6.5 设备/trace/吞吐指标, +也不把性能变化当正确性失败。 + +## 当前 spawn 与 loopback TCP 的专门比较:首轮负控 + +另外启动了仅用于本机测量的 `tcp://127.0.0.1:40616` supervisor,使用当前库、同一 runner、 +credit=1,client/server 均开启 `MOBILEGL_PIPE_STATS=1`、`MOBILEGL_PIPE_STATS_PERIOD=1`。 +`measure_loopback_cpu.py` 用 `P65LinkMetrics` 与 `P65ServerMetrics` 的线程 CPU 时钟, +逐个核对 client/server 帧号:OpenRA 29–128(100 帧),rd12 52–251(200 帧)。 +这组 apply 数据直接来自帧时钟,不使用上节的 `/proc` 外部窗口近似。 + +**首轮不能作为纯传输成本对照。** 虽然两臂都请求 run-ahead=1,实际日志证明 spawn 已 ARMED, +而 TCP 在真实第一份 CapsSnapshot 到达之前,错误地用 placeholder 的零能力锁存为 lockstep, +随后“只允许 demotion”的规则使它一直无法武装。该差异由真实测量揭露,不能当成 TCP 固有成本放过。 + +完整首轮样本保留在 `/home/swung/p65-loopback-cpu/summary.json` 与 +`summary-with-arm-proof.json`;OpenRA 每臂三轮,rd12 每臂一轮。 +其中 rd12 的实际 client/apply CPU 为 spawn **10.377163 / 66.994613 ms/帧**, +错误 lockstep 的 TCP **105.151038 / 160.624839 ms/帧**。 +这是具名负控证据,不进入正式 transport 比较结论。 + +修复由启动路径等待真实首 caps 完成,不强制打开 run-ahead,也不放宽后续只允许 demotion 的规则。 +采集器已增加实际 ARMED、无 lockstep fallback、无 DISARMED 的检查;下节记录修复制品的正式重跑。 + +### 首 caps 修复后的正式两角色 CPU 数 + +修后 TCP 已完整重跑,所有样本均通过实际 run-ahead ARMED 检查,没有 fallback 或 DISARMED。 +OpenRA 各臂 3 次取逐轮均值的中位数;rd12 各臂 1 次,单次样本不用于统计显著性推断。 +两角色使用相同精确帧号窗口,不把 setup 算入稳态,也没有使用外部 `/proc` 插值。 + +| trace / 窗口 | 线程 | spawn unix+shm ms/帧 | TCP+stream ms/帧 | TCP − spawn ms/帧 | 差值 | +|---|---|---:|---:|---:|---:| +| OpenRA / 29–128 | client | 0.913889030 | 0.725938440 | -0.187950590 | -20.566019% | +| OpenRA / 29–128 | apply | 3.414349090 | 3.123573070 | -0.290776020 | -8.516294% | +| rd12 / 52–251 | client | 10.377163490 | 6.507507265 | -3.869656225 | -37.290115% | +| rd12 / 52–251 | apply | 66.994613060 | 53.903945275 | -13.090667785 | -19.539881% | + +这是**指定两个线程**的实测变化,不能据负差值宣称 TCP 的进程总 CPU 更低: +TCP 另外有专用 I/O reader,以上 client/apply 时钟不包含那些线程的 CPU,也不包含其它 driver workers。 +本表开了 PipeStats,前节 P6.5 前后表关闭 PipeStats;两表不能交叉相减。 + +spawn 控制复用本轮首 caps 修复前已真实 ARMED 的样本,其运行中的数据路径未改变; +TCP 使用修后制品。这个复用边界明确保留,不称两个 library 文件哈希相同: + +- spawn library:`d783080c5e1cb7db209a362db4ad742634d7525ae0c9683c4f6e9c5c54803361` +- TCP library:`29fcff6b23557be6b1cc3765db382b3422ed0780643c719937616259298d3116` +- runner 与输入 trace 保持相同,完整哈希在各样本 `role-cpu.json` 中。 + +并发检查:最后一项 syntax 编译结束于 WSL 07:56:32.732;TCP OpenRA 三轮尾窗口分别从 +07:56:56、07:56:58、07:57:00 开始,rd12 尾窗口从 07:57:55 开始,均无编译重叠。 +这组精确帧时钟证据为 `/home/swung/p65-loopback-cpu-fixed/summary.json`, +归约比较为 `/home/swung/p65-loopback-cpu-comparison.json`;旧 lockstep 数据仍原样保存为负控。 + +最小复现(在没有其它 host 重负载时运行;不占手机端口): + +```bash +p65_tree=/mnt/c/Users/geekerwan/.codex/worktrees/p65-all-tcp/MobileGL +LIBGL_ALWAYS_SOFTWARE=1 EGL_PLATFORM=surfaceless \ +MESA_GL_VERSION_OVERRIDE=3.3 MESA_GLSL_VERSION_OVERRIDE=330 \ +python3 "$p65_tree/scripts/ci/tcp_server_fixture.py" start \ + --server /home/swung/p65-split/libMobileGLServer.so \ + --endpoint tcp://127.0.0.1:40616 --state /tmp/p65-cpu-server.json +trap 'python3 "$p65_tree/scripts/ci/tcp_server_fixture.py" stop --state /tmp/p65-cpu-server.json' EXIT +python3 "$p65_tree/tools/trace_replay/measure_loopback_cpu.py" \ + --runner /home/swung/tr-split/tools/trace_replay/mobilegl_trace_replay \ + --library /home/swung/p65-split/libMobileGL.so \ + --server /home/swung/p65-split/libMobileGLServer.so \ + --inputs /home/swung/p65-host-cpu-input --out /tmp/p65-role-cpu +``` + +该命令用同一当前制品重测两臂;本次修复后仅 TCP 重测使用了 `--transport tcp`。 +脚本按帧号核对两份日志、拒绝缺帧/重复帧/非正 CPU,并复用 `p65_link_metrics.py` 的归约逻辑。 diff --git a/docs/Disaggregated/notes/p65/validation-status.md b/docs/Disaggregated/notes/p65/validation-status.md new file mode 100644 index 00000000..86e35df3 --- /dev/null +++ b/docs/Disaggregated/notes/p65/validation-status.md @@ -0,0 +1,289 @@ +# P6.5 TCP 车道与设备验收记录 + +**状态:验收采证中,未宣布 P6.5 收官。** 历史 TCP 主机/设备 102 项功能阳性保留, +但当时实际为 lockstep,不能充作 run-ahead 验收。首 caps race 修复后的 READY7 已取得主机 TCP +及 Redmi 各 **102 PASS / 0 FAIL / 0 SKIP**,每条实际 ARMED;正式 trace 矩阵与同模式性能数仍待补。 + +当前源码基线为 `origin/feat/disaggregated@e6c51d07`,开发树 `codex/p65-all-tcp` 的 +HEAD 为 `52ffc6a7` 加未提交工作。两端曾共享的显式 `MOBILEGL_BUILD_STAMP` 是 +`410dfa94c3d92241e9ec01ddbba1bb911eb60d4b`,**它不是当前 HEAD**;后续 rebase 没有改变当时的 +生产字节,因此保留了这一制品标识。不能拿匹配的显式 stamp 替代每组产物 SHA、实际 arming +状态和运行证据,也不能把较早制品的绿移记到新的修复头。 + +## 主机结果:历史功能阳性 + +环境:WSL Arch,clang/Release,Mesa llvmpipe/lavapipe;源码位于 Windows 工作树, +构建目录 `/home/swung/p65-split`。`integration-split` 的完整标签有 186 条, +其中共享注册宏的 DirectGLES 可比集合为 102 条;不能把 186 与 102 的标签总数直接比较。 + +| 车道 | PASS | FAIL | SKIP | 证据前缀 | +|---|---:|---:|---:|---| +| TCP loopback | 102 | 0 | 0 | `/home/swung/p65-final-tcp` | +| unix + shm spawn | 102 | 0 | 0 | `/home/swung/p65-final-spawn` | +| 完整 inproc 标签 | 186 | 0 | 0 | `/home/swung/p65-final-inproc` | + +这张表保留当时的完整结果。**该次 TCP 102 条后来确认实际是 lockstep**:请求了 +run-ahead=1,却在真实首 CapsSnapshot 到达之前用 placeholder 的零能力锁存了回退状态。 +旧拓扑 proof 证明了 TCP/stream 与远端进程,不证明 run-ahead 已 ARMED。spawn/inproc 行保留其 +原功能结果,不因本表改称 READY7。 + +各前缀均有 `.log`、`.xml`。TCP 的两项 CTest supervisor fixture 另计, +故 CTest 摘要显示 104 条,而真实渲染用例仍为 102 条。 +`p65-final-tcp-lane.json` 是 discovery;`junit_tally.py --require-tcp-ran --discovery ...` +逐条读取私有角色日志,确认 `control=tcp data=stream server=... pid=N`,不是只看测试名。 + +`spawn_lane_parity.py` 比较规范化后的名字集合,保留现有 monolith-only 例外。 +`split_log_paths.py` 同时检查 Split、Spawn、Tcp、TcpDevice 的私有日志路径。 +TCP 用例还在客户端 `/proc/self/task//children` 断言 Connect 没有偷偷启动本地 server。 + +### READY7 主机 TCP:真实 ARMED 复验 + +启动改为等待并采用真实首 CapsSnapshot 后再锁存能力;没有强制打开 run-ahead,也没有放宽 +后续只允许 demotion 的规则。READY7 主机结果为 **102 PASS / 0 FAIL / 0 SKIP**,含两个 +supervisor fixture 的 CTest 总数为 104,耗时 18.35 秒。 + +逐条私有 client 日志实查:**102 条有 `run-ahead ARMED`,`running lockstep` / `run-ahead DISARMED` +均为 0,缺日志为 0**。这组才是首 caps 修复后的 run-ahead 功能证据。 +证据:`/home/swung/p65-ready7-tcp.{log,xml}`、`p65-ready7-tcp-lane.json` 与其所列角色日志。 +完整 retrace 与同模式 TCP/spawn CPU 对照不能由这 102 个用例替代。 + +### READY7 最终 ALL、unit 与名字门 + +最终 ALL 增量退出 0;完整 unit 为 **2399 项 = 2389 PASS + 10 既有条件 SKIP,0 FAIL**, +18.96 秒,新增三条 InitialCapsStartup 均通过。证据为 `/home/swung/p65-final-tests-build.log`、 +`p65-ready7-unit.{log,xml}`;没有把条件 skip 隐藏成 PASS。 + +最终 G14 为 **4016 → 4257,0 removed / 241 added**,新增包括设备 102 条。 +`--require-device` 名集合门实跑得到 split 105 条中可比 102,等于 spawn 102、TCP 102、device 102。 +证据:`/home/swung/p65-ready7-test-name-parity.json`、`p65-ready7-lane-parity.log`。 +完整标签、可比集合和 fixture 数仍分别记录,不能混成同一个计数。 + +必须另记制品身份:最后一次 CMake 重配置因 SPIRV-Cross git-version timestamp +`2026-09-22T08:02:51` 重编 `spirv_cross_c.cpp` 并重新链接,最终 host library SHA-256 为 +`25ca45d756bc5a4e5bd56ad4ce231e4ef89d45ba41131f389575cda0842cd6be`。 +MobileGL 自有 production objects 未重编,但这依然是**不同制品**;READY7 device 102 与此前 +CPU 样本使用原 host library(SHA 前缀 `29fc...`),这些证据保留原身份,不改记成新 SHA。 +正式矩阵使用新的 `25ca45d7...` 制品,不能只凭相同显式 build stamp 混用二者。 + +### 被旧 skip 掩盖的问题 + +首次 TCP 与 spawn 都是 97 PASS / 5 SKIP。没有将其记成 102 PASS,随后补齐了五条: + +- Triangle 的 verb-stamp 与 persistent-map counting 用例原本只在专门车道获得计数标记。 + 现在共享宏和 small-ring 的对应条目也设置真实统计环境;persistent-map 的 acquisition/bytes + 属于 client,分进程时取客户端真实计数差,避免读取 server 的结构性零。 +- 三条 Ct 用例原本因为读不到 server 内存而直接 skip。现在复用 `LogFlush`:client 先等待命令前缀 + 应用完成,server acquire 水位后输出已有 reset/serial/death 计数,client 读取同步后的角色日志。 + 仅 `MOBILEGL_TEST_SERVER_COUNTERS=1` 开启此诊断,没有在 draw 路径加输出。 +- 删除死亡用例的 skip 后,真地发现 spawn/TCP 的 ObjectDeaths 一直是 0,而 inproc 正常: + 独立 client 没安装死亡通知。`WireTables.cpp` 为它安装复用现有 wire emitter 的回调, + 不覆盖 inproc 已有 dispatcher。修复后五条 × 三臂 **15 PASS / 0 SKIP**, + 证据 `/home/swung/p65-fivecases-fixed.{log,xml}`。 + +TCP harness 的 fork pre-flight 也曾先占用远端单会话,使真正测试收到 Busy。Connect 下的驱动 +初始化已经在 server child 隔离,因此不再另开本地 pre-flight;TCP 车道强制 REQUIRE_GPU, +初始化失败是 FAIL,不能作为“本机没有 GPU”而 skip。 + +## 日志前送与 CI + +`tcp_log_forwarding_control.py` 直接执行原有 arming 场景,分别给 client 和 supervisor 设置真实特性。 +`/home/swung/p65-lf-controls2/summary.json` 及六臂角色日志证明: + +| 原有场景 | 后端 / 特性 | forwarding ON | forwarding OFF | +|---|---|---|---| +| UnlocatedIoBlock arming | DirectGLES / `MOBILEGL_ESPRYT_UNLOCATED_IO_BLOCKS=1` | PASS | 缺指定 server marker,FAIL | +| PrimitivesGenerated reroute arming | DirectVulkan / `MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE=1` | PASS | 缺指定 server marker,FAIL | +| PointSizeDemotion arming | DirectGLES / `MOBILEGL_POINT_SIZE_DEMOTION=1` | PASS | 缺指定 server marker,FAIL | + +六臂均无 skip;关闭前送时 server 本地仍真实产出 marker,client 本地没有同名替代 marker, +且失败必须发生在该 marker 断言,不能拿无关失败当负控成功。 + +第四条 `PipeVerifyArmingScenario.Armed` 的 marker 来自 client 的 `PipeFill`,所以正确预期是 +关闭 peer forwarding 后仍绿,不应硬造一个 server 负控。已配置并真实执行当前 verify+split 构建, +但它在 arming 前遇到旧 `PipeRespecifyScope` 断言;同代码 inproc/spawn 同样复现。 +这是既有 verify-only “全资源 respecify”断言与后来合法 per-level producer 的冲突, +不是 TCP 前送失败。证据 `/home/swung/p65-lf-verify/` 与 `/home/swung/p65-verify-controls/`; +此项尚未宣称通过。 + +CI 的 `runtime_mode_proof.py` 曾读取无角色后缀的日志名,导致真实两后端用例通过后仍 FileNotFoundError。 +修复为读取 client/server 两份,并保留任一缺失为硬失败。除了正反 self-test, +还真实执行了 strict + role-split-state + run-ahead 下的两条 P5fRsp 用例,**2 PASS / 0 SKIP**, +随后运行完整 `logs` proof 成功;证据 `/home/swung/p65-ci-runtime-proof.{log,xml,json}`。 + +TCP 工具自身有名字漂移、伪 arm marker、ready 探针不消耗 accept 连接、PID 复用、 +设备 idle 配置恢复等正反控制;`tcp_lane_tools_test.py` 在 WSL 为 **4/4 PASS**。 +link seam 的七个负控与生产 ownership 门已接入 `test.yml`,结果见相邻实现报告。 + +正式 trace driver 为 `tools/trace_replay/run_tcp_matrix.py`,工具 stub 回归 **12/12 PASS**; +没有用 stub 冒充真实渲染。默认按正规 manifest loader 选择 39 个 CI split case,非 CI 的 rd12 +只能显式选择且单列。新尝试独立目录,失败/超时/陈旧结果不作为 passed checkpoint;严格 resume +不能复用旧 lockstep 图像阳性。 + +正式 GLES 矩阵使用 `--require-run-ahead`:显式请求 RUN_AHEAD=1 与 VERB_BARRIER=1,逐例要求 +私有 client 日志 ARMED 且没有 lockstep / DISARMED,并保存 requested_arm / actual_arm。 +默认 300 秒日志无进展截止与 7200 秒绝对上限分开,不沿用本机 main-menu 的 180 秒预算; +旧脚本误杀 CMake 而遗留 retrace 子进程造成 Busy 的失败保留诊断,不计为 GPU 差异或矩阵通过。 +新 driver 的超时清理覆盖整个进程组并回收孤儿,正式 39 条完成结果仍待登记。 + +## 真机进展与环境事实 + +设备为 Redmi `2f7cbe2e`,aarch64 / Adreno 830,IP `192.168.21.181`。 +已安装并启动 trace flavour 的前台 Service,supervisor 监听 `tcp://0.0.0.0:40613`, +两端令牌一致;使用显式设置的 `MOBILEGL_BUILD_STAMP`,不依赖 APK 的 `nogit` 文件名。 +当前沿用的 `410dfa94...` 是共享制品标识,不是当前 `52ffc6a7` 源码 HEAD。 +已经收到真实 Welcome 与 Adreno caps,wire/build 指纹均接受。 +此前安装的 traceDebug 原生实际只有 `-g`、没有优化,以上只作功能诊断,不能作为 release 性能数。 +后续功能复验已使用原生 `-O2/NDEBUG` 的 debuggable traceRelease APK,并保持受信任签名; +各组模式与边界见下,不能把 Debug 诊断数当优化制品性能。 + +必须区分已经定位出的三类问题: + +1. 手机 Dozing 时曾看到 native supervisor `wchan=do_freezer_trap`,前台 Service 的 partial wake lock + 显示 DISABLED。仅给测试包增加 deviceidle 白名单不足以立即解除所有冻结;Awake 下进程与 wake lock + 恢复。没有关闭全局 Doze/freezer,也没有靠延长控制超时掩盖冻结。 +2. Awake 后仍有 LAN 回包只到第一个 MSS 的独立问题。WSL 默认到手机经 `eth2`、源 `172.18.0.1`, + 对应 Windows `xray_tun`。USB `adb forward` 对照通过 EGL;加一条仅针对手机的临时 host route 后, + LAN 同样通过 EGL,完整 peer 日志由十余行恢复到数百行。该环境修复不是 transport timeout 改动。 +3. 真正开始跨 ABI draw 后,暴露 archive 头对 native C++ `sizeof` 的比较(Linux 1056 / Android 0)。 + portable archive v2/schema 已落地,并进入握手指纹;重编后的两端已通过真实 LAN shader/draw 与 + 64 MiB 上传阳性。最终 golden retrace 和性能数仍须使用优化制品采集。 + +### Debug 真机首轮 102 条 + +archive v2 的 Debug 制品首轮结果为 **73 PASS / 29 FAIL / 0 SKIP**,61.44 秒。 +29 条全在 `eglInitialize` 前失败:20 条为明确 `Refuse{Busy}`,9 条为 Welcome 前 transport closed; +没有理由把它们称为 GPU 像素或能力差异。当前证据指向连续用例的客户端退出与远端 child 清理完成 +之间的会话收尾竞态,必须处理后再跑完整车道,不能用 skip 消掉失败。 + +逐名诊断在 `/home/swung/p65-debug-device-differences.json`,完整 `.log/.xml` 为同前缀。 +175 份已存在的角色日志已复制到 `/home/swung/p65-debug-device-rolelogs/`, +避免后续同名用例覆盖首轮证据。Discovery 与正常 Awake 维持记录分别为 +`p65-debug-device-lane.json`、`p65-debug-device-awake.json`。 + +### 优化 Release 真机复验:历史 lockstep 功能阳性 + +有序会话收尾修复后,客户端等待 server EOF 完成退出;Busy 拒绝先消费 Hello, +避免带未读请求关闭 socket 时的 RST 丢失拒绝帧。优化 APK 的 native 实际编译参数为 +`-O2 -DNDEBUG`,签名与已安装调试证书相容。 + +该制品 SHA-256 为 `70a4a3c776a61e03a3c0ad02cbdee38fd6af483a6c7cedeaf33bb1f8531fd228`, +真机完整车道为 **102 PASS / 0 FAIL / 0 SKIP**,98.59 秒。 +全部 client 日志中 `Refuse{Busy}`、`no Welcome`、`ALIVE BUT SILENT` 均为 **0**; +逐条 TCP/stream/远端 endpoint/pid arm proof 通过,名字集合与 spawn 的 102 条完全一致。 + +证据:`/home/swung/p65-release-device.{log,xml}`、`p65-release-device-lane.json`、 +`p65-release-device-awake.json`,以及已保全的 204 份角色日志目录 +`/home/swung/p65-release-device-rolelogs/`。没有删用例、放宽像素断言或恢复 skip。 + +**这组 102 条后来确认实际为 lockstep,而非 ARMED。** 它关闭的是当次连续会话的 Busy/Welcome +初始化失败,保留为有意义的功能阳性;它没有关闭 run-ahead 形态下的设备验收。 +上面的 APK SHA 只属于这组历史制品,不移记为 READY7 的产物身份。 + +### 首 caps race 与 READY7 真机复验 + +性能配对的日志揭露了启动竞态:控制 reader 尚未交付真实首 CapsSnapshot 时,client 就把 +placeholder 的零 capability 当作 server 的回答;后续“只能 demotion”使本次 session 一直停留在 +lockstep。修复要求先收到并采用真实首 caps,再宣布 session started 并锁存 run-ahead。 + +READY7 Redmi 车道已经完成:**102 PASS / 0 FAIL / 0 SKIP**,72.23 秒。 +严格 `--require-run-ahead` proof 通过:102 条实际 ARMED,lockstep / DISARMED / Busy / noWelcome +均为 0。证据为 `/home/swung/p65-armed-device.{log,xml}`、`p65-armed-device-lane.json`, +204 份角色日志保全于 `/home/swung/p65-armed-device-rolelogs/`。 +这组与 READY7 主机结果构成首 caps 修复的实际模式复验;正式 39 条 CI split golden 矩阵与 +同模式性能对照仍待验证,不以这一组结果宣布整个阶段关闭。 + +### 真实对端死亡与 Wi-Fi 半开连接门 + +以下故障注入已有当次独立证据,无需把它们继续列作“从未采集”: + +| 故障 | 观测到 peer hangup / device-lost | 结果与证据 | +|---|---:|---| +| 真正 kill server child(PID 18824) | 133 ms / 133 ms | PASS;`/home/swung/p65-device-peerloss/kill.log` 与两角色日志 | +| 直连 LAN 禁用 Wi-Fi(PID 18979) | 5053 ms / 5053 ms | PASS;`/home/swung/p65-device-wifi-loss/probe.log`、`timing.json` 与两角色日志 | + +两个 probe 都通过生产 barrier 观察挂断并闩 device loss,`gl_reset=0x8255`,10 秒截止内通过; +不是发送“请退出”控制消息,也不是把 apply timeout 冒充挂断。Wi-Fi 门使用 **LAN direct eth0, +不是 adb forward**;父控制器从 disable 计到返回为 5.090431 秒,probe 自身记 5053 ms。 +它们证明故障闩机制,不代替 READY7 完整图像/性能验收。 + +### 临时环境的恢复责任 + +`tcp_device_server.py --allow-idle` 是显式选项,不默认修改设置。原状态已保存为 +`/home/swung/.cache/mobilegl/tcp-server/2f7cbe2e-top.mobilegl.plugin.trace.json`, +内容 `originally_whitelisted=false`。重复 start 不覆盖这个原值。所有设备工作完成后,在 WSL 运行: + +```bash +python3 /tools/trace_replay/tcp_device_server.py stop --serial 2f7cbe2e \ + --state-file /home/swung/.cache/mobilegl/tcp-server/2f7cbe2e-top.mobilegl.plugin.trace.json +``` + +它停止本测试包并撤销本次添加的包级 idle 豁免。root 另负责撤回原本不存在的网络辅助配置: + +```bash +ip route del 192.168.21.181/32 via 192.168.31.1 dev eth0 metric 10 # WSL root +adb -s 2f7cbe2e forward --remove tcp:40615 +``` + +## 尚未关闭的出口项 + +- READY7 真正 ARMED 的主机 TCP 和设备 102 已取得;仍需把最终冻结制品的 ALL、必要回归、 + discovery/逐条 mode proof 与阶段结论对应起来,不能引用旧 lockstep 102 代替。 +- 正式 WSL → Redmi **39 个 CI split case** 的 golden 矩阵(DirectGLES 按现有阈值,OpenRA 1.0); + `ci:false` 的 rd12 作为额外 golden/性能负载单列。旧 scratch 的前两条阳性保留,不能自动变成 + 新 driver/checkpoint 的通过。DirectVulkan 按本波口径记录,不作为相同的硬像素门。 +- 同一真实 ARMED 模式下 OpenRA / MC in-world 的 wait-reply、RTT、Stage bytes/frame 与链路 + 吞吐对应、credit 1/2/3、loopback TCP 对 spawn 的逐线程 CPU 配对数。旧 TCP lockstep 的 CPU + 样本是揭露错误模式的负控,不作为传输成本结论。 +- inproc/spawn 的 before/after 逐线程 CPU 已测,但结果不是“逐字不变”;其口径和局限见 + [performance.md](performance.md)。数据不会被抹除,也不能把变化全部归因于 lk2 虚调用。 +- lf 第四条仍有具名 verify+split 前置债,且其 client-owned marker 本来不能作 server forwarding + 负控。三个 server marker 的正负控与故障闩门已有证据,不重复列成缺采。 +- 阶段审查已完成,见下;剩余验收结果与最终制品证据索引仍需收口,本账本不宣布 P6.5 收官。 + +## 阶段审查状态 + +原上下文已完成一次 `phase_review` 阶段审查,以及一次针对 half-close 的极窄 follow-up。 +按 ID-66 不再另跑完整阶段审查。 + +阶段审查发现 Present / `glFlush` 的尾批在 client 随后空闲时未投递;已修复为发送边界,并用 +真实生产入口的零等待回归证明没有偷加 apply wait: + +- `StreamClientPublication.AllThreePresentCreditsReachAnIdlePeerWithoutAnyClientWait` +- `StreamClientPublication.ExportedGlFlushDeliversPublishedCommandsWithoutWaitingForApplied` + +证据在 `MobileGL/MG_Test/Wire/StreamClientPublicationTest.cpp` 和对应定向测试记录。 +half-close follow-up 未发现 normal-path 死锁或 UAF;它指出 `WaitClosed == true` 也包含 terminal +protocol error,不能仅凭该布尔值声称正常 EOF。该语义限制保留,不把 follow-up 改写为所有错误 +路径均正常关闭的保证。最终证据索引由收尾报告统一串联。 + +## 主机 CPU 配对采集方法 + +`tools/trace_replay/measure_transport_cpu.py` 复用同一个已有 retrace runner,分别载入旧、新 library +和与之配套的 server。OpenRA 的完整 128 帧取尾 100 帧,rd12 的 251 帧取尾 200 帧; +每条臂保留完整 benchmark JSON、逐线程原始采样和三个制品的 SHA-256。 +client 读现有 `CLOCK_THREAD_CPUTIME_ID` 帧数组;apply 读取 `/proc//task//schedstat` +的执行纳秒数,选最早的 `mgl-srv-apply` TID,排除继承该名字的 driver helpers,不以进程 CPU 代替。 + +apply 的帧窗口用 benchmark JSON 的写入时间和已有 wall-frame 数组近似对齐,边界在外部采样点间插值。 +结果明确保留实际最大采样间隔,并说明尚有 summary/JSON 写入延迟;不能把它写成周期精确的测量。 +已有 `before-pilot-not-comparable` 试跑只验证采集机制,发生在并发构建期间,不纳入正式 A/B。 +正式 before 已在重型构建停止后完成,原始记录为 `/home/swung/p65-host-cpu-before/summary.json`。 +使用现存的 P6.5 之前 `lk-split` 制品;三个库/runner 与 trace 的哈希均随样本保存, +不把缓存中的旧 build stamp 冒充精确源码基线。以下是各三轮逐轮均值的中位数,单位 ms/帧: + +| trace / transport | client CPU | apply CPU(外部窗口近似) | +|---|---:|---:| +| OpenRA / inproc | 0.68184 | 3.04067 | +| OpenRA / spawn | 0.80624 | 3.24204 | +| rd12 / inproc | 8.28739 | 64.88425 | +| rd12 / spawn | 10.27651 | 67.61499 | + +after 已按同参数完成,各 case/transport 前后均为三轮;完整差值和精度限制见 +[performance.md](performance.md)。这组前后数包含整波正确性修复(例如补齐独立 client 的死亡记录), +不能把全部差值归因于 lk2 虚调用,也不能称为“逐字不变”。 + + +上述 inproc/spawn before/after 表不是 READY7 的 TCP 传输成本表。此前另做的 TCP/spawn 比较中, +spawn 已 ARMED 而 TCP 被首 caps race 留在 lockstep;原样保留在 +`/home/swung/p65-loopback-cpu/{summary,summary-with-arm-proof}.json`,不进入正式传输结论。 +正式 TCP 对照与 golden driver 必须检查实际 `run-ahead ARMED`,同时拒绝 lockstep fallback / DISARMED。 diff --git a/docs/Disaggregated/notes/p65/wire-and-validation.md b/docs/Disaggregated/notes/p65/wire-and-validation.md new file mode 100644 index 00000000..d04ac714 --- /dev/null +++ b/docs/Disaggregated/notes/p65/wire-and-validation.md @@ -0,0 +1,143 @@ +# P6.5 wire、计量与主机验证记录 + +基线是 `origin/feat/disaggregated@2ec8e7f4`,实现树是 `410dfa94c3d92241e9ec01ddbba1bb911eb60d4b` 加本轮未提交修改。没有推送。以下数字注明采集边界,不代表整个 P6.5 已收官;设备、完整 integration 与最终阶段审查另记。 + +## wire 事实与握手 + +`gen_pipe.py` 从 `PipeFields.def` 生成 82 个 payload/value struct 的成员名、`offsetof`、成员 `sizeof`,以及 81 个 opcode 的有序目录(payload、flags、WaitClass)。摘要还覆盖记录头、handle、render-state chunk 边界、caps codec 版本、caps 尺寸、协议版本、字节序与指针宽度。git stamp 与函数指针表尺寸不再混入 wire 指纹。`DynamicBackendParameters` 的三处 `SizeT` 改为 `Uint64`,两处 `GLenum` 改为 `Uint32`;本波 payload/blob 数值区没有裸 `char` 成员。 + +Hello/Welcome 新字段采用 append;保留旧 FlatBuffers 字段槽。`wireFingerprint` 必须一致;Fork 必须同 build,Connect 的不同 build 默认警告,`MOBILEGL_IPC_REQUIRE_SAME_BUILD=1` 时拒绝。握手不兼容经 `Refuse` 返回,不调用 abort。LinkTerms 的四个窗口由 server 陈述。SurfaceReply 的 `eventHead` fence 使控制回复不能越过尚未送达的数据面事件。 + +使用 WSL clang 与 NDK 27.3 arm64 的**实际 CapsCodec 编译参数**分别编译 constexpr probe,结果如下;不是凭 LP64 推测一致。 + +| 事实 | x86_64 Linux | arm64 Android | +|---|---:|---:| +| 成员布局摘要 | 4304347197452401705 | 4304347197452401705 | +| 有序目录摘要 | 16257371160882448404 | 16257371160882448404 | +| render-state 边界摘要 | 2319517018389825580 | 2319517018389825580 | +| DynamicBackendParameters | 352 B | 352 B | +| MGPCaps | 408 B | 408 B | +| 指针宽度 | 64 bit | 64 bit | +| format 维度 | 76 | 76 | +| target 维度 | 12 | 12 | + +证据:WSL `/tmp/p65-crossabi-80fz3t4f/{probe.cpp,x86_64.s,arm64.s}`。 + +### wf 控制 + +- 真正复制到临时树后,互换 `MGPRange::Offset`/`Size` 两个同宽 `Uint64` 成员;成员布局摘要由 `4304347197452401705` 变为 `6038841773737773369`,两次使用同一未改动 build header。证据 `/tmp/p65-wire-control-ehave5w9/{before.s,after.s}`。生产树没有被临时改坏。 +- `SessionHandshakeTest` 的生产 Accept 路径测试了 major=99、错误 wire 摘要的具名 Refuse、空 union 拒绝、Connect 异 build 成功、Fork 异 build 拒绝、Connect 强制同 build 拒绝;9/9 通过。 +- 真实 TCP supervisor 的开发期控制 `/home/swung/p65-supervisor-smoke/protocol-controls.json` 记录 major=99 → code 1、错误布局 → code 2、不同 build 的 Connect → Welcome、强制同 build → code 3,并验证拒绝后 supervisor 仍活着。该记录采于 rebase 前的 `2bd86664` 开发产物;不能据此冒称最新产物的所有进程退出条件均已重新取证。最终 control 复跑由阶段主报告记载。 + +## G1 与生成器 + +在同一 `/home/swung/p65-g1-src` 源路径、同一 `/home/swung/p65-g1-build` build 路径先编基线再覆盖 P65 源码,保持 clang/Release、pull、LTO OFF 等选项相同。第三方依赖复用同一只读来源;覆盖后更新时间戳,实际重新编译了受影响对象。 + +- 定义符号:**27837 → 27837,新增/删除/resize/rename = 0/0/0/0**。 +- `.text`:**10822147 → 10822147 B**,抽出后逐字节相同。 +- 两侧 `.text` SHA-256:`35115edd2c265162c7f845a87d1a2cb9849609a40ba0febac317b91370b9163c`。 +- `.data`、`.bss`、`.rodata` 也各自尺寸不变。 + +证据:`/home/swung/p65-g1-report.log`、`p65-g1-symbols.json`、`p65-g1-before.so`、`p65-g1-{before,after}.text`。这次 G1 包含 wf 的宿主类型改动;此后的 server/stream/计量收尾均在 disaggregated-only 源中。 + +`gen_pipe.py --check/--self-test`、`gen_pipe_field_ownership.py --check/--self-test` 全绿;flatc 在临时目录重生的 body 与提交文件逐字相同;ROADMAP/ARCHITECTURE/CURRENT_STAGE_PROGRESS 的 78 条文档引用检查零问题。 + +## unit 与唯一环境项 + +最终全 unit 跑了 **2389 项**(`/home/swung/p65-unit-final.log`):第一次为 **2378 PASS、10 既有条件 SKIP、1 环境 FAIL**。唯一红是 `PipeCatalogue.FrontendNeverTakesAnApplierAddress` 从离树 build 的 cwd 向上查源码,错误落到 `/MobileGL/MG_Impl`;不是发现了前端持有 applier 地址。 + +已仅为该测试设置 source-root `WORKING_DIRECTORY`,保留测试名和其他测试的 cwd。重配后只重跑这一项,CTest **1/1 PASS、退出 0**(`/home/swung/p65-unit-cwd-fixed.log`)。因此本轮完整计数为 **2379 个已验证通过用例 + 10 个既有条件 skip**,没有把全 2389 又跑一遍。 + +249 项定向 wire/session/codec/loop/remote/protocol/surface/socket/framing 测试也全绿。最初正则漏了 fixture 名 `StreamPair.*`,不能把这 249 项称为覆盖所有 StreamLink 用例。补跑 8 项时抓到真实接收顺序缺陷:`AdvanceRetired` 先按旧 applied 夹紧,最后 retired 永久丢失。修为先 applied 后 retired 后,8/8 通过(`/home/swung/p65-streampair-fixed.log`),随后纳入上述全 unit。详情见 [数据面实现记录](data-link-implementation.md)。 + +## OpenRA 真实计量 + +首条 loopback TCP OpenRA retrace:SSIM **1.0**、零差异像素、外部运行耗时 **2.118 s**。日志在 `/home/swung/p65-retrace-loopback-smoke/OpenRA/output/mobilegl.{client,server}.log`。这次 smoke 在上述退休顺序修复前采集,只作为当次图像与计量记录;修复后的功能验收另记。 + +计量内部检查:client/server 都恰有 frame 1..29;client 每帧固定 32 桶之和 = `rtt_samples` = `wait_replies`,全跑 reply **221 次**;server 首帧只建基线(valid=0),随后 28 帧有效。两侧有效记录均满足 `0 < thread CPU <= wall`。计时仅在 `MOBILEGL_PIPE_STATS=1` 武装;reply 从 publish 前计至 applied wait 完成,包含 Flush,不包含先前编码/command 空间等待;RTT 分位数明确是桶上界。 + +OpenRA 的第 5 帧含大批纹理上传,不能仅丢前三帧就把均值叫作平稳性能。保留全跑数据,同时明确取 **frame 6..29** 的 24 帧: + +| 指标 | 本次 TCP loopback 单臂 | +|---|---:| +| reply / 帧 | 2.000 | +| RTT 均值 | 288.066 μs | +| RTT p50 / p99 桶上界 | 512 / 1024 μs | +| Stage / 帧 | 130614 B | +| 帧流量速率 | 10.328 MiB/s | +| client 线程 CPU / 帧 | 3.0800 ms | +| apply 线程 CPU / 帧 | 5.5496 ms | +| fps | 82.917 | + +全跑 Stage 合计 **233980258 B**;P6 文档的历史 OpenRA 合计为 233979394 B,相差 864 B,不能声称历史两跑逐字相同。当前 snapshot 提前退出、不走 Destroy,所以 client 没有 PipeStats JSON/最终摘要;server 的 `seg=0` 是生产端计数属于 client 的结果,不可用作交叉校验真值。当前两个采样点在同一个 StageAllocate 成功路径、同用 `size`,彼此不相加;这批日志没有提供同跑上游 `TotalBytes(StageSegmentBytes)` 的独立数值证据。 + +聚合结果:`/home/swung/p65-openra-metrics.json`(warmup 3,仍含尖峰)与 `p65-openra-steady-metrics.json`(warmup 5)。解析器为 `scripts/ci/p65_link_metrics.py`,`--server-arm LABEL:LOG` 合并 apply 线程 CPU;尚不是 spawn/TCP 配对 A/B 结论。真 TCP 64 MiB burst 另测 **917.397 MiB/s**,不可把帧流量速率充作链路峰值,也不可将 loopback 数字代表 Wi-Fi/adb 设备。 + +## 具名前置债:verify + split 阻止 LF 第四例 + +`PipeVerifyArmingScenario.Armed` 的 TCP verify 臂在测试 draw 前就遇到: + +```text +Fatal{PipeRespecifyScope} resource_respecify {slot=12, gen=0}: +scope (target=258, level=0), and no path in this phase may set one +``` + +使用同一 fresh `/home/swung/p65-verify` 库、server 和 itest,控制臂也得到同因首阻塞: + +| transport | 结果与证据 | +|---|---| +| TCP stream | 既有 scope pin;`/home/swung/p65-lf-verify/verify/forward-on/peer.server.log` | +| inproc | 同一 slot/target/level 的 scope pin;`/home/swung/p65-verify-controls/inproc/test.server.log` | +| spawn | 同一 slot/target/level 的 scope pin;`/home/swung/p65-verify-controls/spawn/test.server.log` | + +不是本波 wire 布局差异,也不是不同库混配。`PipeApply.cpp` 的 `PinWholeResourceRespecifyScope` 在 `MOBILEGL_PIPE_VERIFY` 下仍禁止任何 per-level scope,来自 `38f34c13`(2026-09-11);`WireTables.cpp` 在合法 per-level respecify 上调用 `MGPipeSetRespecifiedLevel` 的 producer 来自 `02f15bb6`(2026-09-16),两处均已存在于 P6 基线。旧 pin 未随 producer 落地退役;本任务没有顺手改这条既有生产语义。 + +此外 `MGPipe: verify armed` 是 **client PipeFill 的 marker**,即使 verify+split 前置债关闭,关闭 server 日志前送也不应让它消失;它不能成为 server forwarding 的反控。第四例目前是**具名前置阻断,未执行到 arming 断言**,不是 LF 转发失败,也没有被计成通过。其他三个 server-owned marker 的 LF 正负控另由阶段主报告记载。 + + +## 真机发现后的补充:program archive 的宿主尺寸误作 wire 事实 + +八项 POD 布局相同并不意味着所有 blob codec 都跨工具链兼容。WSL client → Android server 经 adb-forward TCP 已完成 EGL,但首次 draw 在 `CreateShaderState.Reflection` 拒绝 archive(`/home/swung/p65-device-usb-probe/mobilegl.server.log`)。实际原因是旧 ProgramArtifactsCodec v1 的第二个头字段:Linux 写 `MGL_LINKARTIFACTS_SIZE=1056`,Android 未 pin 的 libc++ 分支期待 0,尚未读取任何逐字段 payload 就拒绝。 + +使用两端实际编译参数导出常量,结果为: + +| 宿主事实(不是 wire 格式) | Linux libstdc++ | Android libc++ | +|---|---:|---:| +| sizeof(LinkArtifacts) | 1056 | 1000 | +| sizeof(String) | 32 | 24 | +| sizeof(ResourceReflection) | 128 | 120 | +| 旧 v1 size echo | 1056 | 0 | + +证据 `/tmp/p65-archive-portable-0bzpmyxr/{linux,android}-sizes.s`。不能通过简单删除该检查解决:字段顺序与 scalar representation 仍需断言。 + +修复在 disaggregated 构建启用 archive v2:第二个头字段改为从实际 `VisitFields` 顺序、字段名、容器类别/元素 schema、固定数组长度、scalar 宽度/符号性/浮点格式递归得到的 wire-schema 摘要。字符串只描述计数和内容字节;没有任何 `sizeof(string/vector/map)` 或宿主偏移混入。默认空 record 仅供取字段类型,容器递归 `value_type`,不遍历空元素;摘要每进程只计算一次。glslang UniformInitializer 的八字段表由 writer/reader/schema 共用。原 payload 的逐 scalar 序列化算法保持不变,旧 `LinkArtifacts::program` 仍不运输。 + +archive codec 版本和该 schema 摘要同时纳入 Hello 的 WireFingerprint,使真正不兼容的 archive 在握手先拒绝。新增负控要求 v2 拒绝旧 native echo 1056、0 和旧 v1;既有测试名不删除。非 disaggregated 分支保留 v1 头和旧读写语句;现有 G1 pull graph 不编译 ProgramArtifactsCodec.cpp。 + +此补充写入时,Linux/NDK 的 codec 与 CapsCodec、Linux 的两份相关测试已按实际 compile_commands 做独立语法检查,六项全部通过;没有另起 ninja。双端重编后的 codec 单测与真实设备 draw 结果由最终阶段报告更新,不把语法检查写成设备验收。 + + +### portable codec 的窄审与后续实证 + +按 serializer/deserializer 的分支逐项核对 schema:scalar(类型类别、宽度、enum underlying、浮点格式)、string、map、set、固定数组、vector、UniformInitializer、VisitFields record 均有对应描述;容器的元素类型即使运行时为空也会递归描述。五张实际字段表分别为 TypeFacts 20、ResourceReflection 14、XfbVarying 11、LinkArtifacts 57、SpirvArtifacts 8,名字无重复且与成员一致;唯一跳过的 `LinkArtifacts::program` 仍是既有明确豁免。UniformInitializer 的 8 字段由三个方向共用同一表。该检查针对**实际序列化字段的覆盖**,没有把宿主成员偏移重新引入 wire;未发现漏掉现有 serialized field 的分支。 + +root 统一重编后,`/home/swung/p65-final2-targeted.log` 的 54 项定向测试全部通过,包括 fully-populated archive、截断/版本拒绝、新 portable header 负控、两项 archive fingerprint 输入的敏感性检查、已有握手正负控。两端 v2 的 Debug 制品已在真实 LAN 上完成 GL shader 与 64 MiB 上传;这是功能通过,不能充作 Release 性能或完整设备车道收官。 + +最后一次源码冻结(含 glFlush、Present、stage cancellation、portable codec)后又用原路径更新 G1 snapshot,只重编 after、不重建 before。最新结果仍是 **27837→27837,0/0/0/0,`.text` 10822147 B 逐字相同**,SHA-256 与前表一致。证据为 `/home/swung/p65-g1-latest-{build,report}.log`、`p65-g1-latest-symbols.json`、`p65-g1-latest.text`。没有降低 G1 判据,也没有将只看尺寸当作字节恒等。 + + +## 真实 ServerLoop idle publication 的 red-once + +此控制没有删除测试里的手动 flush。仅复制生产 `ServerLoop.cpp` 到临时目录,删除 `ApplyThreadMain` 真实 idle 分支的 `session.FlushDataProgress()`;保留 `SessionConsumer::WaitForWork` 的 flush、PostReply 的 progress、64 条/1 ms 批量发布及所有其他代码。使用当前 `compile_commands.json` 只编该 object,再从 `ninja -t commands` 的生产 link 命令只替换该 object 与输出路径,生成临时 `libMobileGL.so`。原 client 不变,复制的 server wrapper 通过子进程 `LD_LIBRARY_PATH` 加载临时库。没有在共享 build 目录执行 ninja build。 + +具体用例:`ClearThenReadPixelsScenario.ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels`,真实 TCP 控制与数据连接,三个独立 loopback 端口对;没有使用设备的 40613。透明 TCP proxy 原样转发并记录 MGLD 帧,观测提交序号、Reply 序号与 Progress 水位,不修改 codec 或 payload。 + +| 臂 | 结果 | 实际耗时 | 最后 submitted / Reply / applied | +|---|---|---:|---| +| 原库(先) | 1 PASS / 0 SKIP | 0.665425 s | 57 / 56 / 57 | +| 仅删除生产 idle flush | 8 s 短截止抓到挂住 | 8.000486 s | **1 / 1 / 0** | +| 恢复原库(后) | 1 PASS / 0 SKIP | 0.215740 s | 57 / 56 / 57 | + +阴性明确卡在该用例初始化阶段的 **seq 1、opcode 2 `ResourceCreate`**(PipeCalls 的 `kWaitReply` 行,96 B record),不是冒称已进入 ReadPixels。server 已发送 `Reply{seq=1}`,但最后到达 client 的 applied 仍是 0;没有后续命令替它推进水位。8 s 截止时 client、supervisor、session child 仍活着,无 EOF、无 proxy 错误,`mgl-srv-apply` 位于 `futex_do_wait`。这证明健康连接下最后一个真实 reply record 需要生产 idle publication;测试只声明超过短截止,不把观察时间说成无限等待。 + +三臂都有真实 `control=tcp data=stream ... dial=connect` marker。主 `libMobileGL.so` 与生产 `ServerLoop.cpp` 的 SHA-256 在试验前后相同;APK 与主设备端口未动。证据目录 `/tmp/p65-idle-redonce-c_n59aqs/` 包含原始/变异单 TU、object、临时 library、link argv、`run-control.py`、`results.json`、各臂 `data-frames.json`/角色日志/XML 以及主产物 hash manifest;控制台记录 `/home/swung/p65-idle-redonce-run.log`。 diff --git a/scripts/ci/junit_tally.py b/scripts/ci/junit_tally.py index 2f727f00..4a4a7941 100755 --- a/scripts/ci/junit_tally.py +++ b/scripts/ci/junit_tally.py @@ -37,7 +37,7 @@ ARM_PREFIXES = { TCP_ARM = re.compile(r'control=tcp data=stream server=\S+ pid=[1-9][0-9]*\b') -def require_tcp_proof(path, prefix, discovery): +def require_tcp_proof(path, prefix, discovery, require_run_ahead=False): helper_path = Path(__file__).resolve().parents[2] / 'MobileGL/MG_IntegrationTest/Harness/split_log_paths.py' spec = importlib.util.spec_from_file_location('split_log_paths', helper_path) helper = importlib.util.module_from_spec(spec) @@ -56,6 +56,9 @@ def require_tcp_proof(path, prefix, discovery): output = helper.read_role_logs(logs[name]) if not TCP_ARM.search(output): raise ValueError(f"{case.get('name')}: missing TCP/stream endpoint and server pid arm proof") + if require_run_ahead and ('run-ahead ARMED' not in output or 'running lockstep' in output + or 'run-ahead DISARMED' in output): + raise ValueError(f"{case.get('name')}: TCP runtime did not keep requested run-ahead armed") def tally(path, prefix=None): @@ -79,14 +82,18 @@ def main(): for flag, prefix in ARM_PREFIXES.items(): arms.add_argument(flag, dest='prefix', action='store_const', const=prefix) parser.add_argument('--discovery', help='CTest --show-only=json-v1 output (required for TCP proof)') + parser.add_argument('--require-run-ahead', action='store_true', + help='Additionally prove actual run-ahead, without fallback or demotion, on each TCP case') args = parser.parse_args() if args.prefix in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.') and not args.discovery: parser.error('TCP arm proof requires --discovery to locate each private role log') + if args.require_run_ahead and args.prefix not in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.'): + parser.error('--require-run-ahead requires a TCP arm selection') try: prefix = args.prefix passed, failed, skipped = tally(args.junit, prefix=prefix) if prefix in ('DirectGLES.Tcp.', 'DirectGLES.TcpDevice.'): - require_tcp_proof(args.junit, prefix, args.discovery) + require_tcp_proof(args.junit, prefix, args.discovery, args.require_run_ahead) except Exception as exc: # a malformed file is not "zero of everything" print(f"junit_tally: cannot prove {args.junit}: {exc}", file=sys.stderr) return 1 diff --git a/scripts/ci/link_seam_purity.py b/scripts/ci/link_seam_purity.py new file mode 100644 index 00000000..f2fe2690 --- /dev/null +++ b/scripts/ci/link_seam_purity.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""P6.5 ownership and dispatch boundary, with explicit compatibility limits. + +The old raw constructors and Session::Control/Shm test/bootstrap accessors remain +source compatible. Ring endpoints are cached, non-owning setup handles, shared +by both data planes. This gate does not pretend that their type names disappeared; +it rejects ownership and concrete-link dispatch outside Transport instead. +""" +from __future__ import annotations +import argparse +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] +SOURCES = [ + "MobileGL/MG_Remote/Client/ClientSession.h", + "MobileGL/MG_Remote/Client/ClientSession.cpp", + "MobileGL/MG_Remote/Server/ServerSession.h", + "MobileGL/MG_Remote/Server/ServerSession.cpp", + "MobileGL/MG_Remote/Server/ServerLoop.cpp", + "MobileGL/MG_Remote/Wire/PipeWireCodec.h", + "MobileGL/MG_Remote/Wire/PipeWireCodec.cpp", + "MobileGL/MG_Impl/Pipe/ResourceTracker.h", +] +PHYSICAL = r"(?:SessionSegments|ReplySlotPool|RingProducer|RingConsumer|EventRingProducer|EventRingConsumer)" + +def code_only(source: str) -> str: + return re.sub(r"/\*.*?\*/|//[^\n]*", "", source, flags=re.S) + +def violations(path: str, text: str) -> list[str]: + code = code_only(text) + found: list[str] = [] + rules = [ + (rf"\b{PHYSICAL}\s+m_\w+\s*(?:[;{{=])", "physical data-plane ownership outside Transport"), + (r"\bm_shm\s*\.", "session bypasses its link for segment access"), + (r"(?:new\s+(?:Transport::)?|make_unique\s*<\s*(?:Transport::)?)(?:ShmLink|StreamLink)\b", "concrete link selected outside Transport factory"), + (r'#\s*include\s*[<"][^>"\n]*(?:ShmLink|StreamLink)\.h', "concrete link included above Transport"), + ] + if path.endswith("ServerLoop.cpp"): + rules.append((r"\bRingControl\b|(?:\.|->)Control\(\)", "apply loop reads a physical control page")) + if path.endswith("PipeWireCodec.h"): + rules.append((r"\bRingControl\s*\*\s*m_", "codec stores physical page instead of setup progress/signals")) + if path.endswith("ResourceTracker.h"): + rules.append((r"Segments\(\)\.Resolve", "writeback bypasses ILink span resolution")) + for pattern, message in rules: + for match in re.finditer(pattern, code): + line = code.count("\n", 0, match.start()) + 1 + found.append(f"{path}:{line}: {message}") + return found + +def self_test() -> None: + controls = [ + (SOURCES[0], "Transport::SessionSegments m_segments;"), + (SOURCES[0], "Transport::ReplySlotPool m_replies;"), + (SOURCES[1], "m_shm.Close();"), + (SOURCES[1], "auto link = std::make_unique();"), + (SOURCES[4], "auto& page = session.Control();"), + (SOURCES[5], "Transport::RingControl* m_control = nullptr;"), + (SOURCES[-1], "session->Segments().Resolve(4, offset, size);"), + ] + for path, source in controls: + assert violations(path, source), f"negative control did not turn red: {source}" + assert not violations(SOURCES[0], "Transport::RingProducer* m_cmd = nullptr;") + print(f"link seam negative controls: {len(controls)} named failures observed") + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + if args.self_test: + self_test() + failures = [failure for name in SOURCES for failure in violations(name, (ROOT / name).read_text(encoding="utf-8"))] + if failures: + print("\n".join(failures)) + return 1 + print("link seam ownership/dispatch gate: PASS (cached endpoints and legacy test constructors retained)") + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/p65_link_metrics.py b/scripts/ci/p65_link_metrics.py index b006b919..ca6435d3 100644 --- a/scripts/ci/p65_link_metrics.py +++ b/scripts/ci/p65_link_metrics.py @@ -75,9 +75,27 @@ def summarize(path: Path, warmup: int, throughput: float | None = None) -> dict: return result +def summarize_server(path: Path, warmup: int) -> dict: + frames = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if "P65ServerMetrics " not in line: + continue + frame = dict(FIELDS.findall(line.split("P65ServerMetrics ", 1)[1])) + if frame.get("valid") == "1" and int(frame["frame"]) > warmup: + frames.append(frame) + if not frames: + raise ValueError(f"{path}: no valid server CPU frames after warmup={warmup}") + cpu = sum(int(f["apply_thread_cpu_ns"]) for f in frames) + wall = sum(int(f["wall_ns"]) for f in frames) + return {"server_log": str(path), "server_frames": len(frames), + "apply_thread_cpu_ms_per_frame": cpu / len(frames) / 1e6, + "server_fps": len(frames) * 1e9 / wall if wall else None} + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--arm", action="append", required=True, metavar="LABEL:LOG") + parser.add_argument("--server-arm", action="append", default=[], metavar="LABEL:LOG") parser.add_argument("--warmup-frames", type=int, default=0) parser.add_argument("--link-mib-per-second", type=float) parser.add_argument("--output", type=Path) @@ -93,11 +111,22 @@ def main() -> int: report[label] = summarize(Path(filename), args.warmup_frames, args.link_mib_per_second) except (OSError, ValueError, KeyError) as error: parser.error(str(error)) + for arm in args.server_arm: + label, sep, filename = arm.partition(":") + if not sep or label not in report: + parser.error("each --server-arm must name an existing client arm") + try: + report[label].update(summarize_server(Path(filename), args.warmup_frames)) + except (OSError, ValueError, KeyError) as error: + parser.error(str(error)) if "spawn" in report: baseline = report["spawn"]["client_thread_cpu_ms_per_frame"] for label, arm in report.items(): if label != "spawn": arm["client_thread_cpu_delta_vs_spawn_ms"] = arm["client_thread_cpu_ms_per_frame"] - baseline + if "apply_thread_cpu_ms_per_frame" in arm and "apply_thread_cpu_ms_per_frame" in report["spawn"]: + arm["apply_thread_cpu_delta_vs_spawn_ms"] = (arm["apply_thread_cpu_ms_per_frame"] - + report["spawn"]["apply_thread_cpu_ms_per_frame"]) text = json.dumps(report, ensure_ascii=False, indent=2) + "\n" if args.output: args.output.write_text(text, encoding="utf-8") diff --git a/scripts/ci/tcp_lane_tools_test.py b/scripts/ci/tcp_lane_tools_test.py index 625c3d3a..b84632fd 100644 --- a/scripts/ci/tcp_lane_tools_test.py +++ b/scripts/ci/tcp_lane_tools_test.py @@ -35,6 +35,15 @@ class Accounting(unittest.TestCase): role = root / 'case.client.log' role.write_text('control=tcp data=stream server=127.0.0.1:40613 pid=42\n') tally.require_tcp_proof(junit, 'DirectGLES.Tcp.', discovery) + with self.assertRaises(ValueError): + tally.require_tcp_proof(junit, 'DirectGLES.Tcp.', discovery, require_run_ahead=True) + armed = 'control=tcp data=stream server=127.0.0.1:40613 pid=42\nrun-ahead ARMED\n' + role.write_text(armed) + tally.require_tcp_proof(junit, 'DirectGLES.Tcp.', discovery, require_run_ahead=True) + for fallback in ('running lockstep', 'run-ahead DISARMED'): + role.write_text(armed + fallback) + with self.assertRaises(ValueError): + tally.require_tcp_proof(junit, 'DirectGLES.Tcp.', discovery, require_run_ahead=True) for bad in ('', 'control=tcp data=shm server=127.0.0.1:40613 pid=42', 'control=tcp data=stream server=127.0.0.1:40613 pid=0', 'spawn ARMED - the server role runs in pid 42'): @@ -53,6 +62,38 @@ class Accounting(unittest.TestCase): self.assertEqual(parity.main(), 0) +class DeviceIdleExemption(unittest.TestCase): + def test_stop_restores_only_the_setting_this_task_changed(self): + spec = importlib.util.spec_from_file_location('tcp_device_server', ROOT / 'tools/trace_replay/tcp_device_server.py') + device = importlib.util.module_from_spec(spec) + spec.loader.exec_module(device) + package = 'top.mobilegl.plugin.trace' + for initially_allowed in (False, True): + allowed = {package} if initially_allowed else set() + def shell(_adb, command): + if len(command) == 3: + return '\n'.join('user,' + name + ',10325' for name in sorted(allowed)) + change = command[3] + if change.startswith('+'): + allowed.add(change[1:]) + else: + allowed.discard(change[1:]) + return '' + with tempfile.TemporaryDirectory() as directory, patch.object(device, 'shell', side_effect=shell): + state = Path(directory) / 'original.json' + device.allow_idle([], 'phone', package, state) + # A restarted supervisor must retain the first original state. + device.allow_idle([], 'phone', package, state) + self.assertIn(package, allowed) + self.assertEqual(json.loads(state.read_text())['originally_whitelisted'], initially_allowed) + with self.assertRaises(ValueError): + device.restore_idle([], 'different-phone', package, state) + self.assertTrue(state.exists()) + device.restore_idle([], 'phone', package, state) + self.assertEqual(package in allowed, initially_allowed) + self.assertFalse(state.exists()) + + @unittest.skipUnless(sys.platform.startswith('linux'), 'supervisor fixture uses Linux /proc') class Supervisor(unittest.TestCase): def test_readiness_does_not_consume_a_connection_and_stop_owns_only_its_pid(self): @@ -82,6 +123,10 @@ class Supervisor(unittest.TestCase): unrelated = root / 'unrelated.json' unrelated.write_text(json.dumps({'pid': os.getpid(), 'start': 'not-this-process'})) fixture.stop(unrelated) + already_exited = root / 'already-exited.json' + already_exited.write_text(json.dumps({'pid': 2147483647, 'start': None})) + fixture.stop(already_exited) + self.assertFalse(already_exited.exists()) finally: fixture.stop(state) with self.assertRaises(OSError): diff --git a/scripts/ci/tcp_log_forwarding_control.py b/scripts/ci/tcp_log_forwarding_control.py index 906d5ba0..84bd8559 100644 --- a/scripts/ci/tcp_log_forwarding_control.py +++ b/scripts/ci/tcp_log_forwarding_control.py @@ -47,6 +47,8 @@ def run_arm(build, out, case, forward): env['VK_ICD_FILENAMES'] = '/usr/share/vulkan/icd.d/lvp_icd.json' peer_env = dict(env, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no', MOBILEGL_IPC_LOG_FORWARD=str(int(forward)), MOBILEGL_LOG_FILE_PATH=str(out / 'peer.log')) + for name in ('MOBILEGL_TRANSPORT', 'MOBILEGL_IPC_SERVER_PATH', 'MOBILEGL_IPC_RING_MB', 'MOBILEGL_IPC_STAGE_MB'): + peer_env.pop(name, None) with (out / 'supervisor.log').open('wb') as output: server = subprocess.Popen([str(build / 'libMobileGLServer.so'), endpoint, '--serve'], env=peer_env, stdout=output, stderr=output, start_new_session=True) @@ -79,12 +81,15 @@ def run_arm(build, out, case, forward): pass server.wait(timeout=10) passed = failed = skipped = 0 + marker_assertion_failed = False if (out / 'result.xml').exists(): for entry in ET.parse(out / 'result.xml').getroot().iter('testcase'): if entry.find('skipped') is not None or entry.get('status') == 'notrun': skipped += 1 elif entry.find('failure') is not None: failed += 1 + marker_assertion_failed = any(marker in (failure.get('message', '') + (failure.text or '')) + for failure in entry.findall('failure')) else: passed += 1 client_log = read(out / 'client.client.log') @@ -93,6 +98,7 @@ def run_arm(build, out, case, forward): 'marker_in_client': marker in client_log, 'marker_in_server': marker in read(out / 'peer.server.log'), 'marker_forwarded': marker in read(out / 'client.server.log'), + 'marker_assertion_failed': marker_assertion_failed, 'output': str(out)} @@ -132,6 +138,7 @@ def main(): and on.get('tcp_armed') and on.get('marker_in_server') and on.get('marker_forwarded') and off.get('failed') == 1 and not off.get('skipped') and off.get('tcp_armed') and off.get('marker_in_server') and not off.get('marker_forwarded') + and off.get('marker_assertion_failed') and not off.get('marker_in_client')) results[case] = arms print(case + ': ' + ('PASS' if arms['proved'] else 'NOT PROVEN'), flush=True) diff --git a/scripts/ci/tcp_server_fixture.py b/scripts/ci/tcp_server_fixture.py index 0b30a3a8..9e0d41bc 100644 --- a/scripts/ci/tcp_server_fixture.py +++ b/scripts/ci/tcp_server_fixture.py @@ -26,8 +26,12 @@ def stop(state): saved = json.loads(state.read_text()) pid = saved['pid'] # PID reuse must never let a stale fixture file kill an unrelated process. - if process_start(pid) == saved['start']: - os.killpg(pid, signal.SIGTERM) + if saved['start'] is not None and process_start(pid) == saved['start']: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + state.unlink(missing_ok=True) + return for _ in range(50): if process_start(pid) != saved['start']: break @@ -37,7 +41,7 @@ def stop(state): os.killpg(pid, signal.SIGKILL) except ProcessLookupError: pass - state.unlink() + state.unlink(missing_ok=True) def start(args): @@ -54,8 +58,10 @@ def start(args): if args.state.exists(): raise RuntimeError(f'stale fixture state; run stop first: {args.state}') env = dict(os.environ, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no', - MOBILEGL_IPC_TOKEN='', MOBILEGL_IPC_LOG_FORWARD='1') - for key in ('MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'): + MOBILEGL_IPC_TOKEN='', MOBILEGL_IPC_LOG_FORWARD='1', MOBILEGL_TEST_SERVER_COUNTERS='1', + MOBILEGL_PIPE_STATS='1', MOBILEGL_PIPE_STATS_PERIOD='1') + for key in ('MOBILEGL_TRANSPORT', 'MOBILEGL_IPC_SERVER_PATH', 'MOBILEGL_IPC_RING_MB', 'MOBILEGL_IPC_STAGE_MB', + 'MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'): env.pop(key, None) log_path = args.state.with_suffix('.log') with log_path.open('wb') as log: diff --git a/scripts/ci/tcp_supervisor_smoke.py b/scripts/ci/tcp_supervisor_smoke.py index 1b527f7a..d522d5fe 100644 --- a/scripts/ci/tcp_supervisor_smoke.py +++ b/scripts/ci/tcp_supervisor_smoke.py @@ -84,7 +84,8 @@ def supervisor(server, log, same_build=False): env = dict(os.environ, MOBILEGL_IPC_ROLE='server', MOBILEGL_IPC_DIAL='no', MOBILEGL_IPC_TOKEN='p65-smoke-token', MOBILEGL_IPC_REQUIRE_SAME_BUILD=str(int(same_build)), MOBILEGL_IPC_LOG_FORWARD='0', MOBILEGL_LOG_FILE_PATH=str(log.with_suffix('.library.log'))) - for name in ('MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'): + for name in ('MOBILEGL_TRANSPORT', 'MOBILEGL_IPC_SERVER_PATH', 'MOBILEGL_IPC_RING_MB', 'MOBILEGL_IPC_STAGE_MB', + 'MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_ENDPOINT', 'MOBILEGL_BACKEND_TYPE'): env.pop(name, None) with log.open('wb') as output: child = subprocess.Popen([server, f'tcp://127.0.0.1:{port}', '--serve'], env=env, @@ -154,16 +155,16 @@ def main(): busy = receive(second, schema) assert busy.get('code') == 7, busy evidence['busy'] = busy - token = exchange(port, schema, hello(schema, flatbuffers, token='wrong-token')) + layout = exchange(port, schema, hello(schema, flatbuffers)) + assert layout.get('code') == 2 and layout.get('expected', 0), layout + fingerprint = layout['expected'] + evidence['wrong_layout'] = layout + token = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint, token='wrong-token')) assert token.get('code') == 4, token evidence['wrong_token'] = token version = exchange(port, schema, hello(schema, flatbuffers, major=99)) assert version.get('code') == 1, version evidence['wire_major_99'] = version - layout = exchange(port, schema, hello(schema, flatbuffers)) - assert layout.get('code') == 2 and layout.get('expected', 0), layout - fingerprint = layout['expected'] - evidence['wrong_layout'] = layout accepted = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint)) assert accepted.get('welcome', 0) and accepted['welcome'] != os.getpid(), accepted assert accepted.get('fingerprint') == fingerprint, accepted diff --git a/tools/trace_replay/README.md b/tools/trace_replay/README.md index d36d0fa5..45d36ca0 100644 --- a/tools/trace_replay/README.md +++ b/tools/trace_replay/README.md @@ -403,3 +403,110 @@ the ANGLE the Android lane runs. The CI lane uses a pinned build extension set differ from the SDK copy (`GL_EXT_texture_buffer` support, ES 3.2 entry points). Compare `GL_RENDERER` and the relevant extension lists on both stacks before treating a local result as a statement about CI. + + +## TCP matrix driver (Linux / WSL) + +`run_tcp_matrix.py` uses a CTest JSON catalog plus the **current** `trace_cases.json`. +It does not build, install an APK, or launch the remote server. For the formal +P6.5 run-ahead matrix, use `--require-run-ahead`. It forces `MOBILEGL_IPC_RUN_AHEAD=1` +and `MOBILEGL_IPC_VERB_BARRIER=1`, then requires `run-ahead ARMED` in each private +client log and rejects any `running lockstep` / `DISARMED` marker. The checkpoint +records `requested_arm` and `actual_arm`; strict resume cannot reuse an earlier +lockstep image pass. Start the intended server first, then run serially: + +```bash +ctest --test-dir /path/to/retrace-build --show-only=json-v1 > /tmp/trace-catalog.json +python3 tools/trace_replay/run_tcp_matrix.py \ + --catalog /tmp/trace-catalog.json \ + --runner /path/to/mobilegl_trace_replay --library /path/to/libMobileGL.so \ + --endpoint tcp://192.168.21.181:40613 --token devtoken \ + --backend DirectGLES --credit 2 --require-run-ahead --out /tmp/tcp-matrix +# Continue the same frozen library/runner/peer: +python3 tools/trace_replay/run_tcp_matrix.py \ + --catalog /tmp/trace-catalog.json \ + --runner /path/to/mobilegl_trace_replay --library /path/to/libMobileGL.so \ + --endpoint tcp://192.168.21.181:40613 --token devtoken \ + --backend DirectGLES --credit 2 --require-run-ahead --out /tmp/tcp-matrix --resume +``` + +`--build-dir` can replace `--catalog` to obtain the JSON without building. +`--runner` overrides the catalog runner; fixture paths default to the catalog's +fixture directory and can be overridden with `--fixtures`. Repeat `--case` or +`--backend` to select a subset. The default is the manifest's CI split subset +(currently 39 cases), honoring each case's backend list. Explicit `--case +minecraft-1.21.4-rd12-odinlite-in-world` permits that non-CI workload separately; +an explicit `split:false` always remains excluded. + +The watchdog defaults to **300 seconds without log progress** and a separate +**7200-second absolute ceiling** (`--idle-seconds`, `--max-seconds`). It observes +runner output and trace/client/server log changes. It deliberately ignores the +old local CTest/manifest `timeout_seconds`: TCP main-menu kept progressing beyond +its old local 180-second budget. A growing log does not bypass the explicit absolute ceiling. +On timeout the driver terminates and reaps the entire case process group, +including a child that ignores TERM after CMake exits, and stops the matrix to +avoid cascading Busy failures. Check the peer before resuming. Ctrl-C/TERM/HUP +also clean up the group and save a cancelled checkpoint. + +Each attempt has a new directory. `checkpoint.json` is written atomically, and +only a zero exit plus current-attempt successful result, matching backend/call/ +golden, adequate SSIM, an actual image, and the requested TCP arm marker count +as passed. Resume also checks request/artifact identity and stored evidence +hashes. Failed, timed-out, interrupted, changed, or missing evidence is rerun; +old scratch `results.json` files are retained but never silently promoted into +successful checkpoints. Replacing the remote deployment requires a new output +root unless it is the same frozen peer. `results.json` summarizes the currently +selected runs and marks reused evidence with `resumed:true`. + +`--wake-adb-serial 2f7cbe2e` explicitly opts into `adb shell input keyevent +KEYCODE_WAKEUP` every 15 seconds during a case. It does not install anything or +change global power/idle settings. Its own adb log is excluded from the progress +watchdog, so wakeup messages cannot conceal a stalled trace. + +Tool regression tests use short process stubs, never a GPU/device/trace: + +```bash +python3 tools/trace_replay/test_run_tcp_matrix.py +``` + +## TCP credit and Stage measurements + +`benchmark_tcp_credits.py` runs the complete OpenRA and rd12 traces at credits +1, 2, and 3, then selects matching client/server tail frame IDs (100 and 200 +frames respectively). Extract the unchanged trace files as +`inputs/OpenRA/openra.trace` and `inputs/rd12/trace.trace`. Keep the server and +library frozen, enable server `MOBILEGL_PIPE_STATS=1` and +`MOBILEGL_PIPE_STATS_PERIOD=1`, and leave the device connection free: + +```bash +python3 tools/trace_replay/benchmark_tcp_credits.py \ + --runner /path/to/mobilegl_trace_replay --library /path/to/libMobileGL.so \ + --inputs /path/to/inputs --out /tmp/tcp-credits \ + --endpoint tcp://192.168.21.181:40613 --token devtoken --serial 2f7cbe2e +python3 tools/trace_replay/measure_tcp_stage_burst.py \ + --library /path/to/libMobileGL.so --out /tmp/tcp-stage-burst \ + --endpoint tcp://192.168.21.181:40613 --token devtoken +``` + +The credit runner serializes all six runs, sends the same explicit awake key as +the matrix driver, and preserves complete benchmark and role logs. It requires +the requested credit and continuous run-ahead ARMED proof in the client log, +matching complete tail frames, and positive per-thread CPU observations. +`--case` and `--credit` select a subset; `--resume` reuses only matching identity +and intact evidence. FPS, `kWaitReply` counts, bucketed RTT distribution, Stage +bytes, and the two role CPU clocks come from the selected steady window. +Completing this benchmark does not perform or replace a golden image comparison. + +The Stage tool uploads exactly 64 MiB after one warmup frame, consumes the dirty +buffer in a draw, and waits for apply. It requires actual TCP run-ahead and at +least 64 MiB of observed Stage bytes in frame 2, preserves artifact hashes, and +refuses to overwrite an existing output directory. Its throughput includes +client copies and backend work; it is not raw Wi-Fi bandwidth. Divide steady +frame Stage traffic by this separately measured throughput only with that +interpretation. Device matrices, benchmarks, and the burst must run one at a time. + +Offline evidence controls (no device): + +```bash +python3 tools/trace_replay/test_benchmark_tcp_credits.py +``` diff --git a/tools/trace_replay/benchmark_tcp_credits.py b/tools/trace_replay/benchmark_tcp_credits.py new file mode 100644 index 00000000..10113db2 --- /dev/null +++ b/tools/trace_replay/benchmark_tcp_credits.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Record complete TCP benchmark runs for credit 1/2/3 with exact common tail frames.""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import time +import uuid + +from measure_transport_cpu import CASES, digest +from measure_loopback_cpu import frame_rows, METRICS +from run_tcp_matrix import supervise, atomic_json, fingerprint, actual_arm_from_text + + +def validate_measurement(work, endpoint, credit, tail): + bench = json.loads((work / 'benchmark.json').read_text()) + client_path, server_path = work / 'mobilegl.client.log', work / 'mobilegl.server.log' + text = client_path.read_text(errors='replace') + actual_arm = actual_arm_from_text(text) + if (actual_arm != {'armed': True, 'lockstep': False, 'disarmed': False} + or not re.search(r'paced by a present credit of ' + str(credit) + r'\b', text) + or 'control=tcp data=stream server=' + endpoint.removeprefix('tcp://') not in text): + raise ValueError('runtime does not prove TCP and the requested armed credit') + client = frame_rows(client_path, 'P65LinkMetrics ') + server = frame_rows(server_path, 'P65ServerMetrics ', server=True) + ids = sorted(client) + if len(ids) != bench['totalFrames'] or len(ids) <= tail: + raise ValueError('benchmark and frame metrics differ, or tail contains setup') + selected = ids[-tail:] + if selected != list(range(selected[0], selected[-1] + 1)) or any(n not in server for n in selected): + raise ValueError('no matching complete client/server frame tail') + for number in selected: + if int(client[number]['client_thread_cpu_ns']) <= 0 or int(server[number]['apply_thread_cpu_ns']) <= 0: + raise ValueError('nonpositive CPU measurement in selected frame ' + str(number)) + summary = METRICS.summarize(client_path, selected[0] - 1) + summary.update(METRICS.summarize_server(server_path, selected[0] - 1)) + if summary['frames'] != tail or summary['server_frames'] != tail: + raise ValueError('unexpected frames beyond the chosen tail') + return {'run_ahead_armed': True, 'actual_arm': actual_arm, + 'first_frame': selected[0], 'last_frame': selected[-1], 'benchmark': bench, + 'benchmark_sha256': digest(work / 'benchmark.json'), + 'client_log_sha256': digest(client_path), 'server_log_sha256': digest(server_path), **summary} + + +def measure(args, case, credit, work): + _, trace_name, width, height, tail = CASES[case] + trace = args.inputs / case / trace_name + work.mkdir(parents=True) + env = dict(os.environ, MOBILEGL_TRANSPORT='spawn', MOBILEGL_IPC_CONTROL=args.endpoint, + MOBILEGL_IPC_DATA='stream', MOBILEGL_IPC_TOKEN=args.token, MOBILEGL_IPC_REQUIRE_SAME_BUILD='1', + MOBILEGL_IPC_LOG_FORWARD='1', MOBILEGL_PIPE_STATS='1', MOBILEGL_PIPE_STATS_PERIOD='1', + MOBILEGL_IPC_PRESENT_CREDIT=str(credit), MOBILEGL_IPC_RUN_AHEAD='1', MOBILEGL_IPC_VERB_BARRIER='1', + MOBILEGL_IPC_BATCH_WAITS='1', MOBILEGL_BACKEND_TYPE='DirectGLES', + MOBILEGL_IPC_STRICT_ERRORS='0', MOBILEGL_IPC_ROLE_SPLIT_STATE='0', MOBILEGL_IPC_AUDIT='0') + for name in ('MOBILEGL_IPC_DIAL', 'MOBILEGL_IPC_SERVER_PATH'): + env.pop(name, None) + command = [str(args.runner), '--trace', str(trace), '--mobilegl-library', str(args.library), + '--backend', 'DirectGLES', '--width', str(width), '--height', str(height), + '--output', str(work), '--benchmark', '--benchmark-finish=0', '--benchmark-tail-frames=' + str(tail)] + def wake(): + # Keep maintenance output outside the progress scan: a failed adb wake + # must not make a stalled replay look active. + with (work.parent / 'wake-adb.log').open('ab') as log: + try: + subprocess.run([args.adb, '-s', args.serial, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP'], + stdout=log, stderr=log, timeout=5, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + log.write((str(error) + '\n').encode()) + result = supervise(command, env, str(args.inputs), work, work / 'runner.log', + idle_seconds=300, max_seconds=7200, wake=wake) + if result['returncode'] or result['timeout_kind'] or result['remaining_processes'] or result['leftovers_after_parent_exit']: + return {**result, 'status': 'failed', 'output': str(work), 'command': command} + actual_arm = actual_arm_from_text((work / 'mobilegl.client.log').read_text(errors='replace')) + try: + values = validate_measurement(work, args.endpoint, credit, tail) + except (OSError, ValueError, KeyError) as error: + return {**result, 'status': 'failed', 'actual_arm': actual_arm, + 'error': str(error), 'output': str(work), 'command': command} + return {**result, 'status': 'passed', 'output': str(work), 'command': command, **values} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--runner', type=Path, required=True) + parser.add_argument('--library', type=Path, required=True) + parser.add_argument('--inputs', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--endpoint', required=True) + parser.add_argument('--token', default=os.environ.get('MOBILEGL_IPC_TOKEN', '')) + parser.add_argument('--serial', required=True, help='explicit adb serial to keep awake during each run') + parser.add_argument('--adb', default='adb') + parser.add_argument('--case', action='append', choices=CASES) + parser.add_argument('--credit', action='append', type=int, choices=(1, 2, 3)) + parser.add_argument('--resume', action='store_true') + args = parser.parse_args() + if not args.endpoint.startswith('tcp://'): + parser.error('a TCP endpoint is required') + args.out.mkdir(parents=True, exist_ok=True) + checkpoint = args.out / 'checkpoint.json' + rows = json.loads(checkpoint.read_text()) if checkpoint.exists() else [] + if rows and not args.resume: + parser.error('output already contains evidence; use --resume or a new output directory') + for case in args.case or CASES: + for credit in args.credit or (1, 2, 3): + provenance = {'case': case, 'credit': credit, 'endpoint': args.endpoint, + 'token_hash': fingerprint(args.token), 'runner_sha256': digest(args.runner), + 'library_sha256': digest(args.library), + 'trace_sha256': digest(args.inputs / case / CASES[case][1]), + 'script_sha256': digest(Path(__file__)), + 'helper_sha256': {name: digest(Path(__file__).with_name(name)) for name in ( + 'measure_transport_cpu.py', 'measure_loopback_cpu.py', 'run_tcp_matrix.py')}, + 'metrics_sha256': digest(Path(METRICS.__file__))} + identity = fingerprint(provenance) + prior = next((row for row in reversed(rows) if row.get('identity') == identity and row.get('status') == 'passed'), None) + if args.resume and prior: + directory = Path(prior['output']) + if all((directory / filename).is_file() and digest(directory / filename) == prior[field] + for filename, field in (('benchmark.json', 'benchmark_sha256'), ('mobilegl.client.log', 'client_log_sha256'), + ('mobilegl.server.log', 'server_log_sha256'))): + print(json.dumps({'case': case, 'credit': credit, 'status': 'passed', 'resumed': True}), flush=True) + continue + work = args.out / case / f'credit-{credit}' / ('attempt-' + uuid.uuid4().hex) + row = {'case': case, 'credit': credit, 'identity': identity, + 'provenance': provenance, 'started_at_ns': time.time_ns()} + print(json.dumps({**row, 'status': 'running'}), flush=True) + try: + row.update(measure(args, case, credit, work)) + except Exception as error: + row.update(status='failed', error=str(error), output=str(work)) + rows.append(row) + atomic_json(checkpoint, rows) + print(json.dumps({key: row.get(key) for key in ('case', 'credit', 'status', 'seconds', 'fps', 'wait_replies_per_frame', + 'rtt_mean_us', 'client_thread_cpu_ms_per_frame', 'error')}), flush=True) + if row['status'] != 'passed': + return 1 + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/trace_replay/measure_loopback_cpu.py b/tools/trace_replay/measure_loopback_cpu.py new file mode 100644 index 00000000..d1d4420c --- /dev/null +++ b/tools/trace_replay/measure_loopback_cpu.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Compare current unix+shm and loopback TCP+stream using per-frame role CPU clocks. + +Start a stats-enabled supervisor separately at --endpoint. This runner neither +contacts a device nor changes production code. It refuses partial or mismatched +client/server frame windows instead of treating missing CPU observations as zero. +""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import statistics +import subprocess + +from measure_transport_cpu import CASES, digest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('p65_metrics', ROOT / 'scripts/ci/p65_link_metrics.py') +METRICS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(METRICS) + + +def frame_rows(path, marker, server=False): + rows = {} + for line in path.read_text(encoding='utf-8', errors='replace').splitlines(): + if marker not in line: + continue + row = dict(METRICS.FIELDS.findall(line.split(marker, 1)[1])) + if server: + if row.get('valid') != '1': + continue + elif row.get('kind') != 'frame': + continue + number = int(row['frame']) + if number in rows: + raise ValueError(f'{path}: duplicate frame {number}') + rows[number] = row + return rows + + +def run(args, case, transport, repeat): + _, trace_name, width, height, tail = CASES[case] + trace = args.inputs / case / trace_name + if not trace.is_file(): + raise ValueError(f'{trace}: extract the unchanged fixture before measuring') + out = args.out / case / transport / f'run-{repeat}' + if out.exists(): + raise ValueError(f'{out}: choose a new output directory; evidence is never overwritten') + out.mkdir(parents=True) + env = dict(os.environ, MOBILEGL_TRANSPORT='spawn', MOBILEGL_BACKEND_TYPE='DirectGLES', + MOBILEGL_IPC_SERVER_PATH=str(args.server), MOBILEGL_PIPE_STATS='1', MOBILEGL_PIPE_STATS_PERIOD='1', + MOBILEGL_IPC_PRESENT_CREDIT='1', MOBILEGL_IPC_RUN_AHEAD='1', MOBILEGL_IPC_VERB_BARRIER='1', + MOBILEGL_IPC_BATCH_WAITS='1', MOBILEGL_IPC_SPIN_US='50', MOBILEGL_IPC_STRICT_ERRORS='0', + MOBILEGL_IPC_ROLE_SPLIT_STATE='0', MOBILEGL_IPC_AUDIT='0', + LIBGL_ALWAYS_SOFTWARE='1', EGL_PLATFORM='surfaceless', + MESA_GL_VERSION_OVERRIDE='3.3', MESA_GLSL_VERSION_OVERRIDE='330') + for key in ('MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_DATA', 'MOBILEGL_IPC_TOKEN', 'MOBILEGL_IPC_DIAL', + 'MOBILEGL_IPC_REQUIRE_SAME_BUILD', 'MOBILEGL_TEST_SERVER_COUNTERS'): + env.pop(key, None) + if transport == 'tcp': + env.update(MOBILEGL_IPC_CONTROL=args.endpoint, MOBILEGL_IPC_DATA='stream', MOBILEGL_IPC_TOKEN='', + MOBILEGL_IPC_REQUIRE_SAME_BUILD='1', MOBILEGL_IPC_LOG_FORWARD='1') + if Path('/usr/share/glvnd/egl_vendor.d/50_mesa.json').exists(): + env['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/50_mesa.json' + command = [str(args.runner), '--trace', str(trace), '--mobilegl-library', str(args.library), + '--backend', 'DirectGLES', '--width', str(width), '--height', str(height), '--output', str(out), + '--benchmark', '--benchmark-finish=0', '--benchmark-tail-frames=' + str(tail)] + with (out / 'runner.log').open('w') as log: + result = subprocess.run(command, env=env, stdout=log, stderr=log, timeout=900) + if result.returncode: + raise RuntimeError(f'{out}: benchmark exited {result.returncode}') + bench = json.loads((out / 'benchmark.json').read_text()) + client_path, server_path = out / 'mobilegl.client.log', out / 'mobilegl.server.log' + client = frame_rows(client_path, 'P65LinkMetrics ') + server = frame_rows(server_path, 'P65ServerMetrics ', server=True) + ids = sorted(client) + if len(ids) != bench['totalFrames'] or len(ids) <= tail: + raise ValueError(f'{out}: incomplete frame metrics or tail includes setup') + selected = ids[-tail:] + if selected != list(range(selected[0], selected[-1] + 1)) or any(frame not in server for frame in selected): + raise ValueError(f'{out}: client/server tail frame IDs disagree') + first, last = selected[0], selected[-1] + # The existing reducer now sees precisely the proven common tail range. + values = METRICS.summarize(client_path, first - 1) + values.update(METRICS.summarize_server(server_path, first - 1)) + if values['frames'] != tail or values['server_frames'] != tail: + raise ValueError(f'{out}: unexpected frames outside the selected tail') + for frame in selected: + if int(client[frame]['client_thread_cpu_ns']) <= 0 or int(server[frame]['apply_thread_cpu_ns']) <= 0: + raise ValueError(f'{out}: nonpositive per-thread CPU in frame {frame}') + proof = client_path.read_text(errors='replace') + expected = 'control=tcp data=stream' if transport == 'tcp' else 'spawn ARMED - the server role runs in pid ' + if expected not in proof: + raise ValueError(f'{out}: no proof of the requested transport') + if ('run-ahead ARMED' not in proof or 'running lockstep' in proof or 'run-ahead DISARMED' in proof): + raise ValueError(f'{out}: requested run-ahead was not continuously armed; not a pure transport CPU comparison') + row = {'case': case, 'transport': transport, 'repeat': repeat, + 'first_frame': first, 'last_frame': last, 'tail_frames': tail, + 'run_ahead_armed': True, + 'benchmark_client_cpu_ms': statistics.mean(bench['frameCpuTimesMs'][-tail:]), + 'runner_sha256': args.runner_hash, 'library_sha256': args.library_hash, + 'server_sha256': args.server_hash, 'trace_sha256': digest(trace), + 'command': command, 'output': str(out), **values} + (out / 'role-cpu.json').write_text(json.dumps(row, indent=2), encoding='utf-8') + return row + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--runner', type=Path, required=True) + parser.add_argument('--library', type=Path, required=True) + parser.add_argument('--server', type=Path, required=True) + parser.add_argument('--inputs', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--endpoint', default='tcp://127.0.0.1:40616') + parser.add_argument('--transport', action='append', choices=('spawn', 'tcp'), + help='Measure only the selected arm; default measures both') + parser.add_argument('--case', action='append', choices=CASES, + help='Measure only selected cases; default measures OpenRA and rd12') + args = parser.parse_args() + if not args.endpoint.startswith('tcp://127.0.0.1:'): + parser.error('this measurement is specifically loopback, not a device benchmark') + args.runner_hash, args.library_hash, args.server_hash = map(digest, (args.runner, args.library, args.server)) + rows = [] + for case, repeats in (('OpenRA', 3), ('rd12', 1)): + if args.case and case not in args.case: + continue + for repeat in range(repeats): + for transport in args.transport or ('spawn', 'tcp'): + row = run(args, case, transport, repeat) + rows.append(row) + (args.out / 'summary.json').write_text(json.dumps(rows, indent=2), encoding='utf-8') + print(json.dumps({key: row[key] for key in ('case', 'transport', 'repeat', 'first_frame', 'last_frame', + 'client_thread_cpu_ms_per_frame', 'apply_thread_cpu_ms_per_frame')}), flush=True) + + +if __name__ == '__main__': + main() diff --git a/tools/trace_replay/measure_tcp_stage_burst.py b/tools/trace_replay/measure_tcp_stage_burst.py new file mode 100644 index 00000000..358a9fd2 --- /dev/null +++ b/tools/trace_replay/measure_tcp_stage_burst.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Measure one real 64 MiB buffer upload consumed by a TCP server draw. + +The timed interval includes client staging, stream delivery, backend work, and +an applied fence. Its result is end-to-end staged throughput, not raw network +capacity. Start the same-build stats-enabled server before running this tool. +""" +import argparse +import ctypes as c +import json +import os +from pathlib import Path +import time + +from measure_transport_cpu import digest +from measure_loopback_cpu import frame_rows +from run_tcp_matrix import actual_arm_from_text + + +UPLOAD_BYTES = 64 * 1024 * 1024 + + +def run(args): + args.out.mkdir(parents=True) + os.environ.update( + MOBILEGL_TRANSPORT='spawn', MOBILEGL_IPC_CONTROL=args.endpoint, + MOBILEGL_IPC_DATA='stream', MOBILEGL_IPC_TOKEN=args.token, + MOBILEGL_IPC_REQUIRE_SAME_BUILD='1', MOBILEGL_IPC_LOG_FORWARD='1', + MOBILEGL_BACKEND_TYPE='DirectGLES', MOBILEGL_PIPE_STATS='1', + MOBILEGL_PIPE_STATS_PERIOD='1', MOBILEGL_IPC_RUN_AHEAD='1', + MOBILEGL_IPC_VERB_BARRIER='1', MOBILEGL_IPC_PRESENT_CREDIT='1', + MOBILEGL_LOG_FILE_PATH=str(args.out / 'mobilegl.log'), EGL_PLATFORM='surfaceless') + for name in ('MOBILEGL_IPC_DIAL', 'MOBILEGL_IPC_SERVER_PATH'): + os.environ.pop(name, None) + lib = c.CDLL(str(args.library), mode=c.RTLD_GLOBAL) + P, I, U, F = c.c_void_p, c.c_int, c.c_uint, c.c_float + + def fn(name, result, parameters): + function = getattr(lib, name) + function.restype, function.argtypes = result, parameters + return function + + get_display = fn('eglGetDisplay', P, [P]) + initialize = fn('eglInitialize', U, [P, c.POINTER(I), c.POINTER(I)]) + bind_api = fn('eglBindAPI', U, [U]) + choose = fn('eglChooseConfig', U, [P, c.POINTER(I), c.POINTER(P), I, c.POINTER(I)]) + context = fn('eglCreateContext', P, [P, P, P, c.POINTER(I)]) + surface = fn('eglCreatePbufferSurface', P, [P, P, c.POINTER(I)]) + current = fn('eglMakeCurrent', U, [P, P, P, P]) + swap = fn('eglSwapBuffers', U, [P, P]) + terminate = fn('eglTerminate', U, [P]) + display = get_display(None) + major, minor = I(), I() + if not display or not initialize(display, c.byref(major), c.byref(minor)): + raise RuntimeError('eglInitialize failed') + try: + if not bind_api(0x30A2): # EGL_OPENGL_API + raise RuntimeError('eglBindAPI failed') + attributes = (I * 15)(0x3033, 1, 0x3024, 8, 0x3023, 8, 0x3022, 8, + 0x3021, 8, 0x3025, 24, 0x3040, 8, 0x3038) + config, count = P(), I() + if not choose(display, attributes, c.byref(config), 1, c.byref(count)) or not count.value: + raise RuntimeError('eglChooseConfig failed') + ctx = context(display, config, None, (I * 5)(0x3098, 3, 0x30FB, 3, 0x3038)) + surf = surface(display, config, (I * 5)(0x3057, 4, 0x3056, 4, 0x3038)) + if not ctx or not surf or not current(display, surf, surf, ctx): + raise RuntimeError('context/surface setup failed') + + create_shader = fn('glCreateShader', U, [U]) + shader_source = fn('glShaderSource', None, [U, I, c.POINTER(c.c_char_p), c.POINTER(I)]) + compile_shader = fn('glCompileShader', None, [U]) + get_shader = fn('glGetShaderiv', None, [U, U, c.POINTER(I)]) + create_program = fn('glCreateProgram', U, []) + attach = fn('glAttachShader', None, [U, U]) + link = fn('glLinkProgram', None, [U]) + get_program = fn('glGetProgramiv', None, [U, U, c.POINTER(I)]) + use = fn('glUseProgram', None, [U]) + error = fn('glGetError', U, []) + program = create_program() + for kind, source in ( + (0x8B31, b'#version 330 core\nlayout(location=0) in vec2 p;void main(){gl_Position=vec4(p,0,1);}'), + (0x8B30, b'#version 330 core\nout vec4 c;void main(){c=vec4(1);}'), + ): + shader, data, ok = create_shader(kind), c.c_char_p(source), I() + shader_source(shader, 1, c.byref(data), None) + compile_shader(shader) + get_shader(shader, 0x8B81, c.byref(ok)) + if not ok.value: + raise RuntimeError('shader compilation failed') + attach(program, shader) + link(program) + ok = I() + get_program(program, 0x8B82, c.byref(ok)) + if not ok.value: + raise RuntimeError('program link failed') + use(program) + + gen_vao = fn('glGenVertexArrays', None, [I, c.POINTER(U)]) + bind_vao = fn('glBindVertexArray', None, [U]) + gen_buffer = fn('glGenBuffers', None, [I, c.POINTER(U)]) + bind_buffer = fn('glBindBuffer', None, [U, U]) + buffer_data = fn('glBufferData', None, [U, c.c_ssize_t, P, U]) + subdata = fn('glBufferSubData', None, [U, c.c_ssize_t, c.c_ssize_t, P]) + enable = fn('glEnableVertexAttribArray', None, [U]) + pointer = fn('glVertexAttribPointer', None, [U, I, U, c.c_ubyte, I, P]) + draw = fn('glDrawArrays', None, [U, I, I]) + finish = fn('glFinish', None, []) + vao, buffer = U(), U() + gen_vao(1, c.byref(vao)) + bind_vao(vao) + gen_buffer(1, c.byref(buffer)) + bind_buffer(0x8892, buffer) + buffer_data(0x8892, UPLOAD_BYTES, None, 0x88E8) + vertices = (F * 6)(-1, -1, 1, -1, 0, 1) + subdata(0x8892, 0, c.sizeof(vertices), vertices) + enable(0) + pointer(0, 2, 0x1406, 0, 0, None) + draw(4, 0, 3) + finish() + if not swap(display, surf) or error() != 0: + raise RuntimeError('warmup draw failed') + + payload = c.create_string_buffer(b'\x3f' * UPLOAD_BYTES) + start = time.monotonic_ns() + subdata(0x8892, 0, UPLOAD_BYTES, payload) + draw(4, 0, 3) + finish() + elapsed = (time.monotonic_ns() - start) / 1e9 + if error() != 0 or not swap(display, surf): + raise RuntimeError('64 MiB consuming draw failed') + fn('MGPipeSyncPeerLog', None, [])() + finally: + current(display, None, None, None) + terminate(display) + + client_path = args.out / 'mobilegl.client.log' + proof = client_path.read_text(errors='replace') + actual_arm = actual_arm_from_text(proof) + if (actual_arm != {'armed': True, 'lockstep': False, 'disarmed': False} + or 'control=tcp data=stream server=' + args.endpoint.removeprefix('tcp://') not in proof): + raise ValueError('no continuously armed TCP runtime proof') + frames = frame_rows(client_path, 'P65LinkMetrics ') + if set(frames) != {1, 2} or int(frames[2]['stage_bytes']) < UPLOAD_BYTES: + raise ValueError('frame 2 does not prove that at least 64 MiB crossed Stage') + return {'application_upload_bytes': UPLOAD_BYTES, 'seconds': elapsed, + 'end_to_end_staged_mib_per_second': 64 / elapsed, + 'frame_2_stage_bytes': int(frames[2]['stage_bytes']), + 'actual_arm': actual_arm, 'endpoint': args.endpoint, + 'library_sha256': digest(args.library), 'script_sha256': digest(Path(__file__)), + 'client_log_sha256': digest(client_path), + 'note': 'glBufferSubData plus consuming draw and applied fence; includes client copy and backend work, not raw network capacity'} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--library', required=True, type=Path) + parser.add_argument('--endpoint', required=True) + parser.add_argument('--token', default=os.environ.get('MOBILEGL_IPC_TOKEN', '')) + parser.add_argument('--out', required=True, type=Path) + args = parser.parse_args() + if not args.endpoint.startswith('tcp://'): + parser.error('a TCP endpoint is required') + if args.out.exists(): + parser.error('output already exists; choose a fresh evidence directory') + result = run(args) + (args.out / 'result.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') + print(json.dumps(result), flush=True) + + +if __name__ == '__main__': + main() diff --git a/tools/trace_replay/measure_transport_cpu.py b/tools/trace_replay/measure_transport_cpu.py new file mode 100644 index 00000000..b069f6f2 --- /dev/null +++ b/tools/trace_replay/measure_transport_cpu.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Record client thread CPU and sampled apply-thread CPU for unchanged trace workloads. + +The client clock comes from the existing benchmark runner. Apply CPU comes from +/proc//task//schedstat, never process-wide CPU. The sampled tail window +is approximately anchored to benchmark.json's mtime; raw samples and this +alignment limitation are retained so the result cannot claim cycle precision. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import statistics +import subprocess +import tarfile +import time + + +CASES = { + 'OpenRA': ('openra.tgz', 'openra.trace', 640, 480, 100), + 'rd12': ('minecraft-1.21.4-rd12-odinlite-in-world.tgz', 'trace.trace', 854, 480, 200), +} + + +def digest(path): + value = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + value.update(chunk) + return value.hexdigest() + + +def thread_samples(pid): + pids = [pid] + try: + pids += [int(value) for value in Path(f'/proc/{pid}/task/{pid}/children').read_text().split()] + except (FileNotFoundError, ProcessLookupError): + pass + rows = [] + for process in pids: + try: + tasks = sorted(Path(f'/proc/{process}/task').iterdir(), key=lambda path: int(path.name)) + except (FileNotFoundError, ProcessLookupError): + continue + for task in tasks: + try: + name = (task / 'comm').read_text().strip() + if name != 'mgl-srv-apply': + continue + cpu_ns = int((task / 'schedstat').read_text().split()[0]) + rows.append({'time_ns': time.monotonic_ns(), 'pid': process, 'tid': int(task.name), + 'name': name, 'cpu_ns': cpu_ns}) + # Driver helper threads inherit their creator's comm. The + # ServerLoop thread exists before it calls the driver, so its + # live TID precedes those helpers; do not sum them as apply CPU. + break + except (FileNotFoundError, ProcessLookupError): + continue + return rows + + +def at_time(samples, timestamp): + for left, right in zip(samples, samples[1:]): + if left['time_ns'] <= timestamp <= right['time_ns']: + width = right['time_ns'] - left['time_ns'] + part = (timestamp - left['time_ns']) / width if width else 0 + return left['cpu_ns'] + part * (right['cpu_ns'] - left['cpu_ns']) + return None + + +def reduce_apply(samples, start, end, frames): + grouped = {} + for row in samples: + grouped.setdefault((row['pid'], row['tid']), []).append(row) + contributions = [] + for (pid, tid), rows in grouped.items(): + first = at_time(rows, start) + last = at_time(rows, end) + if first is None or last is None: + continue + inside = [row for row in rows if start <= row['time_ns'] <= end] + gaps = [(b['time_ns'] - a['time_ns']) / 1e6 for a, b in zip(inside, inside[1:])] + contributions.append({'pid': pid, 'tid': tid, 'cpu_ms': (last - first) / 1e6, + 'sample_count': len(inside), 'max_sample_gap_ms': max(gaps, default=None)}) + # One owning apply thread must cover the whole selected window. Refuse to + # sum unrelated driver threads that inherited an indistinguishable name. + if len(contributions) != 1: + return {'valid': False, 'reason': 'no unique apply thread spans the complete tail window', + 'candidates': contributions} + return {'valid': True, **contributions[0], 'mean_cpu_ms_per_frame': contributions[0]['cpu_ms'] / frames} + + +def run(args, transport, case, repeat): + archive, trace_name, width, height, tail = CASES[case] + trace_dir = args.inputs / case + trace_dir.mkdir(parents=True, exist_ok=True) + trace = trace_dir / trace_name + if not trace.exists(): + archive_path = args.fixtures / archive + if archive_path.stat().st_size < 1024: + raise ValueError(f'{archive_path}: fixture is an LFS pointer, not a trace archive') + with tarfile.open(archive_path, 'r:gz') as bundle: + bundle.extractall(trace_dir, filter='data') + out = args.out / transport / case / f'run-{repeat}' + out.mkdir(parents=True, exist_ok=True) + if (out / 'benchmark.json').exists(): + raise ValueError(f'{out}: evidence already exists; choose a new output directory') + trace_digest = digest(trace) + env = dict(os.environ, MOBILEGL_TRANSPORT=transport, MOBILEGL_IPC_SERVER_PATH=str(args.server), + MOBILEGL_PIPE_STATS='0', MOBILEGL_IPC_PRESENT_CREDIT='1', MOBILEGL_IPC_RUN_AHEAD='1', + MOBILEGL_IPC_STRICT_ERRORS='0', MOBILEGL_IPC_ROLE_SPLIT_STATE='0', MOBILEGL_IPC_AUDIT='0', + MOBILEGL_IPC_VERB_BARRIER='1', MOBILEGL_IPC_BATCH_WAITS='1', MOBILEGL_IPC_SPIN_US='50', + LIBGL_ALWAYS_SOFTWARE='1', EGL_PLATFORM='surfaceless', + MESA_GL_VERSION_OVERRIDE='3.3', MESA_GLSL_VERSION_OVERRIDE='330') + for key in ('MOBILEGL_IPC_CONTROL', 'MOBILEGL_IPC_DATA', 'MOBILEGL_IPC_TOKEN', 'MOBILEGL_IPC_DIAL', + 'MOBILEGL_IPC_REQUIRE_SAME_BUILD', 'MOBILEGL_TEST_SERVER_COUNTERS'): + env.pop(key, None) + if Path('/usr/share/glvnd/egl_vendor.d/50_mesa.json').exists(): + env['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/50_mesa.json' + command = [str(args.runner), '--trace', str(trace), '--mobilegl-library', str(args.library), + '--backend', 'DirectGLES', '--width', str(width), '--height', str(height), + '--output', str(out), '--benchmark', '--benchmark-finish=0', + '--benchmark-tail-frames=' + str(tail), '--hold-ms', '150'] + raw = [] + offset_before = time.monotonic_ns() - time.time_ns() + started = time.monotonic() + with (out / 'runner.log').open('w') as log: + process = subprocess.Popen(command, env=env, stdout=log, stderr=log) + try: + while True: + raw.extend(thread_samples(process.pid)) + if process.poll() is not None: + break + if time.monotonic() - started > args.timeout: + process.kill() + raise TimeoutError('benchmark exceeded its run budget') + time.sleep(args.sample_ms / 1000) + finally: + process.wait() + offset_after = time.monotonic_ns() - time.time_ns() + (out / 'apply-samples.json').write_text(json.dumps(raw)) + if process.returncode: + raise RuntimeError(f'{out}: replay failed with {process.returncode}') + report_path = out / 'benchmark.json' + report = json.loads(report_path.read_text()) + wall, cpu = report['frameTimesMs'], report['frameCpuTimesMs'] + if len(wall) != len(cpu) or len(wall) <= tail: + raise RuntimeError('no complete CPU series or no startup-excluding tail window') + # End() precedes JSON serialization. Subtract the measured unframed suffix + # after the last frame; do not accidentally include teardown in the tail. + report_end = report_path.stat().st_mtime_ns + (offset_before + offset_after) // 2 + unframed_ns = int(report['totalSeconds'] * 1e9 - sum(wall) * 1e6) + tail_end = report_end - unframed_ns + tail_start = tail_end - int(sum(wall[-tail:]) * 1e6) + apply = reduce_apply(raw, tail_start, tail_end, tail) + result = {'label': args.label, 'transport': transport, 'case': case, 'repeat': repeat, + 'total_frames': len(wall), 'tail_frames': tail, 'tail_wall_ms': sum(wall[-tail:]), + 'client_mean_cpu_ms': statistics.mean(cpu[-tail:]), + 'client_median_cpu_ms': statistics.median(cpu[-tail:]), + 'apply': apply, 'sample_period_ms': args.sample_ms, + 'frame_window_start_ns': tail_start, 'frame_window_end_ns': tail_end, + 'clock_offset_drift_ns': offset_after - offset_before, + 'apply_clock': '/proc//task//schedstat execution runtime (nanoseconds)', + 'apply_thread_selection': 'oldest live TID named mgl-srv-apply; excludes driver helpers inheriting its name', + 'alignment_limit': 'mtime anchor includes unmeasured benchmark summary/JSON serialization latency; ' + 'apply endpoints are interpolated between external samples, not exact frame markers', + 'runner_sha256': args.runner_digest, 'library_sha256': args.library_digest, + 'server_sha256': args.server_digest, 'trace_sha256': trace_digest, + 'runtime_environment': {key: value for key, value in env.items() + if key.startswith(('MOBILEGL_', 'MESA_', 'LIBGL_', '__EGL_'))}, + 'command': command, 'output': str(out)} + (out / 'cpu-summary.json').write_text(json.dumps(result, indent=2)) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--runner', type=Path, required=True) + parser.add_argument('--library', type=Path, required=True) + parser.add_argument('--server', type=Path, required=True) + parser.add_argument('--fixtures', type=Path, required=True) + parser.add_argument('--inputs', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--label', required=True) + parser.add_argument('--transport', action='append', choices=('inproc', 'spawn')) + parser.add_argument('--case', action='append', choices=CASES) + parser.add_argument('--repeats', type=int, default=3) + parser.add_argument('--case-repeats', action='append', default=[], metavar='CASE=N', + help='Override repetitions for one case, e.g. rd12=2') + parser.add_argument('--sample-ms', type=float, default=5) + parser.add_argument('--timeout', type=float, default=900) + args = parser.parse_args() + if args.repeats < 1 or args.sample_ms <= 0: + parser.error('repeats and sample-ms must be positive') + repeats = {} + for override in args.case_repeats: + name, separator, count = override.partition('=') + if not separator or name not in CASES or not count.isdigit() or int(count) < 1: + parser.error('--case-repeats requires a known CASE and a positive count') + repeats[name] = int(count) + args.runner_digest, args.library_digest, args.server_digest = map(digest, (args.runner, args.library, args.server)) + results = [] + for case in args.case or list(CASES): + for repeat in range(repeats.get(case, args.repeats)): + for transport in args.transport or ('inproc', 'spawn'): + result = run(args, transport, case, repeat) + results.append(result) + (args.out / 'summary.json').write_text(json.dumps(results, indent=2)) + print(json.dumps({key: result[key] for key in ('case', 'transport', 'repeat', 'client_mean_cpu_ms', 'apply')}), flush=True) + return 0 if all(row['apply']['valid'] for row in results) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/trace_replay/paired_tcp_benchmark.py b/tools/trace_replay/paired_tcp_benchmark.py new file mode 100644 index 00000000..77b7e075 --- /dev/null +++ b/tools/trace_replay/paired_tcp_benchmark.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Reboot-clean, pinned Redmi TCP credit measurements with original-state restore. + +This explicitly mutating session is separate from the ordinary benchmark tool. +Run only while this device has no other client. The service/idle exemption is +left available to its owner; CPU/GPU/fan settings are always restored here. +""" +import argparse +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import sys +import time + +import benchmark_tcp_credits as benchmark +from measure_transport_cpu import CASES, digest +from run_tcp_matrix import atomic_json, supervise + + +CPU = '/sys/devices/system/cpu/cpufreq' +GPU = '/sys/class/kgsl/kgsl-3d0' +FAN = '/sys/class/xm_power/hw_monitor/pwm_fan' +POLICIES = ('policy0', 'policy6') +RESTORE_NODES = [f'{CPU}/{policy}/{field}' for policy in POLICIES + for field in ('scaling_min_freq', 'scaling_max_freq', 'scaling_governor')] +RESTORE_NODES += [f'{GPU}/min_pwrlevel', f'{GPU}/max_pwrlevel', f'{FAN}/target_level'] +READ_NODES = RESTORE_NODES + [f'{CPU}/{policy}/scaling_cur_freq' for policy in POLICIES] +READ_NODES += [f'{GPU}/gpuclk', f'{FAN}/real_speed', f'{FAN}/pwm_duty'] +ORDERS = ((1, 2, 3), (2, 3, 1), (3, 1, 2)) + + +class Device: + def __init__(self, serial): + self.adb = ['adb', '-s', serial] + + def shell(self, argv, timeout=30): + return subprocess.run([*self.adb, 'shell', shlex.join(argv)], check=True, + capture_output=True, text=True, timeout=timeout).stdout.strip() + + def su(self, script): + return self.shell(['su', '-c', script]) + + def boot_id(self): + return self.shell(['cat', '/proc/sys/kernel/random/boot_id']) + + def observe(self): + statements = [f'printf "%s=%s\\n" {shlex.quote(node)} "$(cat {shlex.quote(node)})"' + for node in READ_NODES] + statements.append('for node in /sys/class/thermal/thermal_zone*; do ' + 'if [ "$(cat "$node/type")" = cpuss-0-0 ]; then ' + 'printf "thermal_path=%s\\nthermal_mc=%s\\n" "$node" "$(cat "$node/temp")"; ' + 'break; fi; done') + values = dict(line.split('=', 1) for line in self.su('; '.join(statements)).splitlines() if '=' in line) + if any(not values.get(node) for node in READ_NODES) or not values.get('thermal_mc'): + raise RuntimeError('incomplete device state; cannot establish or restore the thermal window') + for node, value in values.items(): + if node.endswith('scaling_governor'): + if not re.fullmatch(r'[A-Za-z0-9_-]+', value): + raise ValueError('invalid governor observation') + elif node != 'thermal_path': + int(value) + return {'time_ns': time.time_ns(), **values} + + def restore(self, original): + statements = [] + for policy in POLICIES: + base = f'{CPU}/{policy}' + statements += [f'cat {base}/cpuinfo_min_freq > {base}/scaling_min_freq', + f'echo {int(original[base + "/scaling_max_freq"])} > {base}/scaling_max_freq', + f'echo {int(original[base + "/scaling_min_freq"])} > {base}/scaling_min_freq', + f'echo {shlex.quote(original[base + "/scaling_governor"])} > {base}/scaling_governor'] + statements += [f'echo {int(original[node])} > {node}' for node in RESTORE_NODES if node.startswith((GPU, FAN))] + self.su('; '.join(statements)) + after = self.observe() + differences = {node: {'expected': original[node], 'actual': after[node]} + for node in RESTORE_NODES if original[node] != after[node]} + return {'after': after, 'differences': differences, 'restored': not differences} + + +def wait_for_boot(device, old_boot_id): + subprocess.run([*device.adb, 'reboot'], check=True, timeout=30) + deadline = time.monotonic() + 300 + while time.monotonic() < deadline: + try: + if device.shell(['getprop', 'sys.boot_completed']) == '1' and device.boot_id() != old_boot_id: + time.sleep(20) + return device.boot_id() + except (OSError, subprocess.SubprocessError): + pass + time.sleep(5) + raise RuntimeError('reboot did not establish a changed, fully booted device') + + +def pin(args, action, output): + result = subprocess.run(['bash', str(args.pin_copy), args.serial, action], + capture_output=True, text=True, timeout=30) + output.write_text(result.stdout + result.stderr, encoding='utf-8') + return result.returncode + + +def prepare_arm(args, device, directory): + directory.mkdir(parents=True) + if pin(args, 'pin', directory / 'pin-request.txt') != 0: + raise RuntimeError('pin did not take') + readings, consecutive = [], 0 + deadline = time.monotonic() + args.cool_timeout + while time.monotonic() < deadline: + observation = device.observe() + readings.append(observation) + atomic_json(directory / 'cooldown.json', readings) + if int(observation['thermal_mc']) <= args.cool_max_mc: + consecutive += 1 + if consecutive == 2: + break + else: + consecutive = 0 + time.sleep(5) + if consecutive < 2: + raise RuntimeError('cooldown expired; no valid measurement window') + if pin(args, 'check', directory / 'pin-before.txt') != 0: + raise RuntimeError('frequency drift before measurement') + if int(readings[-1][FAN + '/target_level']) != 2 or int(readings[-1][FAN + '/real_speed']) <= 0: + raise RuntimeError('fan setting was not actually established') + return readings[-1] + + +def start_server(args, device): + script = Path(__file__).with_name('tcp_device_server.py') + command = [sys.executable, str(script), 'start', '--serial', args.serial, + '--package', args.package, '--listen', args.listen, '--token', args.token, + '--allow-idle', '--state-file', str(args.server_idle_state), '--env', + 'MOBILEGL_TEST_SERVER_COUNTERS=1;MOBILEGL_PIPE_STATS=1;MOBILEGL_PIPE_STATS_PERIOD=1;' + 'MOBILEGL_LOG_FILE_PATH=/data/data/' + args.package + '/files/mgl.log'] + result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=30) + (args.out / 'server-start.txt').write_text(result.stdout + result.stderr, encoding='utf-8') + port = args.endpoint.rsplit(':', 1)[-1] + for _ in range(20): + sockets = device.shell(['ss', '-ltn']) + if re.search(r':' + re.escape(port) + r'\s', sockets): + return + time.sleep(1) + raise RuntimeError('restarted supervisor is not listening') + + +def run_session(args): + device = Device(args.serial) + original = device.observe() + session = {'status': 'prepared', 'serial': args.serial, 'endpoint': args.endpoint, + 'library_sha256': digest(args.library), 'runner_sha256': digest(args.runner), + 'pin_script_sha256': digest(args.pin_script), 'original_state': original, + 'boot_id_before': device.boot_id(), 'credit_orders': ORDERS, + 'cool_max_mc': args.cool_max_mc, 'arms': [], 'started_at_ns': time.time_ns()} + state_path = args.out / 'session.json' + atomic_json(state_path, session) # Durable before the first mutation, including reboot. + args.pin_copy = args.out / 'pin_device.sh' + args.pin_copy.write_text(args.pin_script.read_text(), encoding='utf-8', newline='\n') + try: + session['boot_id'] = wait_for_boot(device, session['boot_id_before']) + session['rebooted_clean'] = True + device.su(f'echo 2 > {FAN}/target_level') + time.sleep(3) + host = args.endpoint.removeprefix('tcp://').rsplit(':', 1)[0] + wlan = device.shell(['ip', '-f', 'inet', 'addr', 'show', 'wlan0']) + session['wlan_after_reboot'] = wlan + if host not in wlan: + raise RuntimeError('device address changed; resolve host routing before a new performance session') + for _ in range(60): + names = device.shell(['ps', '-A', '-o', 'NAME']) + if not any('dex2oat' in name for name in names.splitlines()): + break + time.sleep(5) + else: + raise RuntimeError('dex2oat did not become idle') + start_server(args, device) + session['status'] = 'running' + atomic_json(state_path, session) + index = 0 + for case in args.case or CASES: + for repeat, order in enumerate(ORDERS, 1): + for credit in order: + index += 1 + directory = args.out / f'{index:02d}-{case}-credit{credit}-r{repeat}' + row = {'index': index, 'case': case, 'credit': credit, 'repeat': repeat, 'status': 'preparing'} + session['arms'].append(row) + atomic_json(state_path, session) + print(json.dumps(row), flush=True) + row['before'] = prepare_arm(args, device, directory) + try: + row.update(benchmark.measure(args, case, credit, directory / 'measurement')) + finally: + row['pin_after_rc'] = pin(args, 'check', directory / 'pin-after.txt') + row['after'] = device.observe() + atomic_json(state_path, session) + if row['status'] != 'passed' or row['pin_after_rc'] != 0: + row['status'] = 'invalid' + raise RuntimeError(f'arm {index} is invalid; preserve it and stop') + print(json.dumps({key: row.get(key) for key in ('index', 'case', 'credit', 'status', 'fps')}), flush=True) + directory = args.out / 'stage-burst' + burst = {'before': prepare_arm(args, device, directory)} + session['stage_burst'] = burst + script = Path(__file__).with_name('measure_tcp_stage_burst.py') + command = [sys.executable, str(script), '--library', str(args.library), '--endpoint', args.endpoint, + '--token', args.token, '--out', str(directory / 'measurement')] + def wake(): + device.shell(['input', 'keyevent', 'KEYCODE_WAKEUP']) + try: + burst.update(supervise(command, dict(os.environ), str(args.inputs), directory, + directory / 'runner.log', wake=wake)) + if burst['returncode'] != 0 or burst['remaining_processes'] or burst['leftovers_after_parent_exit']: + raise RuntimeError('Stage burst failed') + burst['measurement'] = json.loads((directory / 'measurement/result.json').read_text()) + finally: + burst['pin_after_rc'] = pin(args, 'check', directory / 'pin-after.txt') + burst['after'] = device.observe() + if burst['pin_after_rc'] != 0: + raise RuntimeError('Stage burst pin drifted') + session['status'] = 'passed' + except BaseException as error: + session.update(status='failed', error=repr(error)) + raise + finally: + try: + session['restore'] = device.restore(original) + if not session['restore']['restored']: + session['status'] = 'restore-failed' + except BaseException as error: + session.update(status='restore-failed', restore_error=repr(error)) + session['completed_at_ns'] = time.time_ns() + atomic_json(state_path, session) + return 0 if session['status'] == 'passed' else 1 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ('runner', 'library', 'inputs', 'out', 'pin-script', 'server-idle-state'): + parser.add_argument('--' + name, required=True, type=Path) + parser.add_argument('--serial', required=True, choices=('2f7cbe2e',), help='profile validated for this Redmi only') + parser.add_argument('--endpoint', required=True) + parser.add_argument('--listen', required=True) + parser.add_argument('--token', default=os.environ.get('MOBILEGL_IPC_TOKEN', '')) + parser.add_argument('--package', default='top.mobilegl.plugin.trace') + parser.add_argument('--cool-max-mc', type=int, default=36000) + parser.add_argument('--cool-timeout', type=int, default=300) + parser.add_argument('--case', action='append', choices=CASES) + parser.add_argument('--reboot', action='store_true', required=True) + args = parser.parse_args() + args.adb = 'adb' + if args.out.exists(): + parser.error('choose a new output directory; paired sessions never resume across boots') + if not args.endpoint.startswith('tcp://') or args.cool_timeout <= 0: + parser.error('TCP endpoint and a positive cooldown timeout are required') + args.out.mkdir(parents=True) + return run_session(args) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/trace_replay/run_tcp_matrix.py b/tools/trace_replay/run_tcp_matrix.py new file mode 100644 index 00000000..21bf4efe --- /dev/null +++ b/tools/trace_replay/run_tcp_matrix.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +"""Run manifest-selected TCP retraces from a CTest JSON catalog, with resumable evidence. + +Requires Linux/WSL process groups. The old local CTest/manifest TIMEOUT is not a +TCP deadline: log inactivity and an explicit absolute ceiling are separate. +""" +from __future__ import annotations +import argparse +import contextlib +import ctypes +import fcntl +import hashlib +import json +import math +import os +from pathlib import Path +import re +import signal +import shutil +import subprocess +import sys +import time +import uuid + +from trace_cases import load_trace_case_manifest, ci_backends + +ROOT = Path(__file__).resolve().parents[2] +OWNED_ENV = {"MOBILEGL_TRANSPORT", "MOBILEGL_IPC_CONTROL", "MOBILEGL_IPC_DATA", + "MOBILEGL_IPC_TOKEN", "MOBILEGL_IPC_DIAL", "MOBILEGL_IPC_ROLE", + "MOBILEGL_IPC_SERVER_PATH", "MOBILEGL_LOG_FILE_PATH"} + + +def read_json(path): + def invalid(value): + raise ValueError("non-finite JSON number: " + value) + return json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=invalid) + + +def digest(path): + h = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def fingerprint(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def atomic_json(path, value): + temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp") + try: + with temporary.open("w", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2, allow_nan=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def select_cases(manifest_path, requested): + manifest = load_trace_case_manifest(manifest_path) + raw = read_json(manifest_path) + explicit = {case["name"]: case.get("split", raw.get("defaults", {}).get("split")) + for case in raw["cases"]} + cases = {case["name"]: case for case in manifest["cases"]} + names = requested or [case["name"] for case in cases.values() if case["split"]] + if len(names) != len(set(names)): + raise ValueError("duplicate --case") + selected = [] + for name in names: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name) or name not in cases: + raise ValueError("unknown/unsafe case: " + name) + case = cases[name] + # An explicit non-CI workload, such as rd12, is useful for extra golden/perf runs. + # An explicit split:false remains a refusal, including in this explicit selection. + if explicit[name] is False or (not case["split"] and case.get("ci", True)): + raise ValueError("case opts out of split: " + name) + selected.append(case) + if not selected: + raise ValueError("the manifest selected no split cases") + return selected + + +def properties(test): + return {entry["name"]: entry["value"] for entry in test.get("properties", [])} + + +def case_environment(test, base, args, output=None): + env = dict(base) # A fresh copy for every case; never leak the previous case's quirks. + for pair in properties(test).get("ENVIRONMENT", []): + key, sep, value = pair.partition("=") + if not sep: + raise ValueError("malformed CTest environment entry: " + pair) + if key not in OWNED_ENV: + env[key] = value + for key in OWNED_ENV: + env.pop(key, None) + env.update(MOBILEGL_TRANSPORT="spawn", MOBILEGL_IPC_CONTROL=args.endpoint, + MOBILEGL_IPC_DATA="stream", MOBILEGL_IPC_TOKEN=args.token, + MOBILEGL_IPC_REQUIRE_SAME_BUILD="1", MOBILEGL_IPC_LOG_FORWARD="1", + MOBILEGL_PIPE_STATS="1", MOBILEGL_PIPE_STATS_PERIOD="1", + MOBILEGL_IPC_PRESENT_CREDIT=str(args.credit)) + if getattr(args, "require_run_ahead", False): + env["MOBILEGL_IPC_RUN_AHEAD"] = "1" + env["MOBILEGL_IPC_VERB_BARRIER"] = "1" + if output is not None: + env["MOBILEGL_LOG_FILE_PATH"] = str(output / "output/mobilegl.log") + return env + + +def definitions(command): + result = {} + for arg in command: + if arg.startswith("-D") and "=" in arg: + key, value = arg[2:].split("=", 1) + result[key.split(":", 1)[0]] = value + return result + + +def plan_case(case, backend, catalog, args, hashes): + name = f"MobileGLTraceReplay.{case['name']}.{backend}" + test = next((catalog[key] for key in (name, name + ".TCP", name + ".SPAWN", name + ".SPLIT") + if key in catalog), None) + if test is None: + raise ValueError("CTest catalog has no entry for " + name) + command = list(test["command"]) + defs = definitions(command) + if defs.get("TRACE_CASE_NAME") != case["name"] or defs.get("TRACE_BACKEND") != backend: + raise ValueError("CTest name and command disagree: " + name) + if "-P" not in command or not defs.get("TRACE_ARCHIVE"): + raise ValueError("expected a run_trace_case.cmake catalog entry: " + name) + fixtures = args.fixtures or Path(defs["TRACE_ARCHIVE"]).parent + runner = (args.runner or Path(defs["TRACE_REPLAY_EXE"])).resolve() + script = args.source / "tools/trace_replay/run_trace_case.cmake" + values = {"TRACE_REPLAY_EXE": str(runner), "MOBILEGL_LIBRARY": str(args.library), + "TRACE_CASE_NAME": case["name"], "TRACE_BACKEND": backend, + "TRACE_TRANSPORT": "spawn", "TRACE_IPC_SERVER_PATH": "", + "TRACE_IPC_CONTROL": "", "TRACE_IPC_TOKEN": ""} + # Read values from the CURRENT manifest, not stale -D values in the catalog. + keys = {"TRACE_FILE": "trace_file", "TRACE_TARGET_CALL": "target_call", "TRACE_WIDTH": "width", + "TRACE_HEIGHT": "height", "TRACE_SSIM_THRESHOLD": "ssim_threshold", "TRACE_CROP_X": "crop_x", + "TRACE_CROP_Y": "crop_y", "TRACE_CROP_WIDTH": "crop_width", "TRACE_CROP_HEIGHT": "crop_height", + "TRACE_COHERENT_AS_FLUSH": "coherent_as_flush"} + for key, field in keys.items(): + value = case.get(field, False if field == "coherent_as_flush" else 0.99 if field == "ssim_threshold" else 0) + values[key] = ("ON" if value else "OFF") if isinstance(value, bool) else str(value) + inputs = [] + for key, field in (("TRACE_ARCHIVE", "trace_archive"), ("TRACE_GOLDEN", "golden"), + ("TRACE_ALTERNATE_GOLDEN", "alternate_golden")): + path = (fixtures / case[field]).resolve() if case.get(field) else None + values[key] = str(path) if path else "" + if path: + stat = path.stat() + inputs.append({"path": str(path), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns, + **({"sha256": digest(path)} if key != "TRACE_ARCHIVE" else {})}) + trace_file = Path(case["trace_file"]) + if trace_file.is_absolute() or ".." in trace_file.parts: + raise ValueError("trace_file must remain inside the extracted input directory") + for path in (runner, args.library, script): + if str(path) not in hashes: + hashes[str(path)] = digest(path) + env = case_environment(test, args.base_env, args) + # Ignore shell/session bookkeeping when resuming, but retain renderer knobs and + # every case-specific catalog variable. The digest stores no token/plaintext env. + case_keys = {pair.partition("=")[0] for pair in properties(test).get("ENVIRONMENT", [])} + relevant = {key: value for key, value in env.items() if key in case_keys or + key.startswith(("MOBILEGL_", "MGITEST_", "EGL_", "LIBGL_", "MESA_", "VK_", "__GL", "__EGL", "LD_")) or + key in ("DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DRI_PRIME", "GALLIUM_DRIVER")} + identity = fingerprint({"case": case, "backend": backend, "credit": args.credit, + "endpoint": args.endpoint, "token": args.token, + "require_run_ahead": args.require_run_ahead, + "artifacts": {str(p): hashes[str(p)] for p in (runner, args.library, script)}, + "inputs": inputs, "environment": relevant, "command": command}) + return {"case": case, "backend": backend, "test": test, "command": command, "values": values, + "script": script, "runner": runner, "identity": identity, + "key": f"{backend}/credit-{args.credit}/{case['name']}"} + + +def command_for(plan, work): + values = {**plan["values"], "TRACE_OUTPUT_DIR": str(work), "TRACE_ARTIFACT_DIR": str(work.parent / "artifacts")} + command, found = [], set() + original = plan["command"] + for index, arg in enumerate(original): + if index and original[index - 1] == "-P": + command.append(str(plan["script"])) + elif arg.startswith("-D") and arg[2:].split("=", 1)[0].split(":", 1)[0] in values: + key = arg[2:].split("=", 1)[0].split(":", 1)[0] + command.append(f"-D{key}={values[key]}") + found.add(key) + else: + command.append(arg) + position = command.index("-P") + command[position:position] = [f"-D{k}={v}" for k, v in values.items() if k not in found] + return command + + +def group_members(pgid): + members = [] + for path in Path("/proc").glob("[0-9]*/stat"): + try: + fields = path.read_text().rsplit(")", 1)[1].split() + if int(fields[2]) == pgid: + members.append((int(path.parent.name), fields[0])) + except (OSError, ValueError, IndexError): + pass + return members + + +def reap_group(pgid): + while True: + try: + pid, _ = os.waitpid(-pgid, os.WNOHANG) + if pid == 0: + break + except ChildProcessError: + break + + +@contextlib.contextmanager +def subreaper(): + libc = ctypes.CDLL(None, use_errno=True) + previous = ctypes.c_int() + if libc.prctl(37, ctypes.byref(previous), 0, 0, 0) or libc.prctl(36, 1, 0, 0, 0): + raise OSError(ctypes.get_errno(), "cannot enable Linux child subreaper") + try: + yield + finally: + libc.prctl(36, previous.value, 0, 0, 0) + + +def terminate_group(process, grace=2.0): + """Reap descendants even when the wrapper exits before a TERM-ignoring child.""" + pgid = process.pid + for sig, duration in ((signal.SIGTERM, grace), (signal.SIGKILL, 2.0)): + try: + os.killpg(pgid, sig) + except ProcessLookupError: + pass + until = time.monotonic() + duration + while time.monotonic() < until: + process.poll() # Let Popen reap its own child before reaping adopted descendants. + if process.returncode is not None: + reap_group(pgid) + if not group_members(pgid): + return [] + time.sleep(.02) + process.wait(timeout=2) + reap_group(pgid) + return group_members(pgid) + + +def progress_signature(work, console): + signature = [] + for path in [console, *work.rglob("*.log")]: + try: + stat = path.stat() + signature.append((str(path), stat.st_size, stat.st_mtime_ns)) + except FileNotFoundError: + pass + return tuple(sorted(signature)) + + +def supervise(command, env, cwd, work, console, idle_seconds=300, max_seconds=7200, + poll_seconds=.2, terminate_grace=2.0, wake=None): + started = time.monotonic() + last_progress = started + previous = progress_signature(work, console) + timeout_kind = None + next_wake = started + with subreaper(), console.open("wb") as output: + process = subprocess.Popen(command, env=env, cwd=cwd, stdout=output, stderr=output, + start_new_session=True) + try: + while process.poll() is None: + now = time.monotonic() + if wake is not None and now >= next_wake: + wake() + next_wake = time.monotonic() + 15 + signature = progress_signature(work, console) + if signature != previous: + previous, last_progress = signature, time.monotonic() + now = time.monotonic() + if now - started >= max_seconds: + timeout_kind = "absolute" + break + if now - last_progress >= idle_seconds: + timeout_kind = "idle" + break + time.sleep(poll_seconds) + leftovers = group_members(process.pid) if process.poll() is not None else [] + returncode = process.returncode + finally: + remaining = terminate_group(process, terminate_grace) + return {"returncode": 124 if timeout_kind else returncode, "timeout_kind": timeout_kind, + "seconds": round(time.monotonic() - started, 3), "pid": process.pid, + "leftovers_after_parent_exit": [pid for pid, state in leftovers if state != "Z"], + "remaining_processes": remaining} + + +def actual_arm_from_text(client_text): + return {"armed": "run-ahead ARMED" in client_text, + "lockstep": "running lockstep" in client_text, + "disarmed": bool(re.search(r"\bDISARMED\b", client_text))} + + +def validate_result(plan, work, endpoint, require_run_ahead=False): + path = work / "output/result.json" + result = read_json(path) + if result.get("passed") is not True or result.get("statusCode") != 0: + raise ValueError("result.json does not report success") + if result.get("backend") != plan["backend"] or result.get("targetCall") != int(plan["values"]["TRACE_TARGET_CALL"]): + raise ValueError("result identifies a different backend/call") + expected_trace = (work / "input" / plan["case"]["trace_file"]).resolve() + if Path(result.get("tracePath", "")).resolve() != expected_trace: + raise ValueError("result tracePath belongs to a different attempt") + actual = work / "output/actual.png" + if Path(result.get("actualPath", "")).resolve() != actual.resolve() or not actual.is_file(): + raise ValueError("result has no current-attempt actual image") + goldens = {str(Path(plan["values"][key]).resolve()) for key in ("TRACE_GOLDEN", "TRACE_ALTERNATE_GOLDEN") if plan["values"][key]} + if str(Path(result.get("matchedGoldenPath", "")).resolve()) not in goldens: + raise ValueError("result matched a different golden") + score = result.get("ssim") + if isinstance(score, bool) or not isinstance(score, (float, int)) or not math.isfinite(score) or score < float(plan["values"]["TRACE_SSIM_THRESHOLD"]): + raise ValueError("result SSIM does not meet the current manifest threshold") + client_log = work / "output/mobilegl.client.log" + client_text = client_log.read_text(errors="replace") + marker = re.compile(r"control=tcp data=stream server=(\S+) pid=([1-9][0-9]*)") + if not any(match[0] == endpoint.removeprefix("tcp://") for match in marker.findall(client_text)): + raise ValueError("current-attempt log lacks the requested TCP arm proof") + actual_arm = actual_arm_from_text(client_text) + if require_run_ahead and (not actual_arm["armed"] or actual_arm["lockstep"] or actual_arm["disarmed"]): + raise ValueError("required run-ahead arm missing or contradicted in private client log: " + str(actual_arm)) + return {"result": result, "result_path": str(path), "result_sha256": digest(path), + "client_log": str(client_log), "client_log_sha256": digest(client_log), "actual_arm": actual_arm} + + +def reusable(row, plan, out, endpoint, require_run_ahead=False): + if row is None or row.get("status") != "passed" or row.get("returncode") != 0 or row.get("timeout_kind") is not None or row.get("identity") != plan["identity"]: + return False + if require_run_ahead and row.get("require_run_ahead") is not True: + return False + try: + work = Path(row["work"]) + if not work.resolve().is_relative_to(out.resolve()): + return False + proof = validate_result(plan, work, endpoint, require_run_ahead) + return all(proof[key] == row.get(key) for key in ("result_sha256", "client_log_sha256", "actual_arm")) + except (OSError, ValueError, KeyError): + return False + + +def run_case(plan, args, row): + work = Path(row["work"]) + console = work.parent / "runner.log" + env = case_environment(plan["test"], args.base_env, args, work) + row["requested_arm"] = {key: env.get(key) for key in ("MOBILEGL_IPC_RUN_AHEAD", "MOBILEGL_IPC_VERB_BARRIER")} + props = properties(plan["test"]) + cwd = props.get("WORKING_DIRECTORY", str(args.source)) + def wake(): + with (work.parent / "wake-adb.log").open("ab") as log: + try: + subprocess.run([args.adb, "-s", args.wake_adb_serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP"], + stdout=log, stderr=log, timeout=5, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + log.write((str(error) + "\n").encode()) + outcome = supervise(command_for(plan, work), env, cwd, work, console, args.idle_seconds, + args.max_seconds, wake=wake if args.wake_adb_serial else None) + row.update(outcome, status="failed", console=str(console)) + client_log = work / "output/mobilegl.client.log" + row["actual_arm"] = actual_arm_from_text(client_log.read_text(errors="replace")) if client_log.is_file() else None + if outcome["timeout_kind"]: + row["status"] = "timeout" + elif outcome["returncode"] == 0 and not outcome["remaining_processes"] and not outcome["leftovers_after_parent_exit"]: + try: + row.update(validate_result(plan, work, args.endpoint, args.require_run_ahead), status="passed") + except (OSError, ValueError, KeyError) as error: + row["error"] = str(error) + if row["status"] != "passed" and row["returncode"] == 0: + row["process_returncode"] = 0 + row["returncode"] = 1 # A zero wrapper exit cannot turn stale/missing proof green. + return row + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + catalog_source = parser.add_mutually_exclusive_group(required=True) + catalog_source.add_argument("--catalog", type=Path, help="ctest --show-only=json-v1 output") + catalog_source.add_argument("--build-dir", type=Path, help="read a catalog using ctest; does not build") + parser.add_argument("--source", type=Path, default=ROOT) + parser.add_argument("--manifest", type=Path) + parser.add_argument("--fixtures", type=Path, help="default: the fixture directory recorded in the catalog") + parser.add_argument("--runner", type=Path, help="override TRACE_REPLAY_EXE from the catalog") + parser.add_argument("--library", type=Path, required=True) + parser.add_argument("--endpoint", required=True) + parser.add_argument("--token", default=os.environ.get("MOBILEGL_IPC_TOKEN", "")) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--case", action="append") + parser.add_argument("--backend", action="append", choices=("DirectGLES", "DirectVulkan")) + parser.add_argument("--credit", type=int, choices=(1, 2, 3), default=2) + parser.add_argument("--resume", action="store_true") + parser.add_argument("--require-run-ahead", action="store_true", + help="require private-client ARMED proof, no lockstep fallback/DISARMED; force RUN_AHEAD=1 and VERB_BARRIER=1") + parser.add_argument("--idle-seconds", type=float, default=300) + parser.add_argument("--max-seconds", type=float, default=7200) + parser.add_argument("--wake-adb-serial", help="explicit opt-in: KEYCODE_WAKEUP every 15 seconds while a case runs") + parser.add_argument("--adb", default="adb") + args = parser.parse_args(argv) + if sys.platform != "linux": + parser.error("run this process-group driver in Linux/WSL") + if not args.endpoint.startswith("tcp://") or any(not math.isfinite(value) or value <= 0 + for value in (args.idle_seconds, args.max_seconds)): + parser.error("a tcp:// endpoint and positive timeouts are required") + args.source, args.library, args.out = args.source.resolve(), args.library.resolve(), args.out.resolve() + args.base_env = dict(os.environ) + args.manifest = args.manifest or args.source / "tools/trace_replay/trace_cases.json" + backends = args.backend or ["DirectGLES"] + if len(backends) != len(set(backends)): + parser.error("duplicate --backend") + try: + cases = select_cases(args.manifest, args.case) + document = read_json(args.catalog) if args.catalog else json.loads(subprocess.check_output( + ["ctest", "--test-dir", str(args.build_dir), "--show-only=json-v1"], text=True)) + catalog = {test["name"]: test for test in document["tests"]} + hashes = {} + plans = [plan_case(case, backend, catalog, args, hashes) for case in cases + for backend in backends if backend in ci_backends(case)] + if not plans: + raise ValueError("no selected case supports the requested backend") + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + parser.error(str(error)) + args.out.mkdir(parents=True, exist_ok=True) + checkpoint_path = args.out / "checkpoint.json" + with (args.out / ".matrix.lock").open("w") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + parser.error("another matrix driver owns this output directory") + if not checkpoint_path.exists() and (args.out / "results.json").is_file(): + legacy = args.out / ("results.legacy-" + uuid.uuid4().hex + ".json") + shutil.copy2(args.out / "results.json", legacy) + print(json.dumps({"legacy_results_preserved": str(legacy)}), flush=True) + checkpoint = read_json(checkpoint_path) if checkpoint_path.exists() else {"schema_version": 1, "runs": []} + if checkpoint.get("schema_version") != 1 or not isinstance(checkpoint.get("runs"), list): + parser.error("unsupported/corrupt checkpoint; old scratch results are not a checkpoint") + selected = [] + def interrupted(signum, frame): + raise KeyboardInterrupt + handlers = {sig: signal.signal(sig, interrupted) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)} + try: + for plan in plans: + prior = next((r for r in reversed(checkpoint["runs"]) if r.get("key") == plan["key"]), None) + if args.resume and reusable(prior, plan, args.out, args.endpoint, args.require_run_ahead): + selected.append({**prior, "resumed": True}) + print(json.dumps({"case": prior["case"], "backend": prior["backend"], "status": "passed", "resumed": True}), flush=True) + continue + attempt = args.out / plan["key"] / ("attempt-" + uuid.uuid4().hex) + attempt.mkdir(parents=True) + row = {"key": plan["key"], "case": plan["case"]["name"], "backend": plan["backend"], + "credit": args.credit, "ci": plan["case"].get("ci", True), "identity": plan["identity"], + "require_run_ahead": args.require_run_ahead, + "work": str(attempt / "work"), "status": "running", "started_at_ns": time.time_ns()} + checkpoint["runs"].append(row) + atomic_json(checkpoint_path, checkpoint) + print(json.dumps({"case": row["case"], "backend": row["backend"], "status": "running"}), flush=True) + try: + run_case(plan, args, row) + except KeyboardInterrupt: + row.update(status="cancelled", returncode=130) + atomic_json(checkpoint_path, checkpoint) + return 130 + except Exception as error: + row.update(status="failed", returncode=1, error=str(error)) + row["completed_at_ns"] = time.time_ns() + atomic_json(checkpoint_path, checkpoint) + selected.append({**row, "resumed": False}) + atomic_json(args.out / "results.json", selected) + print(json.dumps({k: row.get(k) for k in ("case", "backend", "status", "returncode", "seconds", "timeout_kind", "error")}), flush=True) + if row["status"] == "timeout" or row.get("remaining_processes"): + # Do not convert an unhealthy peer into a cascade of Busy outcomes. + # The failed checkpoint is retained; resume after checking the peer. + break + time.sleep(.3) + finally: + for sig, handler in handlers.items(): + signal.signal(sig, handler) + atomic_json(args.out / "results.json", selected) + return 0 if all(row["status"] == "passed" for row in selected) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/trace_replay/tcp_device_server.py b/tools/trace_replay/tcp_device_server.py index 4afccad3..1f8909f8 100644 --- a/tools/trace_replay/tcp_device_server.py +++ b/tools/trace_replay/tcp_device_server.py @@ -1,10 +1,56 @@ #!/usr/bin/env python3 """Start or stop the trace APK's foreground TCP supervisor through adb.""" import argparse +import json +from pathlib import Path +import re import shlex import subprocess +def shell(adb, command): + return subprocess.run(adb + ['shell', shlex.join(command)], check=True, + capture_output=True, text=True).stdout + + +def idle_whitelisted(adb, package): + entries = shell(adb, ['dumpsys', 'deviceidle', 'whitelist']) + return any(package in line.strip().split(',') for line in entries.splitlines()) + + +def allow_idle(adb, serial, package, state_path): + if state_path.exists(): + saved = json.loads(state_path.read_text()) + if saved['serial'] != serial or saved['package'] != package: + raise ValueError('idle state belongs to a different device or package') + else: + saved = {'serial': serial, 'package': package, + 'originally_whitelisted': idle_whitelisted(adb, package)} + state_path.parent.mkdir(parents=True, exist_ok=True) + # Save the original state before the device mutation, including if the + # command's response is lost and a later stop has to recover it. + state_path.write_text(json.dumps(saved, indent=2)) + if not idle_whitelisted(adb, package): + shell(adb, ['dumpsys', 'deviceidle', 'whitelist', '+' + package]) + if not idle_whitelisted(adb, package): + raise RuntimeError('the package was not added to the device idle whitelist') + print(f'Idle exemption recorded in {state_path}; stop restores the original package setting') + + +def restore_idle(adb, serial, package, state_path): + if not state_path.exists(): + return + saved = json.loads(state_path.read_text()) + if saved['serial'] != serial or saved['package'] != package: + raise ValueError('idle state belongs to a different device or package') + if not saved['originally_whitelisted']: + shell(adb, ['dumpsys', 'deviceidle', 'whitelist', '-' + package]) + if idle_whitelisted(adb, package): + raise RuntimeError('the package idle exemption could not be restored') + state_path.unlink() + print('Restored the original device idle whitelist setting for ' + package) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=('start', 'stop')) @@ -15,15 +61,25 @@ def main(): parser.add_argument('--token', default='') parser.add_argument('--env', action='append', default=[], help='KEY=VALUE;KEY overrides (repeatable)') parser.add_argument('--forward', action='store_true', help='Forward local adb port 40613 to the device') + parser.add_argument('--allow-idle', action='store_true', + help='Temporarily exempt this package from Doze; stop restores its previous setting') + parser.add_argument('--state-file', type=Path, + help='Saved original idle setting (default: ~/.cache/mobilegl/tcp-server/-.json)') args = parser.parse_args() adb = [args.adb, '-s', args.serial] + state_name = re.sub(r'[^A-Za-z0-9_.-]', '_', args.serial + '-' + args.package) + '.json' + state_path = args.state_file or (Path.home() / '.cache/mobilegl/tcp-server' / state_name) if args.action == 'stop': command = ['am', 'force-stop', args.package] else: + if args.allow_idle: + allow_idle(adb, args.serial, args.package, state_path) command = ['am', 'start-foreground-service', '-n', args.package + '/top.mobilegl.plugin.MobileGLServerService', '--es', 'listen', args.listen, '--es', 'token', args.token, '--es', 'env', ';'.join(args.env)] - subprocess.run(adb + ['shell', shlex.join(command)], check=True) + print(shell(adb, command), end='') + if args.action == 'stop': + restore_idle(adb, args.serial, args.package, state_path) if args.action == 'start' and args.forward: subprocess.run(adb + ['forward', 'tcp:40613', 'tcp:40613'], check=True) if args.action == 'start': diff --git a/tools/trace_replay/test_benchmark_tcp_credits.py b/tools/trace_replay/test_benchmark_tcp_credits.py new file mode 100644 index 00000000..522e8223 --- /dev/null +++ b/tools/trace_replay/test_benchmark_tcp_credits.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Offline controls for selecting truthful TCP benchmark windows.""" +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import benchmark_tcp_credits as benchmark +from benchmark_tcp_credits import validate_measurement + + +class BenchmarkEvidenceTest(unittest.TestCase): + endpoint = 'tcp://127.0.0.1:40613' + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.work = Path(self.temporary.name) + self.client = ['run-ahead ARMED', 'paced by a present credit of 1', + 'control=tcp data=stream server=127.0.0.1:40613'] + self.server = [] + for frame in range(1, 4): + self.client.append('P65LinkMetrics kind=frame frame=' + str(frame) + + ' wait_replies=2 rtt_samples=1 rtt_mean_us=8 stage_bytes=1024' + ' wall_ns=1000000 client_thread_cpu_ns=500000 rtt_hist_us_pow2=' + + ','.join('1' if bucket == 3 else '0' for bucket in range(32))) + self.server.append('P65ServerMetrics valid=1 frame=' + str(frame) + + ' wall_ns=1000000 apply_thread_cpu_ns=250000') + + def validate(self, credit=1, total=3): + (self.work / 'benchmark.json').write_text(json.dumps({'totalFrames': total})) + (self.work / 'mobilegl.client.log').write_text('\n'.join(self.client)) + (self.work / 'mobilegl.server.log').write_text('\n'.join(self.server)) + return validate_measurement(self.work, self.endpoint, credit, 2) + + def test_valid_matching_tail(self): + result = self.validate() + self.assertEqual((result['first_frame'], result['last_frame']), (2, 3)) + self.assertEqual(result['wait_replies_per_frame'], 2) + self.assertEqual(result['rtt_mean_us'], 8) + self.assertEqual(result['fps'], 1000) + + def test_server_marker_cannot_arm_client(self): + self.client.remove('run-ahead ARMED') + self.server.append('run-ahead ARMED') + with self.assertRaisesRegex(ValueError, 'requested armed credit'): + self.validate() + + def test_demotion_or_lockstep_rejected(self): + for marker in ('running lockstep', 'run-ahead DISARMED'): + with self.subTest(marker=marker): + self.client.append(marker) + with self.assertRaisesRegex(ValueError, 'requested armed credit'): + self.validate() + self.client.pop() + + def test_credit_proof_is_exact(self): + self.client[1] = 'paced by a present credit of 10' + with self.assertRaisesRegex(ValueError, 'requested armed credit'): + self.validate() + + def test_missing_server_tail_frame_rejected(self): + self.server.pop() + with self.assertRaisesRegex(ValueError, 'matching complete'): + self.validate() + + def test_duplicate_client_frame_rejected(self): + self.client.append(self.client[-1]) + with self.assertRaisesRegex(ValueError, 'duplicate frame'): + self.validate() + + def test_partial_metrics_rejected(self): + with self.assertRaisesRegex(ValueError, 'benchmark and frame metrics differ'): + self.validate(total=4) + + def test_missing_cpu_is_not_zero(self): + self.server[-1] = self.server[-1].replace('apply_thread_cpu_ns=250000', 'apply_thread_cpu_ns=0') + with self.assertRaisesRegex(ValueError, 'nonpositive CPU'): + self.validate() + + def test_resume_requires_unchanged_artifacts_and_library(self): + valid = self.validate() + inputs = self.work / 'inputs' / 'OpenRA' + inputs.mkdir(parents=True) + (inputs / 'openra.trace').write_bytes(b'trace fixture') + runner, library = self.work / 'runner', self.work / 'library' + runner.write_bytes(b'runner fixture') + library.write_bytes(b'library fixture') + output = self.work / 'results' + argv = ['benchmark_tcp_credits.py', '--runner', str(runner), '--library', str(library), + '--inputs', str(inputs.parent), '--out', str(output), '--endpoint', self.endpoint, + '--case', 'OpenRA', '--credit', '1', '--serial', 'offline-fixture'] + + def completed_measurement(args, case, credit, work): + work.mkdir(parents=True) + for filename in ('benchmark.json', 'mobilegl.client.log', 'mobilegl.server.log'): + (work / filename).write_bytes((self.work / filename).read_bytes()) + return {**valid, 'status': 'passed', 'output': str(work)} + + with mock.patch.object(benchmark, 'measure', side_effect=completed_measurement) as measure: + with mock.patch('sys.argv', argv), mock.patch('builtins.print'): + self.assertEqual(benchmark.main(), 0) + with mock.patch('sys.argv', [*argv, '--resume']), mock.patch('builtins.print'): + self.assertEqual(benchmark.main(), 0) + self.assertEqual(measure.call_count, 1) + log = next(output.rglob('mobilegl.client.log')) + log.write_text('tampered') + with mock.patch('sys.argv', [*argv, '--resume']), mock.patch('builtins.print'): + self.assertEqual(benchmark.main(), 0) + self.assertEqual(measure.call_count, 2) + library.write_bytes(b'changed library') + with mock.patch('sys.argv', [*argv, '--resume']), mock.patch('builtins.print'): + self.assertEqual(benchmark.main(), 0) + self.assertEqual(measure.call_count, 3) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/trace_replay/test_run_tcp_matrix.py b/tools/trace_replay/test_run_tcp_matrix.py new file mode 100644 index 00000000..3bbd4710 --- /dev/null +++ b/tools/trace_replay/test_run_tcp_matrix.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Small process/fixture stubs only: no GL, network peer, adb or production trace.""" +import contextlib +import io +import json +import os +from pathlib import Path +import sys +import tempfile +import textwrap +import types +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import run_tcp_matrix as matrix + +STUB = r""" +import json,os,sys,time +from pathlib import Path +values = dict(arg[2:].split('=',1) for arg in sys.argv[1:] if arg.startswith('-D') and '=' in arg) +work=Path(values['TRACE_OUTPUT_DIR']);(work/'output').mkdir(parents=True,exist_ok=True);(work/'input').mkdir() +behavior=os.environ.get('CASE_BEHAVIOR','success') +if behavior=='active': + for n in range(16): + with (work/'output/progress.log').open('a') as log:log.write(str(n)+'\n') + time.sleep(.05) +if behavior!='no-result': + actual=work/'output/actual.png';actual.write_bytes(b'fixture-image') + endpoint=os.environ['MOBILEGL_IPC_CONTROL'].removeprefix('tcp://') + arm=os.environ.get('CASE_ARM','armed') + if arm=='respect-env':arm='armed' if os.environ.get('MOBILEGL_IPC_RUN_AHEAD')=='1' and os.environ.get('MOBILEGL_IPC_VERB_BARRIER')=='1' else 'lockstep' + line={'armed':'run-ahead ARMED\n','lockstep':'running lockstep\n','disarmed':'run-ahead ARMED\nrun-ahead DISARMED\n','server-only':''}[arm] + (work/'output/mobilegl.client.log').write_text('control=tcp data=stream server='+endpoint+' pid=123 dial=connect\n'+line) + if arm=='server-only':(work/'output/mobilegl.server.log').write_text('run-ahead ARMED\n') + result={'passed':True,'statusCode':0,'backend':values['TRACE_BACKEND'], + 'targetCall':int(values['TRACE_TARGET_CALL']),'tracePath':str(work/'input'/values['TRACE_FILE']), + 'actualPath':str(actual),'matchedGoldenPath':values['TRACE_GOLDEN'],'ssim':1.0} + (work/'output/result.json').write_text(json.dumps(result)) +if behavior=='timeout':time.sleep(60) +raise SystemExit(7 if behavior=='failed' else 0) +""" + + +@unittest.skipUnless(sys.platform == 'linux', 'Linux/WSL process-group driver') +class MatrixTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='tcp-matrix-test-') + self.root = Path(self.temp.name) + self.source = self.root/'source' + self.tool = self.source/'tools/trace_replay' + self.tool.mkdir(parents=True) + (self.tool/'run_trace_case.cmake').write_text('# stub script') + self.fixtures = self.root/'fixtures';self.fixtures.mkdir() + (self.fixtures/'fixture.tgz').write_bytes(b'fixture archive') + (self.fixtures/'golden.png').write_bytes(b'golden image') + self.library = self.root/'libMobileGL.so';self.library.write_bytes(b'library-v1') + self.stub = self.root/'stub.py';self.stub.write_text(STUB) + self.catalog = self.root/'catalog.json' + self.out = self.root/'results' + self.manifest = self.tool/'trace_cases.json' + self.write_inputs() + + def tearDown(self): + self.temp.cleanup() + + def write_inputs(self, behavior='success', extra_cases=None): + cases=[{'name':'Example','trace_archive':'fixture.tgz','trace_file':'trace.trace', + 'golden':'golden.png','target_call':42,'width':16,'height':16,'timeout_seconds':.05}] + if extra_cases:cases.extend(extra_cases) + self.manifest.write_text(json.dumps({'defaults':{'ssim_threshold':.99},'cases':cases})) + command=[sys.executable,str(self.stub),'-DTRACE_REPLAY_EXE='+sys.executable, + '-DMOBILEGL_LIBRARY=/old/library','-DTRACE_CASE_NAME=Example','-DTRACE_BACKEND=DirectGLES', + '-DTRACE_ARCHIVE='+str(self.fixtures/'fixture.tgz'),'-DTRACE_OUTPUT_DIR=/old/output', + '-P',str(self.tool/'run_trace_case.cmake')] + self.catalog.write_text(json.dumps({'kind':'ctestInfo','tests':[{ + 'name':'MobileGLTraceReplay.Example.DirectGLES','command':command, + 'properties':[{'name':'ENVIRONMENT','value':['CASE_BEHAVIOR='+behavior]}, + {'name':'TIMEOUT','value':.05},{'name':'WORKING_DIRECTORY','value':str(self.root)}]}]})) + + def run_main(self, *extra): + args=['--catalog',str(self.catalog),'--source',str(self.source),'--library',str(self.library), + '--runner',sys.executable,'--endpoint','tcp://127.0.0.1:9','--token','not-saved-in-checkpoint', + '--out',str(self.out),'--idle-seconds','1','--max-seconds','5',*extra] + with contextlib.redirect_stdout(io.StringIO()): + return matrix.main(args) + + def checkpoint(self): + return matrix.read_json(self.out/'checkpoint.json')['runs'] + + def set_arm(self, arm, extra=None): + catalog=matrix.read_json(self.catalog) + catalog['tests'][0]['properties'][0]['value'] += ['CASE_ARM='+arm,*(extra or [])] + self.catalog.write_text(json.dumps(catalog)) + + def test_required_arm_forces_both_environment_knobs_and_records_actual_arm(self): + self.set_arm('respect-env',['MOBILEGL_IPC_RUN_AHEAD=0','MOBILEGL_IPC_VERB_BARRIER=0']) + self.assertEqual(self.run_main('--require-run-ahead'),0) + row=self.checkpoint()[0] + self.assertEqual(row['requested_arm'],{'MOBILEGL_IPC_RUN_AHEAD':'1','MOBILEGL_IPC_VERB_BARRIER':'1'}) + self.assertEqual(row['actual_arm'],{'armed':True,'lockstep':False,'disarmed':False}) + self.assertEqual(self.run_main('--require-run-ahead','--resume'),0) + self.assertEqual(len(self.checkpoint()),1) + + def test_required_arm_rejects_lockstep_disarmed_and_server_only_marker(self): + for arm in ('lockstep','disarmed','server-only'): + with self.subTest(arm=arm): + self.write_inputs() + self.set_arm(arm) + self.assertEqual(self.run_main('--require-run-ahead'),1) + row=self.checkpoint()[-1] + self.assertEqual(row['status'],'failed') + self.assertIn('required run-ahead',row['error']) + self.assertIsInstance(row['actual_arm'],dict) + + def test_required_arm_does_not_resume_a_former_lockstep_image_pass(self): + self.set_arm('lockstep') + self.assertEqual(self.run_main(),0) + self.assertTrue(self.checkpoint()[0]['actual_arm']['lockstep']) + self.assertEqual(self.run_main('--require-run-ahead','--resume'),1) + self.assertEqual(len(self.checkpoint()),2) + self.assertEqual(self.checkpoint()[-1]['status'],'failed') + + def test_success_is_resumed_only_while_its_evidence_is_intact(self): + self.assertEqual(self.run_main(),0) + self.assertEqual(self.run_main('--resume'),0) + self.assertEqual(len(self.checkpoint()),1) + record=self.checkpoint()[0] + self.assertNotIn('not-saved-in-checkpoint',(self.out/'checkpoint.json').read_text()) + Path(record['result_path']).write_text('{"passed":false}') + self.assertEqual(self.run_main('--resume'),0) + self.assertEqual(len(self.checkpoint()),2) + self.assertNotEqual(self.checkpoint()[0]['work'],self.checkpoint()[1]['work']) + + def test_nonzero_exit_with_passed_json_never_becomes_a_checkpoint_pass(self): + self.write_inputs('failed') + self.assertEqual(self.run_main(),1) + self.assertEqual(self.run_main('--resume'),1) + self.assertEqual(len(self.checkpoint()),2) + self.assertTrue(all(row['status']=='failed' and row['returncode']==7 for row in self.checkpoint())) + + def test_timeout_with_passed_json_is_not_resumed_as_success(self): + self.write_inputs('timeout') + self.assertEqual(self.run_main('--idle-seconds','.2'),1) + self.assertEqual(self.run_main('--idle-seconds','.2','--resume'),1) + self.assertEqual(len(self.checkpoint()),2) + self.assertTrue(all(row['status']=='timeout' and row['returncode']==124 for row in self.checkpoint())) + + def test_stale_scratch_result_cannot_satisfy_a_new_attempt(self): + old=self.out/'Example/output/result.json';old.parent.mkdir(parents=True) + old.write_text('{"passed":true,"statusCode":0}') + legacy='[{"case":"Example","returncode":0}]' + (self.out/'results.json').write_text(legacy) + self.write_inputs('no-result') + self.assertEqual(self.run_main('--resume'),1) + row=self.checkpoint()[0] + self.assertEqual(row['status'],'failed') + self.assertEqual(row['returncode'],1) + self.assertEqual(json.loads(old.read_text())['passed'],True) # Old evidence is preserved, not promoted. + backups=list(self.out.glob('results.legacy-*.json')) + self.assertEqual(len(backups),1) + self.assertEqual(backups[0].read_text(),legacy) + + def test_artifact_change_invalidates_a_successful_checkpoint(self): + self.assertEqual(self.run_main(),0) + self.library.write_bytes(b'library-v2') + self.assertEqual(self.run_main('--resume'),0) + self.assertEqual(len(self.checkpoint()),2) + self.assertNotEqual(self.checkpoint()[0]['identity'],self.checkpoint()[1]['identity']) + + def test_active_logs_outlive_old_local_catalog_and_manifest_timeout(self): + self.write_inputs('active') + self.assertEqual(self.run_main('--idle-seconds','.3','--max-seconds','4'),0) + row=self.checkpoint()[0] + self.assertGreater(row['seconds'],.6) # Both obsolete local timeouts are .05 seconds. + self.assertIsNone(row['timeout_kind']) + self.assertEqual(row['status'],'passed') + + def test_timeout_kills_and_reaps_a_child_that_ignores_term_after_parent_exits(self): + work=self.root/'group';work.mkdir() + pidfile=work/'child.pid' + child=f"import os,signal,time;from pathlib import Path;signal.signal(signal.SIGTERM,signal.SIG_IGN);Path({str(pidfile)!r}).write_text(str(os.getpid()));time.sleep(60)" + parent=f"import subprocess,sys,time;subprocess.Popen([sys.executable,'-c',{child!r}]);time.sleep(60)" + outcome=matrix.supervise([sys.executable,'-c',parent],dict(os.environ),str(work),work,work/'runner.log', + idle_seconds=1,max_seconds=4,poll_seconds=.05,terminate_grace=.1) + self.assertEqual(outcome['timeout_kind'],'idle') + self.assertEqual(outcome['remaining_processes'],[]) + pid=int(pidfile.read_text()) + self.assertFalse(Path('/proc',str(pid)).exists(),f'orphan/zombie {pid} survived cleanup') + + def test_case_environment_starts_from_fresh_base_and_owned_transport_wins(self): + args=types.SimpleNamespace(endpoint='tcp://127.0.0.1:9',token='x',credit=2) + first={'properties':[{'name':'ENVIRONMENT','value':['CASE_ONLY=one','MOBILEGL_IPC_CONTROL=tcp://stale:1']}]} + base={'BASE':'same'} + a=matrix.case_environment(first,base,args) + b=matrix.case_environment({'properties':[]},base,args) + self.assertEqual(a['CASE_ONLY'],'one') + self.assertNotIn('CASE_ONLY',b) + self.assertEqual(a['MOBILEGL_IPC_CONTROL'],args.endpoint) + self.assertEqual(base,{'BASE':'same'}) + + def test_current_manifest_default_and_explicit_non_ci_selection(self): + common={'trace_archive':'fixture.tgz','trace_file':'trace.trace','golden':'golden.png', + 'target_call':42,'width':16,'height':16} + self.write_inputs(extra_cases=[dict(common,name='Extra',ci=False),dict(common,name='OptOut',split=False)]) + self.assertEqual([c['name'] for c in matrix.select_cases(self.manifest,None)],['Example']) + self.assertEqual(matrix.select_cases(self.manifest,['Extra'])[0]['name'],'Extra') + with self.assertRaises(ValueError):matrix.select_cases(self.manifest,['OptOut']) + + +if __name__ == '__main__': + unittest.main(verbosity=2)