mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[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:
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 来满足错误的负控预期。
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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());
|
||||
|
||||
@@ -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*` 报告里,不在这里重复。规范契约见
|
||||
|
||||
@@ -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 门。
|
||||
@@ -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`。
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user