[MG_Remote, MG_Pipe, MG_Test, CI, tools, docs] (P6.5): the first wave completed and reviewed - archive v2, publication boundary, half-close, AcceptPair/role fixes, device re-validation

This commit is contained in:
2026-09-22 10:56:19 -04:00
parent 018fab0f5b
commit fe28bdb5f3
75 changed files with 4887 additions and 254 deletions
+10 -1
View File
@@ -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
@@ -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)
+8
View File
@@ -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;
@@ -11,6 +11,8 @@
#if !defined(__ANDROID__)
#include <MG_State/GLState/BufferState/BufferObject.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <Config.h>
#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;
@@ -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
@@ -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
@@ -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`).
@@ -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<MobileGL::Uint64>(resets), static_cast<MobileGL::Uint64>(serial),
static_cast<MobileGL::Uint64>(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);
@@ -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<long long>(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<double>(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());
+2
View File
@@ -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);
+4
View File
@@ -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,
+60
View File
@@ -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:<path>` 或 `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 写自己的 `<base>.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 来满足错误的负控预期。
+3
View File
@@ -36,6 +36,7 @@
#include "Protocol/mg_protocol_base.h"
#include <MG_Pipe/PipeWireLayout.h>
#include <MG_State/GLState/ProgramState/ProgramArtifactsCodec.h>
#include <MGGitHash.h>
#include <MG_Util/Debug/Log.h>
@@ -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<Uint64>(MG_Pipe::MGPWireOp::kOpCount);
inputs.AbiVersion = MOBILEGL_ABI_VERSION(MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR);
inputs.PointerBits = sizeof(void*) * 8;
+109 -24
View File
@@ -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<std::mutex> 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<ClientSession*>(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<ClientSession*>(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<Uint8> 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<int>(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<std::chrono::milliseconds>(
deadline - std::chrono::steady_clock::now()).count();
if (left <= 0) break;
std::vector<Uint8> finalFrame;
const auto result = ReceiveControlFrame(finalFrame, static_cast<Uint32>(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<Uint32>(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<int>(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);
@@ -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.
+39 -8
View File
@@ -44,6 +44,7 @@
// is legal under rule E (CONTRACT-P5C.md §5.2).
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_State/GLState/ProgramState/ProgramArtifactsCodec.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
#include <MG_Util/Debug/Log.h>
// 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() {
+54 -10
View File
@@ -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 <memory>
#include <string>
#include <vector>
#include <chrono>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
@@ -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<std::uint8_t> hello(static_cast<std::size_t>(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<FlushAck*>(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<unsigned long long>(applied),
static_cast<unsigned long long>(verbs.ApplierResets()),
static_cast<unsigned long long>(verbs.ExpectedApplierResetSerial()),
static_cast<unsigned long long>(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<unsigned>(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<int>(::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<SocketTransport> 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);
@@ -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();
}
@@ -74,4 +74,17 @@ namespace MobileGL::MG_Remote::Transport {
std::lock_guard<std::mutex> lock(m_mutex);
return m_frames.empty() ? 0 : m_frames.front().size();
}
bool ControlInbox::WaitClosed(std::uint32_t timeoutMs) {
std::unique_lock<std::mutex> 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;
}
}
}
@@ -17,6 +17,7 @@ namespace MobileGL::MG_Remote::Transport {
void Stop();
MobileGLResult Receive(std::vector<std::uint8_t>& frame, std::uint32_t timeoutMs);
std::uint64_t Peek();
bool WaitClosed(std::uint32_t timeoutMs);
private:
void Read();
ITransport& m_transport;
+5 -2
View File
@@ -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
@@ -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<unsigned long long>(serial), valid ? 1u : 0u,
static_cast<unsigned long long>(valid ? wall - previousWall : 0),
static_cast<unsigned long long>(valid ? cpu - previousCpu : 0));
previousSerial = serial; previousWall = wall; previousCpu = cpu;
}
}
@@ -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);
}
+2
View File
@@ -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);
@@ -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;
+1 -1
View File
@@ -319,7 +319,7 @@ namespace MobileGL::MG_Remote::Transport {
}
m_private[i] = ::operator new(static_cast<std::size_t>(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<std::size_t>(bytes[i]));
m_mappedBytes += bytes[i];
@@ -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<std::mutex> 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*,
@@ -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<bool> m_closed{false};
std::atomic<bool> m_readClosed{false};
bool m_failed = false; // framing violated: latched, never recovers
};
+96 -47
View File
@@ -15,6 +15,7 @@
#if !defined(_WIN32)
#include <cerrno>
#include <sys/socket.h>
#include <sys/uio.h>
#include <unistd.h>
#if defined(__linux__)
#include <pthread.h>
@@ -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<std::mutex> 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<const std::uint8_t*>(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<std::size_t>(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<std::uint32_t>(size));
Put32(h + 4, static_cast<std::uint32_t>(kDataEnvelopeBytes + size));
h[8] = static_cast<std::uint8_t>(kind);
Put64(h + 12, a);
Put64(h + 20, b);
return Write(h, sizeof h) && (!size || Write(payload, static_cast<std::size_t>(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<void*>(payload), static_cast<std::size_t>(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<std::size_t>(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<std::uint8_t*>(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<std::uint8_t*>(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<std::uint8_t>& 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<std::uint8_t*>(base) + a % capacity, p.data(), p.size());
received += p.size();
std::memcpy(static_cast<std::uint8_t*>(base) + a % capacity, payload, static_cast<std::size_t>(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<std::uint32_t>(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<std::uint8_t> 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<std::uint64_t>(remaining, sizeof chunk);
if (!Read(chunk, static_cast<std::size_t>(count))) return;
if (frames.Feed(chunk, count) != MOBILEGL_OK) Corrupt("invalid data frame payload");
remaining -= count;
}
std::vector<std::uint8_t> p;
if (frames.TakeMessage(p) != MOBILEGL_OK) Corrupt("incomplete data frame");
const auto* payload = p.data() + kDataEnvelopeBytes;
if (kind == Stage) {
std::memcpy(static_cast<std::uint8_t*>(memory->StageBase()) + a, p.data(), size);
std::memcpy(static_cast<std::uint8_t*>(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<std::uint32_t>(Get64(v + 48)), std::memory_order_release);
C().eventDropped.store(static_cast<std::uint32_t>(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<std::mutex> 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<std::int32_t>(static_cast<std::uint32_t>(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<std::uint8_t*>(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<std::mutex> 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;
+22 -5
View File
@@ -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<SizeT>(size));
const Uint64 offset = static_cast<Uint64>(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
+18
View File
@@ -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<bool>* 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<bool>* 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;
@@ -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<Uint8> 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.
@@ -13,6 +13,7 @@
#include <bit>
#include <cstring>
#include <set>
#include <limits>
#include <string>
#include <type_traits>
#include <vector>
@@ -70,6 +71,88 @@ namespace MobileGL::MG_State::GLState {
template <class T>
concept ArchiveUniformInitializer = std::same_as<T, UniformInitializer>;
// glslang supplies no VisitFields table for this aggregate. Keep its one explicit
// table shared by the payload writer, reader and wire-schema walk.
template <class Self, class Visitor>
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<unsigned char>(*text);
hash *= 1099511628211ull;
} while (*text++ != '\0');
}
template <class Value>
void DescribeWireType(Uint64& hash) {
using T = std::remove_cv_t<Value>;
if constexpr (ArchiveScalar<T>) {
// 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<T>) {
SchemaName(hash, "enum");
DescribeWireType<std::underlying_type_t<T>>(hash);
} else {
SchemaName(hash, std::is_same_v<T, bool> ? "bool" :
std::is_floating_point_v<T> ? "ieee-float" :
std::is_signed_v<T> ? "signed" : "unsigned");
SchemaWord(hash, sizeof(T));
if constexpr (std::is_floating_point_v<T>) {
static_assert(std::numeric_limits<T>::is_iec559);
SchemaWord(hash, std::numeric_limits<T>::digits);
SchemaWord(hash, std::numeric_limits<T>::max_exponent);
}
}
} else if constexpr (ArchiveString<T>) {
SchemaName(hash, "string-u64-count-byte-content");
} else if constexpr (ArchiveMap<T>) {
SchemaName(hash, "map-u64-count");
DescribeWireType<typename T::key_type>(hash);
DescribeWireType<typename T::mapped_type>(hash);
} else if constexpr (ArchiveSet<T>) {
SchemaName(hash, "set-u64-count");
DescribeWireType<typename T::value_type>(hash);
} else if constexpr (ArchiveFixedArray<T>) {
SchemaName(hash, "fixed-array");
SchemaWord(hash, std::tuple_size<T>::value);
DescribeWireType<typename T::value_type>(hash);
} else if constexpr (ArchiveVector<T>) {
SchemaName(hash, "vector-u64-count");
DescribeWireType<typename T::value_type>(hash);
} else {
SchemaName(hash, ArchiveUniformInitializer<T> ? "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<std::remove_cvref_t<decltype(member)>>(hash);
};
if constexpr (ArchiveUniformInitializer<T>) VisitUniformInitializer(value, field);
else VisitFields(value, field);
SchemaWord(hash, fields);
SchemaName(hash, "end-record");
}
}
#endif
// ---- the writer ----
template <class T>
@@ -117,6 +200,11 @@ namespace MobileGL::MG_State::GLState {
} else if constexpr (ArchiveVector<T>) {
WriteSequence(out, value);
} else if constexpr (ArchiveUniformInitializer<T>) {
#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<T>) {
#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<LinkArtifacts>(hash);
DescribeWireType<SpirvArtifacts>(hash);
return hash == 0 ? Uint64{1} : hash;
}();
return fingerprint;
}
#endif
void EncodeProgramArtifacts(const LinkArtifacts& link, const SpirvArtifacts& spirv,
Vector<Uint8>& 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<glslang::TProgram>: 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);
@@ -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);
+11
View File
@@ -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
@@ -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<glslang::TProgram>, which is
@@ -32,6 +32,7 @@
// stays name-for-name identical between the pull and the push trees.
#include <gtest/gtest.h>
#include <cstring>
#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<Uint8> 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<Uint8> 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<Uint8> 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
}
+13 -1
View File
@@ -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 ()
+18
View File
@@ -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<std::uint8_t> 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<std::uint8_t> 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);
}
@@ -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 <MG_Remote/Client/ClientSession.h>
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Transport/ControlInbox.h>
#include <MG_Remote/Transport/SocketTransport.h>
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <thread>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
using namespace MobileGL;
using namespace MobileGL::MG_Remote;
namespace {
template <class Tag, typename Tag::Type member>
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<StartupControlTag, &Client::ClientSession::m_transport>;
struct StartupInboxTag {
using Type = std::unique_ptr<Transport::ControlInbox> Client::ClientSession::*;
friend Type StartupPeerMember(StartupInboxTag);
};
template struct StartupPeerAccess<StartupInboxTag, &Client::ClientSession::m_controlInbox>;
class InitialCapsStartup : public testing::Test {
protected:
Client::ClientSession client;
std::unique_ptr<Transport::SocketTransport> controlClient, controlPeer;
std::unique_ptr<Transport::ILink> 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<sockaddr*>(&address), sizeof address), 0);
socklen_t length = sizeof address;
ASSERT_EQ(::getsockname(reserve, reinterpret_cast<sockaddr*>(&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<Transport::ControlInbox>(*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<Uint8> encodedFormats, encodedRenderer;
if (!EncodeFormatCapabilities(formats, encodedFormats) || !EncodeRendererInfo(renderer, encodedRenderer))
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
flatbuffers::FlatBufferBuilder builder;
const auto parameters = builder.CreateVector(reinterpret_cast<const Uint8*>(&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<Uint32>(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<bool> 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<void>{});
::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
+270
View File
@@ -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 <Init.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Remote/Client/ClientSession.h>
#include <MG_Remote/Transport/Doorbell.h>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include <thread>
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
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<unsigned>(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<char*>(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<std::chrono::milliseconds>(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<long long>(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<Uint32>(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::milliseconds>(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<long long>(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<long long>(hangupMs),
static_cast<long long>(lostMs), options.deadlineMs, reset);
std::fflush(stdout);
return 0;
}
+14 -7
View File
@@ -74,9 +74,12 @@ struct BackendTag {
};
template struct PeerAccess<BackendTag, &Srv::ServerLoop::m_backend>;
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{};
@@ -40,7 +40,7 @@ void CheckE1Boundary(Bool serverCap, MGPWireOp op, Bool expectWait, const char*
const Uint64 caps = serverCap ? static_cast<Uint64>(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();
+3 -18
View File
@@ -2338,19 +2338,8 @@ void StartRunAheadSession() {
Srv::ServerSessionInstance().SetCapabilityBits(
static_cast<Uint64>(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<Uint64>(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<Uint64>(MG_Pipe::kCapRunAheadApply));
Srv::ServerSessionInstance().SetConsumedSubsystems(kMGPipeSubsystemsMigratedAtP4a);
MG_Pipe::MGPCaps caps{};
caps.CallMask = static_cast<Uint64>(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);
}
@@ -41,6 +41,7 @@
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Handshake.h>
#include <MG_Pipe/PipeWireLayout.h>
#include <MG_State/GLState/ProgramState/ProgramArtifactsCodec.h>
#include <MG_Remote/Client/ClientSession.h>
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <MG_Remote/Server/ServerSession.h>
@@ -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<Uint64>(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";
@@ -174,6 +174,21 @@ TEST(SocketTransportTest, CarriesFramesInBothDirections) {
EXPECT_EQ(Receive(*pair.client), welcome);
}
TEST(SocketTransportTest, RequestHalfCloseStillDeliversTheFinalReply) {
std::unique_ptr<SocketTransport> 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();
@@ -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 <MG_Remote/Client/ClientSession.h>
#include <MG_Remote/CapsCodec.h>
#include <MG_Remote/Transport/InProcessTransport.h>
#include <MG_Remote/Transport/StreamLink.h>
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <gtest/gtest.h>
#include <sys/socket.h>
#include <unistd.h>
#include <atomic>
#include <MG_Pipe/PipeRoute.h>
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <chrono>
#include <thread>
#include <csignal>
#include <sys/wait.h>
#include <vector>
#include <cstring>
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 <class Tag, typename Tag::Type member>
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<ControlEndpointTag, &Client::ClientSession::m_transport>;
class StreamClientPublication : public testing::Test {
protected:
Client::ClientSession client;
std::unique_ptr<Transport::ILink> peer;
std::unique_ptr<Transport::InProcessTransport> 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<Uint8> encodedFormats, encodedRenderer;
ASSERT_TRUE(EncodeFormatCapabilities(formats, encodedFormats));
ASSERT_TRUE(EncodeRendererInfo(renderer, encodedRenderer));
flatbuffers::FlatBufferBuilder builder;
const auto dynamicBytes = builder.CreateVector(reinterpret_cast<const Uint8*>(&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<Uint32>(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<Uint8> bytes(count, fill);
MG_Pipe::MGPSubData record{};
record.Res = {1, 1};
record.Target = MG_Pipe::kMGPipeResourceTargetBuffer;
record.UnionBox.W = static_cast<Uint32>(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<const Uint8*>(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<std::uint16_t>(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<std::uint16_t>(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<const void*>(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<Transport::ILink> 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<bool> 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
+121 -5
View File
@@ -8,6 +8,9 @@
#include <thread>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <cstdio>
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<bool> 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<sockaddr*>(&address), sizeof address), 0);
ASSERT_EQ(::listen(listener, 1), 0);
socklen_t addressBytes = sizeof address;
ASSERT_EQ(::getsockname(listener, reinterpret_cast<sockaddr*>(&address), &addressBytes), 0);
int clientFd = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(clientFd, 0);
ASSERT_EQ(::connect(clientFd, reinterpret_cast<sockaddr*>(&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<ILink> 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<std::size_t>(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<const unsigned char*>(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<double>(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<unsigned long long>(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);
@@ -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());
+25 -1
View File
@@ -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*` 报告里,不在这里重复。规范契约见
+6 -4
View File
@@ -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://'
@@ -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`(含本次两修复)。
@@ -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。生产预算不受该单测缩短。
@@ -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 已清理。
@@ -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;不能把旧原因直接套用到本次。
@@ -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 门。
+184
View File
@@ -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/<pid>/task/<tid>/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 <tree>/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`
- 各目录的 `<transport>/<case>/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` 的归约逻辑。
@@ -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/<pid>/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 <tree>/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/<pid>/task/<tid>/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。
@@ -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`。
+9 -2
View File
@@ -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
+80
View File
@@ -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<Transport::StreamLink>();"),
(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())
+29
View File
@@ -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")
+45
View File
@@ -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):
+7
View File
@@ -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)
+11 -5
View File
@@ -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:
+7 -6
View File
@@ -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
+107
View File
@@ -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
```
+142
View File
@@ -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())
+139
View File
@@ -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()
@@ -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()
+215
View File
@@ -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/<pid>/task/<tid>/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/<pid>/task/<tid>/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())
+260
View File
@@ -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())
+495
View File
@@ -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())
+57 -1
View File
@@ -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/<serial>-<package>.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':
@@ -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()
+211
View File
@@ -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)