"""host-state 并发保存回归测试(2026-10-29 断点丢失实弹)。 旧写法两处同时失效:tmp 文件名只含 pid(容器内 host.py 全是 pid 1,并发保存写同一个 tmp 交错出坏 JSON);flock 在 runsc 容器间不互斥(实测双双 0 秒拿到排他锁)。坏 JSON 又被 _read_state_file 静默当成空 state,下一次保存合法地重写, 全部任务条目一起丢、版本从 390 跌回个位数。 本文件锁死三条修法:tmp 名每个写者唯一、mkdir 原子锁跨进程 互斥、磁盘已损坏时保存直接拒绝覆盖。 """ import glob import json import multiprocessing import os import tempfile import time import unittest import host def _save_one(args): path, name = args state = {"tasks": {name: {"status": "running"}}, "automations": []} host.save_state(path, state) return name def _tmp_name(_ignored): return host._state_tmp_path("/tmp/x/host-state.json") class StateTmpNameTest(unittest.TestCase): def test_tmp_names_unique_in_process(self): names = {host._state_tmp_path("/tmp/x/host-state.json") for _ in range(100)} self.assertEqual(len(names), 100) def test_tmp_names_unique_across_processes(self): ctx = multiprocessing.get_context("fork") with ctx.Pool(8) as pool: names = pool.map(_tmp_name, range(8)) self.assertEqual(len(set(names)), 8) class StateMkdirLockTest(unittest.TestCase): def test_concurrent_saves_all_tasks_survive(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "host-state.json") ctx = multiprocessing.get_context("fork") names = [f"task-{i}" for i in range(8)] with ctx.Pool(8) as pool: pool.map(_save_one, [(path, n) for n in names]) with open(path, encoding="utf-8") as f: data = json.load(f) self.assertEqual(set(data["tasks"]), set(names)) self.assertEqual(data["version"], 8) self.assertFalse(glob.glob(path + ".tmp.*")) self.assertFalse(os.path.exists(path + ".lockdir")) def test_stale_lock_is_recovered(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "host-state.json") lock_dir = path + ".lockdir" os.mkdir(lock_dir) old = time.time() - 1000 os.utime(lock_dir, (old, old)) host.save_state(path, {"tasks": {"a": {}}, "automations": []}) self.assertFalse(os.path.exists(lock_dir)) with open(path, encoding="utf-8") as f: self.assertIn("a", json.load(f)["tasks"]) def test_fresh_lock_timeout_raises_instead_of_writing_unlocked(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "host-state.json") os.mkdir(path + ".lockdir") old_timeout = host._STATE_LOCK_TIMEOUT host._STATE_LOCK_TIMEOUT = 0.3 try: with self.assertRaises(RuntimeError): host.save_state(path, {"tasks": {"a": {}}, "automations": []}) finally: host._STATE_LOCK_TIMEOUT = old_timeout self.assertFalse(os.path.exists(path)) class StateCorruptRefusalTest(unittest.TestCase): def test_corrupt_disk_refuses_to_overwrite(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "host-state.json") garbage = b"{not json, torn write" with open(path, "wb") as f: f.write(garbage) with self.assertRaises(RuntimeError): host.save_state(path, {"tasks": {"a": {}}, "automations": []}) with open(path, "rb") as f: self.assertEqual(f.read(), garbage) self.assertFalse(os.path.exists(path + ".bak")) self.assertFalse(os.path.exists(path + ".lockdir")) def test_wrong_shaped_disk_refuses_to_overwrite(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "host-state.json") with open(path, "w", encoding="utf-8") as f: json.dump({"tasks": ["not", "an", "object"], "automations": []}, f) with self.assertRaises(RuntimeError): host.save_state(path, {"tasks": {"a": {}}, "automations": []}) if __name__ == "__main__": unittest.main()