From ea346be484dec522089a18dbe149b89d8232b081 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:54:12 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=87=AA=E5=8A=A8=E5=AD=98=E6=A1=A3?= =?UTF-8?q?=E5=87=A0=E4=B9=8E=E6=B0=B8=E4=B8=8D=E8=A7=A6=E5=8F=91=E7=9A=84?= =?UTF-8?q?=E7=9C=9F=20bug=E2=80=94=E2=80=94=E6=8A=8A=20dirty=20=E5=92=8C?= =?UTF-8?q?=20unsaved=20=E6=8B=86=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 顺着验收网页修复时发现:default 牧场根本没写进库,内存里每次 重启都是新档。根因是 session.dirty 一个标志位伺二主: * Publisher 推送循环每 ~250ms 就把 dirty 清掉(无人在线时直接清, "没人在看就不做序列化,省 CPU"); * autosave() 每 60s 才看一眼 dirty。 dirty=True 的窗口只有约 250ms,自动存档撞上它的概率约 0.4%—— 等于永不存盘。进度只在手动存档/优雅停机时落盘,进程被强杀全丢。 修法:新增 session.unsaved 专管「有变化没落盘」,_on_change / load / touch / advance_day 置位,只有成功的 save() 清位;autosave() 改看 unsaved;dirty 继续只管推送节流,hub.py 的推送循环一字未动。 两只旧测试直接操纵 dirty 驱动 autosave,固化了被修掉的耦合,改成 unsaved 表达意图;新增回归测试:推送循环清了 dirty 也要照常落盘、 没有新变化不重复写库。 真机验证:0 客户端在线纯 HTTP 种一块地,70 秒内库行更新(金币 120→112,种子钱都对得上)。515 项测试 OK。 --- farm/server/session.py | 18 ++++++++++++++++-- tests/test_server.py | 27 ++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/farm/server/session.py b/farm/server/session.py index 5abaf5d..6001024 100644 --- a/farm/server/session.py +++ b/farm/server/session.py @@ -85,6 +85,11 @@ class FarmSession: self.engine.next_tick_at = time.time() + self.engine.tick_seconds # 由 FarmClock 维护,Publisher 读取;用普通属性即可,都是原子读写 self.dirty = True + # unsaved 与 dirty 分开:dirty 管推送节流(Publisher 会频繁清它), + # unsaved 管「有变化还没落盘」,只有成功的 save() 才清。 + # 之前两个概念共用 dirty,而推送循环 ~250ms 就清一次,自动存档 + # 60s 才看一眼——窗口太小,autosave 几乎永远不触发。 + self.unsaved = True self.published_day = 0 self.last_events: list[str] = [] self.created_at = time.time() @@ -100,6 +105,7 @@ class FarmSession: def _on_change(self) -> None: """引擎的任何状态变化都会走到这里(可能来自任意线程)。""" self.dirty = True + self.unsaved = True def load(self) -> str: state, message = load_state(self.db_path, self.farm_id) @@ -112,17 +118,23 @@ class FarmSession: self.load_message = f"{message},已开新档" self.published_day = self.engine.state.day self.dirty = True + self.unsaved = True return self.load_message def save(self) -> str: with self._lock: path = save_state(self.engine.state, self.db_path, self.farm_id) self.saved_at = time.time() + self.unsaved = False return path def autosave(self) -> bool: - """只在有变化时落盘,返回是否真的写了。""" - if not self.dirty or self.save_dir == "": + """只在有未落盘的变化时写,返回是否真的写了。 + + 看的是 ``unsaved`` 而不是 ``dirty``:dirty 是推送节流用的, + Publisher 每 ~250ms 就清一次,拿来当存盘依据等于永不存盘。 + """ + if not self.unsaved or self.save_dir == "": return False if time.time() - self.saved_at < 5: return False @@ -142,12 +154,14 @@ class FarmSession: if count_action: self.actions_taken += 1 self.dirty = True + self.unsaved = True def advance_day(self, reason: str = "时间流逝") -> list[str]: with self._lock: events = self.engine.advance_day(reason=reason) self.last_events = events self.dirty = True + self.unsaved = True return events def reset(self) -> None: diff --git a/tests/test_server.py b/tests/test_server.py index 3529b75..ec021c8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -216,6 +216,23 @@ class FarmSessionTestCase(unittest.TestCase): self.assertEqual(reopened.engine.state.gold, 777) self.assertIn("第 9 天", reopened.load_message) + def test_autosave_survives_the_push_loop_clearing_dirty(self): + """回归:dirty 是推送节流用的,Publisher ~250ms 就清一次;自动存档 + 如果也看 dirty,就等于永不存盘(进程被强杀进度全丢)。它必须看 + 只有成功的 save() 才清的 unsaved。""" + session = FarmSession("alice", self.dir, 2400.0) + session.touch() + session.dirty = False # 模拟 Publisher 清推送标记 + self.assertTrue(session.autosave(), "推送循环清了 dirty 也要照常落盘") + reopened = FarmSession("alice", self.dir, 2400.0) + self.assertIn("已载入存档", reopened.load_message) + + def test_autosave_skips_when_nothing_changed_since_last_save(self): + session = FarmSession("alice", self.dir, 2400.0) + session.touch() + self.assertTrue(session.autosave()) + self.assertFalse(session.autosave(), "没有新变化就不该重复写库") + def test_broken_save_degrades_to_a_new_farm(self): path = os.path.join(self.dir, DB_NAME) with open(path, "w", encoding="utf-8") as handle: @@ -247,13 +264,13 @@ class FarmSessionTestCase(unittest.TestCase): def test_autosave_skips_when_clean(self): session = FarmSession("alice", self.dir, 2400.0) - session.dirty = False + session.unsaved = False session.saved_at = 0.0 self.assertFalse(session.autosave()) - def test_autosave_writes_when_dirty(self): + def test_autosave_writes_when_unsaved(self): session = FarmSession("alice", self.dir, 2400.0) - session.dirty = True + session.unsaved = True session.saved_at = 0.0 self.assertTrue(session.autosave()) self.assertTrue(farm_exists(session.db_path, session.farm_id)) @@ -377,8 +394,8 @@ class SessionManagerTestCase(unittest.TestCase): manager.get("alice") bob = manager.get("bob") alice = manager.get("alice") - alice.dirty = True - bob.dirty = False + alice.unsaved = True + bob.unsaved = False alice.saved_at = bob.saved_at = 0.0 self.assertEqual(manager.autosave_all(), 1)