Просмотр исходного кода

fix(apilot): host recv robustness + enter_game step + retry tails

wuzilong 1 месяц назад
Родитель
Сommit
4502e2f43a
2 измененных файлов с 90 добавлено и 29 удалено
  1. 37 29
      apilot/host.py
  2. 53 0
      tests/test_host_unit.py

+ 37 - 29
apilot/host.py

@@ -4,6 +4,7 @@ from __future__ import annotations
 import json
 import os
 import random
+import socket
 import sys
 import time
 from typing import Any
@@ -11,7 +12,7 @@ from typing import Any
 ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 sys.path.insert(0, os.path.join(ROOT, "protocol_bot"))
 
-import requests as rq  # noqa: E402
+import requests  # noqa: E402
 import login_996pc as L  # type: ignore  # noqa: E402
 import cq2_pure_enter as P  # type: ignore  # noqa: E402
 from encode6bitbuf_final import encode6, decode6  # type: ignore  # noqa: E402
@@ -42,6 +43,7 @@ class Host:
         self.jwt = ""
         self.uid = ""
         self._login_port = 0
+        self._tail = 0
 
     def build_2001_body(self, uid: str, jwt: str) -> bytes:
         return (f"{uid}/".encode() + encode6(jwt.encode()) + b"/" +
@@ -52,8 +54,8 @@ class Host:
                            "job": job, "sex": sex, "account": uid,
                            "sdkid": CHANNEL}, ensure_ascii=False).encode()
 
-    def http_login(self) -> dict:
-        s = rq.Session()
+    def http_login(self) -> requests.Session:
+        s = requests.Session()
         j, _ = L.do_login(s, self.user, self.password)
         if j.get("code") != 200:
             raise SystemExit(f"[!] 登录失败: {j.get('msg')}")
@@ -61,7 +63,7 @@ class Host:
         self.uid = str((j["data"].get("userInfo") or {}).get("id") or "")
         self.env.data.update({"uid": self.uid, "token": self.jwt,
                               "new_boxtoken": self.jwt, "isBoxLogin": 1})
-        return j
+        return s
 
     def boot_lua(self) -> None:
         self.lua = bootstrap_runtime(self.nc, self.ctl, self.sched, self.env, self.state_dir)
@@ -125,32 +127,33 @@ class Host:
                     break
             except (socket.timeout, ConnectionResetError, OSError):
                 d = b""
-            consumed = 0
-            found_frame: Frame | None = None
-            found_pre: int | None = None
-            while consumed + 48 <= len(d):
+            # 半包处理: 每轮把 wire._buf 前置到 data 再扫描, 扫剩的写回 _buf。
+            data = wire._buf + d
+            wire._buf = b""
+            while len(data) >= 48:
                 try:
-                    m = struct.unpack("<9i", decode6(d[consumed:consumed + 48]))
+                    m = struct.unpack("<9i", decode6(data[:48]))
                 except Exception:
                     break
                 blen = m[7]
+                if len(data) < 48 + blen:  # 帧体不完整, 整帧留待下轮
+                    break
                 if m[1] == target_mid:
