diff --git a/audit/history.jsonl b/audit/history.jsonl index 1dabc816..2ff0264f 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -498,3 +498,4 @@ {"time": "2026-09-23T21:40:00Z", "event": "port_round", "unit": "PORT-PLAN 批次 2P step 3c: staging python27.zip into the mobile sandbox", "ported": [], "adapted": [], "deleted": [], "divergent": [], "needs_live": [], "staging": {"ships": "make_stdlib_zip.py also writes .sha256; target mtpython_stdlib_project copy_if_different's python27.zip + .sha256 into project/ (gitignored), and both export presets' include_filter lists them so they enter the PCK", "runtime": "extension/src/python_stdlib.cpp (GDScript: Metin2Python.stdlib_path(force_stage)) — desktop uses ProjectSettings.globalize_path(res://python27.zip) in place; exported builds read the bytes out of the PCK, write user://python27.zip.part, verify the sha256 of the written file, then rename to user://python27.zip. An existing sandbox copy is reused only when it hashes to the shipped digest, so a torn first start or a new build re-stages.", "handoff": "PythonHost::SetDefaultStdLibPath() — DefaultStdLibPath() now answers with the staged path; $MT_PYTHON_STDLIB still wins. python_stdlib.cpp is the only unit that knows res:// / user://; port_platform stays godot-free.", "build_fix": "the Windows gate (MinGW, no mtpython) was broken by step 3b and is fixed here: port/CMakeLists.txt now also excludes UserInterface/StdAfx.h (it includes ScriptLib/StdAfx.h, as the original PCH does) and UserInterface/PythonPackModule.cpp, and platform/CMakeLists.txt excludes platform/ScriptLib/ the way port_logic already excluded port/ScriptLib/."}, "status_note": "On-device staging is not verified: the macOS test drives the mobile path with force_stage=true, and Android/iOS user:// sandboxes come with step 4. The zip's presence in an exported pack was checked with --export-pack, not with an APK. Nothing in the app's boot path calls Metin2Python.stdlib_path() yet — the interpreter only starts in the process with 2V0; today the test is its only caller.", "tests": ["project/python_stdlib_test.gd (godot --headless --path project --script python_stdlib_test.gd) -> PASS: in-place path on desktop; force_stage copies into user://, sha256 matches the shipped digest, bytes equal res://, no .part left, ZIPReader opens it and finds encodings/__init__.py and os.py; a second call does not re-copy (mtime unchanged); a deliberately corrupted sandbox copy is re-staged", "godot --headless --export-pack macOS lists python27.zip (6876547) and python27.zip.sha256 (65)"], "not_done": "真机验证(Android/iOS 进程内落盘 + system.py -> prototype.RunApp())随 2P step 4;Linux/Windows 见 step 4 表格", "commit": "2P-step3c"} {"time": "2026-09-23T22:20:00Z", "event": "scope_decision", "unit": "目标平台收窄为 macOS arm64 + Android arm64(Linux / Windows 不做)", "ported": [], "adapted": [], "deleted": [], "divergent": [], "needs_live": [], "decision": {"by": "user", "statement": "Linux 和 Windows 不考虑,目前主要支持 macOS arm64 和 Android arm64", "ios": "iOS arm64 保留为交叉编译干净的次要目标,不作为验收目标(我的判断,待用户确认)", "mingw": "仓库里的 mingw-w64 交叉编译只留作可移植性门禁——它这次抓到了 UserInterface/StdAfx.h 的真问题——不代表支持 Windows,不为它投入额外工作,也不阻塞任何步骤"}, "docs": ["docs/PORT-PLAN.md: 第 1 节新增范围段;批次 2A step 3 平台列表;批次 2P step 1/2 措辞;step 4 由「五个平台」改为「两个目标平台」,Linux/Windows 两行划掉并保留原因备查;总表 2P 行", "docs/PLATFORMS.md: 新增 Scope 段", "docs/THIRD-PARTY.md: CPython 的 Windows 段由「not built yet」改为 out of scope + 原因备查", "docs/PYTHON-EMBED-EVAL.md: 下一步 1/3 两条"], "tests": "仅文档改动,无代码改动;port_map.py check 0 error / 1 tag;参考 EterPack.cpp 的 8 个 pack key 值逐个 git grep --cached -w,均未入库", "not_done": "2P step 4 剩余项仍在:Android 真机 APK 进程内的 3c 沙盒落地,以及 app 进程里跑 system.py → prototype.RunApp()"} {"time": "2026-09-23T22:35:00Z", "event": "known_issue", "unit": "Android GBK 编解码挂起;优先保证 macOS 可用", "ported": [], "adapted": [], "deleted": [], "divergent": [], "needs_live": [], "decision": {"by": "user", "statement": "先不管这个问题了,先保证 macOS 能正常使用,Android 后期再想办法"}, "finding": {"symptom": "build-android.sh 默认 API 24 下 extension/src/net/text_codec.cpp 编译失败:use of undeclared identifier 'iconv_open' / 'iconv' / 'iconv_close'", "root_cause": "text_codec.cpp:10 用 __has_include() 探测。NDK 里该头在任何 API 等级都存在,但声明被 __BIONIC_AVAILABILITY_GUARD(28) 挡住,探测方式与 bionic 的可用性模型不匹配", "measured": "NDK 28.2.13676358 clang++ -fsyntax-only:--target=aarch64-linux-android24 报 3 处 undeclared;android28 通过", "why_api_bump_is_wrong": "NDK iconv.h 注释写明 bionic 只支持 utf8/ascii/usascii/utf16be/utf16le/utf32be/utf32le/wchart,没有 GB2312/GBK。升到 28 后 iconv_open(\"GB2312\",\"UTF-8\") 运行期返回 -1,encode_one()/decode_wire() 对所有输入都返回 false,连纯 ASCII 也被拒,比现有 #else 的 ASCII 回退分支(text_codec.cpp:104-127)更糟。附带成本:APK min_sdk 需 >=28;config/android/pyconfig.h 是 API 24 下 configure 的(HAVE_LANGINFO_H 因 nl_langinfo 需 API 26 被关掉),要重跑 gen_pyconfig.sh android 并重验内建模块表", "future_fix": "1) 探测改为 #if __has_include() && !defined(__ANDROID__),不要写 __ANDROID_API__ >= 28;2) Android 自带 GBK<->Unicode 表(约 22k 码位)。40250 原客户端走 Windows MultiByteToWideChar(CP949/CP936),跨平台无对应物;macOS 能用只是系统 libiconv 恰好带 GBK"}, "docs": ["docs/PORT-PLAN.md 新增「已知问题:Android 的 GBK 编解码(挂起,2026-09-23)」小节"], "tests": "仅文档改动;诊断由 NDK clang++ -fsyntax-only 双 API 等级实测得出", "not_done": "text_codec.cpp 未改动;Android 扩展构建目前仍是红的"} +{"time": "2026-09-23T23:30:00Z", "event": "test_fix", "unit": "net 测试去 flaky:net_classic_session_test + net_loopback_test", "ported": [], "adapted": [], "deleted": [], "divergent": [], "needs_live": [], "trigger": "macOS 全量 ctest 连续两次各挂 1 个 net 测试,且每次挂的不是同一个(第一次 net.loopback_flow,第二次 net.classic_session)", "root_causes": ["accept_loopback(): poll() 的 `<= 0` 把 EINTR 当成对端从未连接;且单片 500ms 超时在有负载时不够。50 轮压测里 4 次失败全部源于此", "net_classic_session_test.cpp:570 的 pump 循环条件抄自上面的死端口用例,但此处 connect 会成功,stage 先经过 LoggingIn,循环提前一步退出", "同文件 5 处手写 pump 循环只给 10-20ms 等真实 loopback socket(非阻塞 connect 完成 / connect 被拒 / 对端 FIN)", "net_loopback_test.cpp 10 处 run(..., N, []{return false;}) 是固定空转而非等待条件,等不够就断言失败"], "fixes": ["accept_loopback() 改为 EINTR 重试 + 5s 截止时间(listener 本就是非阻塞,超时后 accept 不会挂住)", "新增 pump_until(session, want, max_ms=2000),替换全部 5 处手写循环;等的是断言真正关心的那个状态", "替换 replacement PHASE_LOGIN 处的单次 pump(前一条 CHECK 已经断言过「不在同一帧」,这里只需要后续某次 pump 收到)", "net_loopback_test 的 10 处固定空转改为条件等待;服务端批量收包用该批最后一个包的标志作条件(TCP 有序,最后一个到了说明前面都到了),客户端事件用 decltype 声明的本地累加器在谓词里 drain"], "speedup": {"net_classic_session_test": "61.2s -> 3.5s:两处 this-*-host-must-not-resolve.invalid 换成带空标签的名字。本机解析器不按 RFC 6761 本地拒绝 .invalid,每次发往上游等满 30s 超时;空标签让 getaddrinfo 在语法层返回同一个 EAI_NONAME,实测 <10ms", "net_loopback_test": "固定空转改条件退出后单跑 0.94s", "ctest_total": "78.7s -> 8.7s"}, "tests": "net_classic_session_test 100/100(修复前 50 轮挂 4 轮);net_loopback_test 100/100(修复前单独跑第 7 轮即挂);全量 ctest 27/27;port_map.py check 0 error;参考 EterPack.cpp 的 8 个 pack key 值逐个 git grep --cached -w,均未入库", "not_done": "只改测试,未改产品代码。这些都是测试自身的计时/错误处理缺陷,不是 mtnet 的行为问题"} diff --git a/extension/tests/net_classic_session_test.cpp b/extension/tests/net_classic_session_test.cpp index bfaea929..d7b20f7a 100644 --- a/extension/tests/net_classic_session_test.cpp +++ b/extension/tests/net_classic_session_test.cpp @@ -4,6 +4,7 @@ #include "../src/net/classic/classic_session.h" #include +#include #include #include #include @@ -52,6 +53,21 @@ static std::vector drain(ClassicSession &s) { return drain_stream(s.stream()); } +// Pump until the session reaches `want`, or give up after `max_ms`. +// +// Every caller below is waiting on a real loopback socket: a nonblocking +// connect completing, a connect being refused, or a peer's FIN arriving. None +// of that is instantaneous, and the old hand-rolled loops gave it 10-20ms, +// which made these checks fail intermittently on a loaded machine. Waiting on +// the state the check is about (rather than on "still Connecting") also avoids +// stopping one step early when the stage passes through an intermediate owner. +static void pump_until(ClassicSession &s, mtnet::INetSession::Stage want, int max_ms = 2000) { + for (int i = 0; i < max_ms && s.stage() != want; ++i) { + s.pump(); + ::usleep(1000); + } +} + static std::string cjk_name(int count) { std::string value; for (int i = 0; i < count; ++i) { @@ -96,12 +112,29 @@ static int listen_loopback(uint16_t &port) { } static bool accept_loopback(int listener, int &accepted) { - pollfd pfd{listener, POLLIN, 0}; - if (::poll(&pfd, 1, 500) <= 0) { - return false; + // Two things made this the single largest source of flakiness in this suite. + // poll() returns -1/EINTR whenever a signal lands, which the old `<= 0` test + // counted as "the peer never connected"; and one flat 500ms slice is not + // always enough for a loopback connect on a loaded machine. Retry through + // EINTR and give the connect a real deadline instead. + constexpr int deadline_ms = 5000; + constexpr int slice_ms = 250; + for (int waited = 0; waited < deadline_ms;) { + pollfd pfd{listener, POLLIN, 0}; + const int rc = ::poll(&pfd, 1, slice_ms); + if (rc > 0) { + accepted = ::accept(listener, nullptr, nullptr); + return accepted >= 0; + } + if (rc < 0) { + if (errno == EINTR) { + continue; // interrupted, not expired + } + return false; + } + waited += slice_ms; } - accepted = ::accept(listener, nullptr, nullptr); - return accepted >= 0; + return false; } int main() { @@ -472,11 +505,7 @@ int main() { CHECK(auth_connect_failure.connect("127.0.0.1", dead_port, "127.0.0.1", 1, "admin", "secret"), "auth connect-failure fixture starts nonblocking connection"); - for (int i = 0; i < 20 && - auth_connect_failure.stage() == mtnet::INetSession::Stage::Connecting; ++i) { - auth_connect_failure.pump(); - ::usleep(1000); - } + pump_until(auth_connect_failure, mtnet::INetSession::Stage::Offline); CHECK(auth_connect_failure.stage() == mtnet::INetSession::Stage::Offline, "auth connect failure returns to the 40250 Offline owner"); uint16_t retry_port = 0; @@ -539,10 +568,16 @@ int main() { // CAccountConnector::Connect routes a synchronous socket/address failure // through OnConnectFailure and then __OfflineState_Set. It must be a // retryable Offline owner, not a terminal Failed stage. + // + // The unresolvable host has an empty label on purpose. getaddrinfo() rejects + // that on syntax and returns EAI_NONAME without asking a resolver; a ".invalid" + // name is supposed to be refused locally too (RFC 6761) but resolvers here hand + // it upstream and wait out the full 30s timeout, which is where this suite used + // to spend most of its wall clock. { ClassicSession auth_immediate_failure; const bool connected = auth_immediate_failure.connect( - "this-auth-host-must-not-resolve.invalid", 1, + "this-auth-host..must-not-resolve", 1, "127.0.0.1", 1, "admin", "secret"); CHECK(!connected, "invalid auth address reports a synchronous connect failure"); CHECK(auth_immediate_failure.stage() == mtnet::INetSession::Stage::Offline, @@ -567,10 +602,10 @@ int main() { if (accepted >= 0) { ::close(accepted); } - for (int i = 0; i < 20 && auth_close.stage() == mtnet::INetSession::Stage::Connecting; ++i) { - auth_close.pump(); - ::usleep(1000); - } + // Unlike the dead-port fixture above, this connect succeeds, so the + // stage goes Connecting -> LoggingIn first and only reaches Offline + // once the peer's FIN is read. + pump_until(auth_close, mtnet::INetSession::Stage::Offline); CHECK(auth_close.stage() == mtnet::INetSession::Stage::Offline, "auth peer close returns to the 40250 Offline owner"); auth_close.disconnect(); @@ -828,9 +863,14 @@ int main() { "auth success starts the replacement game transport without processing it in the same frame"); CHECK(replacement_phase_sent, "replacement game fixture sends PHASE_LOGIN before the handoff callback returns"); - retry_handoff.pump(); + // The check above already pinned down "not in the same frame". Here we + // only need a later pump to pick it up, and that needs the replacement + // transport's nonblocking connect to have completed and PHASE_LOGIN to + // have crossed the loopback — a single pump is not guaranteed to see + // both. + pump_until(retry_handoff, mtnet::INetSession::Stage::LoggingIn); CHECK(retry_handoff.stage() == mtnet::INetSession::Stage::LoggingIn, - "replacement PHASE_LOGIN is processed on the following pump"); + "replacement PHASE_LOGIN is processed on a following pump"); if (auth_connection >= 0) { ::close(auth_connection); @@ -1896,10 +1936,7 @@ int main() { if (accepted >= 0) { ::close(accepted); } - for (int i = 0; i < 10 && pre_game_close.stage() != mtnet::INetSession::Stage::LoggingIn; ++i) { - pre_game_close.pump(); - ::usleep(1000); - } + pump_until(pre_game_close, mtnet::INetSession::Stage::LoggingIn); CHECK(pre_game_close.stage() == mtnet::INetSession::Stage::LoggingIn, "peer close before PHASE_GAME returns to LoginPhase"); CHECK(!pre_game_close.phase_closed(), @@ -1931,10 +1968,7 @@ int main() { if (in_game_accepted >= 0) { ::close(in_game_accepted); } - for (int i = 0; i < 10 && in_game_close.stage() != mtnet::INetSession::Stage::LoggingIn; ++i) { - in_game_close.pump(); - ::usleep(1000); - } + pump_until(in_game_close, mtnet::INetSession::Stage::LoggingIn); CHECK(in_game_close.stage() == mtnet::INetSession::Stage::LoggingIn, "in-game peer close returns to LoginPhase"); CHECK(in_game_close.login_retry_pending(), @@ -2009,10 +2043,7 @@ int main() { // The connection may fail synchronously (ECONNREFUSED) or on the // first non-blocking pump; both paths must reach LoginPhase. direct_failure.connect_direct_enter(0); - for (int i = 0; i < 10 && direct_failure.stage() != mtnet::INetSession::Stage::LoggingIn; ++i) { - direct_failure.pump(); - ::usleep(1000); - } + pump_until(direct_failure, mtnet::INetSession::Stage::LoggingIn); CHECK(direct_failure.stage() == mtnet::INetSession::Stage::LoggingIn, "DirectEnter connect failure returns to LoginPhase"); CHECK(direct_phase_close, @@ -2059,7 +2090,7 @@ int main() { connect_failure_reason = reason; }; CHECK(ordinary_failure.connect("127.0.0.1", auth_port, - "this-game-host-must-not-resolve.invalid", 1, "admin", "secret"), + "this-game-host..must-not-resolve", 1, "admin", "secret"), "ordinary game-connect failure fixture opens the auth transport"); int accepted = -1; CHECK(accept_loopback(auth_listener, accepted), diff --git a/extension/tests/net_loopback_test.cpp b/extension/tests/net_loopback_test.cpp index 8ab0b639..df340e36 100644 --- a/extension/tests/net_loopback_test.cpp +++ b/extension/tests/net_loopback_test.cpp @@ -760,7 +760,10 @@ int main() { // --- CG_CHANGE_NAME -> GC_CHANGE_NAME updates the local slot list --- CHECK(game.change_name(0, "Renamed"), "game: change_name sent"); - run(srv, game, 3000, [] { return false; }); + run(srv, game, 3000, [&] { + return srv.m_change_name_seen && game.chars().size() == 1 && + game.chars()[0].name == "Renamed"; + }); CHECK(srv.m_change_name_seen && srv.m_last_change_name.index == 0, "game: server got CG_CHANGE_NAME idx=0"); CHECK(std::string(srv.m_last_change_name.name) == "Renamed", @@ -853,41 +856,65 @@ int main() { run(srv, game, 5000, [&] { return game.in_game(); }); CHECK(game.in_game(), "game: reached PHASE_GAME"); CHECK(srv.m_selected == 0, "game: server saw our selection"); - run(srv, game, 200, [] { return false; }); - CHECK(game.drain_guild_make_requests() == 1, + int guild_make_requests = 0; + run(srv, game, 3000, [&] { + guild_make_requests += game.drain_guild_make_requests(); + return guild_make_requests >= 1; + }); + CHECK(guild_make_requests == 1, "game: GC_REQUEST_MAKE_GUILD surfaced as an event"); CHECK(game.send_warp(), "game: CG_WARP sent"); - run(srv, game, 200, [] { return false; }); + decltype(game.world().drain_warps()) warp_cues; + run(srv, game, 3000, [&] { + auto v = game.world().drain_warps(); + warp_cues.insert(warp_cues.end(), v.begin(), v.end()); + return srv.m_warp_seen && !warp_cues.empty(); + }); CHECK(srv.m_warp_seen, "game: server got CG_WARP"); { - auto warps = game.world().drain_warps(); + auto &warps = warp_cues; CHECK(!warps.empty() && warps.back().x == 4321 && warps.back().y == 8765, "game: GC_WARP destination surfaced"); CHECK(game.world().get(9902) == nullptr, "game: GC_WARP drops pipelined old-socket tail"); } CHECK(game.send_fishing(36), "game: CG_FISHING sent"); - run(srv, game, 200, [] { return false; }); + decltype(game.world().drain_fishing_events()) fishing_events; + run(srv, game, 3000, [&] { + auto v = game.world().drain_fishing_events(); + fishing_events.insert(fishing_events.end(), v.begin(), v.end()); + return srv.m_fishing_seen && !fishing_events.empty(); + }); CHECK(srv.m_fishing_seen && srv.m_last_fishing_dir == 36, "game: server got CG_FISHING direction"); { - auto fishing = game.world().drain_fishing_events(); + auto &fishing = fishing_events; CHECK(!fishing.empty() && fishing.back().subheader == FISHING_START && fishing.back().info == 1000 && fishing.back().dir == 36, "game: GC_FISHING event surfaced"); } CHECK(game.send_dungeon(), "game: CG_DUNGEON sent"); - run(srv, game, 200, [] { return false; }); + decltype(game.world().drain_dungeon_events()) dungeon_events; + run(srv, game, 3000, [&] { + auto v = game.world().drain_dungeon_events(); + dungeon_events.insert(dungeon_events.end(), v.begin(), v.end()); + return srv.m_dungeon_seen && !dungeon_events.empty(); + }); CHECK(srv.m_dungeon_seen, "game: server got CG_DUNGEON"); { - auto dungeons = game.world().drain_dungeon_events(); + auto &dungeons = dungeon_events; CHECK(!dungeons.empty() && dungeons.back().subheader == DUNGEON_DESTINATION_POSITION && dungeons.back().has_destination && dungeons.back().x == 3210 && dungeons.back().y == 6540, "game: GC_DUNGEON destination surfaced"); } // game-phase packets flowed into the EntityStore - run(srv, game, 200, [] { return false; }); // drain a few more polls + // The game-phase burst is what these checks read; wait for its last field + // instead of hoping a fixed number of polls was enough. + run(srv, game, 3000, [&] { + return game.world().main_vid() == 1000 && game.world().get(1000) != nullptr && + srv.m_client_version_seen; + }); mtnet::EntityStore &wm = game.world(); const EntityStore &w = wm; CHECK(w.main_vid() == 1000, "world: main vid from GC_MAIN_CHARACTER"); @@ -926,7 +953,7 @@ int main() { CHECK(game.send_script_select_item(/*selection=*/91), "send_script_select_item"); CHECK(game.send_quest_cancel(), "send_quest_cancel"); CHECK(game.send_chat(1, "hello"), "send_chat"); - run(srv, game, 300, [] { return false; }); + run(srv, game, 3000, [&] { return srv.m_last_chat == "hello"; }); CHECK(srv.m_move_count == 1 && srv.m_last_move.func == FUNC_MOVE && srv.m_last_move.rot == 18 && srv.m_last_move.x == 12345 && srv.m_last_move.y == 67890 && srv.m_last_move.time == 111, @@ -976,7 +1003,7 @@ int main() { CHECK(game.send_item_use_to_item(WINDOW_INVENTORY, 14, WINDOW_EQUIPMENT, 90), "send_item_use_to_item"); CHECK(game.send_give_item(2000, WINDOW_INVENTORY, 15, 2), "send_give_item"); CHECK(game.send_item_pickup(5000), "send_item_pickup"); - run(srv, game, 300, [] { return false; }); + run(srv, game, 3000, [&] { return srv.m_last_pickup_vid == 5000; }); CHECK(srv.m_item_move_count == 1 && srv.m_last_item_move.pos.window_type == WINDOW_INVENTORY && srv.m_last_item_move.pos.cell == 5 && srv.m_last_item_move.change_pos.window_type == WINDOW_EQUIPMENT @@ -1001,7 +1028,7 @@ int main() { CHECK(game.send_shop_buy(/*pos=*/3, /*count=*/2), "send_shop_buy"); CHECK(game.send_exchange_start(2000), "send_exchange_start"); CHECK(game.send_safebox_checkin(/*safe_pos=*/7, WINDOW_INVENTORY, 9), "send_safebox_checkin"); - run(srv, game, 300, [] { return false; }); + run(srv, game, 3000, [&] { return srv.m_last_safebox_checkin_pos == 7; }); CHECK(srv.m_last_party_invite_vid == 2000, "server got CG_PARTY_INVITE{2000}"); CHECK(srv.m_party_skill_count == 1 && srv.m_last_party_skill.skill_index == 4 && srv.m_last_party_skill.target_vid == 2000, @@ -1036,7 +1063,7 @@ int main() { shop_items.push_back(si); CHECK(game.send_open_private_shop("Cheap swords", shop_items), "send_open_private_shop"); - run(srv, game, 300, [] { return false; }); + run(srv, game, 3000, [&] { return srv.m_myshop_seen; }); CHECK(srv.m_mall_checkout_seen && srv.m_last_mall_checkout.mall_pos == 0 && srv.m_last_mall_checkout.inv_pos.cell == 11, "server got CG_MALL_CHECKOUT{mall=0,inv=11}");