-                    found_frame = Frame(recog=m[2], msg_id=m[1], p1=m[3], p2=m[4],
-                                        p3=m[5], tail=m[8],
-                                        body=d[consumed + 48:consumed + 48 + blen])
-                    found_pre = m[0]
-                    after = d[consumed + 48 + blen:]
-                    wire._buf = wire._buf + d[:consumed] + after
-                    return found_frame, found_pre
-                consumed += 48 + blen
-            wire._buf = wire._buf + d
+                    wire._buf = data[48 + blen:]
+                    return Frame(recog=m[2], msg_id=m[1], p1=m[3], p2=m[4],
+                                 p3=m[5], tail=m[8],
+                                 body=data[48:48 + blen]), m[0]
+                # 未命中帧 → 丢弃并继续扫下一帧(一次收到多帧的场景)
+                data = data[48 + blen:]
+            wire._buf = wire._buf + data
         return None, None
 
     def run_create_role(self, name: str | None = None, timeout: float = 30) -> dict:
         """登录链逐帧驱动(对照 cq2_pure_enter.login_gate 唯一真源,逐帧注释)。
 
         2001(tail=0,recog=random) → 等 529 取 key32(decode6 split /) →
-        104(body=encode6(key32),tail=1) → 等 530 取 sid(recog 字段,cq2_pure_enter:138) →
+        104(body=encode6(key32),tail=1) → 等 530 取 sid(m[0]/pre 字段,
+        cq2_pure_enter:138) →
         nc.set_sdata(sid) → 100(uid/sid/zone/channel/gameid/key32,tail=2) →
         等 520 角色 JSON(decode6 后 json.loads) →
         角色列表含 name→复用;否则 101(tail=3) → 等 521(decode6 后 split /)
@@ -201,9 +204,11 @@ class Host:
             name = "萌新" + str(random.randint(1000, 9999))
         chr_info = next((c for c in (roles or {}).get("list", [])
                           if c.get("chr") == name), None)
-        retries = 0
-        while chr_info is None and retries <= 5:
-            self._send(101, 0, 0, 0, sid, 3,
+        self._tail = 2
+        attempts = 0
+        while chr_info is None and attempts < 5:
+            self._tail += 1
+            self._send(101, 0, 0, 0, sid, self._tail,
                        self.build_101_body(name, self.uid))
             ticket: str | None = None
             got_522 = False
@@ -216,17 +221,17 @@ class Host:
                             ticket = decode6(f.body).decode("utf-8", "replace")
                         except Exception:
                             ticket = f.body.decode("utf-8", "replace")
-                    if f.msg_id == 522:
+                    if ticket is None and f.msg_id == 522:
                         got_522 = True
                 if ticket or got_522:
                     break
-            if got_522:
-                retries += 1
-                name = "萌新" + str(random.randint(1000, 9999))
-                continue
             if ticket:
                 chr_info = {"chr": name, "UserID": ticket.split("/", 1)[1]}
                 break
+            if got_522:
+                attempts += 1
+                name = "萌新" + str(random.randint(1000, 9999))
+                continue
             break
         if chr_info is None:
             lst = (roles or {}).get("list") or []
@@ -240,9 +245,12 @@ class Host:
 
 def run_zone(zone_id: int, user: str, password: str, name: str | None = None,
              port: int = 8041) -> dict:
-    """一站式: HTTP 登录 → 网关连接 → 创角 → 返回票据。"""
+    """一站式: HTTP 登录 → enter_game → 网关连接 → 创角 → 返回票据。"""
     h = Host(zone_id, user, password)
-    h.http_login()
+    session = h.http_login()
+    j2 = P.enter_game(session, h.jwt, zone_id)
+    if j2.get("code") != 200:
+        raise SystemExit(f"[!] enter_game 失败: {j2.get('msg') or j2}")
     h.connect_login_gate(port=port)
     info = h.run_create_role(name)
     return {"uid": h.uid, "jwt": h.jwt, "zone": h.zone, **info}

+ 53 - 0
tests/test_host_unit.py

@@ -1,6 +1,9 @@
 # tests/test_host_unit.py
 """Task 7: host.py — 登录链拼包纯函数(零真实网络)。"""
 import json
+import socket
+import time
+from types import SimpleNamespace
 
 from apilot.host import Host
 
@@ -26,3 +29,53 @@ def test_create_role_101_body(tmp_path):
     assert body["server_name"] == 14481607
     assert body["account"] == "u1" and body["job"] == 0 and body["sex"] == 0
     assert body["secsdk"] == "0"
+
+
+def test_recv_with_pre_silent_server(tmp_path):
+    """B-1 回归: 静默服务端 1s → _recv_with_pre 不抛 NameError,
+    超时后正常返回 (None, None)。"""
+    h = Host(14481607, "u", "p", state_dir=str(tmp_path))
+    a, b = socket.socketpair()
+    fake = SimpleNamespace(sock=a, _buf=b"")
+    h.nc.wire = fake
+    a.settimeout(1.0)
+    try:
+        start = time.time()
+        frame, pre = h._recv_with_pre(530, 1.0)
+        assert frame is None and pre is None
+        assert time.time() - start >= 0.9
+    finally:
+        b.close()
+        a.close()
+
+
+def test_recv_with_pre_fragments_across_buf(tmp_path):
+    """M-1 回归: 530 帧头 48 字节被 TCP 分片、前半留在 wire._buf,
+    下一轮 _buf+新数据重组后仍能命中; sid 取 m[0] 而非 recog。"""
+    import struct
+    import sys
+    sys.path.insert(0, "protocol_bot")
+    from encode6bitbuf_final import encode6  # type: ignore
+    h = Host(14481607, "u", "p", state_dir=str(tmp_path))
+    a, b = socket.socketpair()
+    fake = SimpleNamespace(sock=a, _buf=b"")
+    h.nc.wire = fake
+    a.settimeout(1.0)
+    m = (777, 530, 888, 0, 0, 0, 0, 0, 0)
+    pkt = encode6(struct.pack("<9i", *m))
+    assert len(pkt) == 48
+    b.sendall(pkt[:20])
+    time.sleep(0.15)
+    frame, pre = h._recv_with_pre(530, 1.0)
+    assert frame is None and pre is None
+    assert fake._buf == pkt[:20]
+    b.sendall(pkt[20:])
+    try:
+        frame, pre = h._recv_with_pre(530, 1.0)
+        assert frame is not None and frame.msg_id == 530
+        assert frame.recog == 888
+        assert pre == 777
+        assert fake._buf == b""
+    finally:
+        b.close()
+        a.close()