feat(chv): 阶段 5 多会话并行——具名会话独立 context + storage-state 落盘 + execd 组杀

This commit is contained in:
host committed 2026-11-08 09:45:00 +00:00
1 parent f723eb8a88
commit 6466ea6024
5 files changed
+723 -24

No files matched your search

+56 -5
View File
@@ -71,19 +71,67 @@ class FakePage:
pass
class FakeContext:
class FakePagePool:
"""多 context 共享的页轮换池:具名会话各建独立 context,
但页按 open 顺序从同一池里取(A 取第 1 页、B 取第 2 页),
与阶段 3 旧测试“两会话各拿一页”的期望一致。"""
def __init__(self, pages):
self._pages = list(pages)
self._idx = 0
self.launches = []
def new_page(self):
def next_page(self):
page = self._pages[self._idx % len(self._pages)]
self._idx += 1
return page
class FakeContext:
def __init__(self, pages, browser=None):
if isinstance(pages, FakePagePool):
self._pool = pages
else:
self._pool = FakePagePool(pages)
# 阶段 5:.browser 指回所属 FakeBrowser(与真实
# Playwright 的 BrowserContext.browser 属性同款),
# 具名会话靠它 new_context;不传时为 None(旧测试
# 只用默认会话,不触此路)。
self._browser = browser
self.launches = []
self._closed = False
self.storage_calls = []
@property
def browser(self):
return self._browser
def new_page(self):
return self._pool.next_page()
def storage_state(self, path=None):
if path is None:
raise TypeError("storage_state 需要 path")
self.storage_calls.append(path)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
json.dump({"cookies": [], "origins": []}, fh)
def close(self):
pass
self._closed = True
class FakeBrowser:
"""假 Browser:new_context 每次建全新 FakeContext(独立存
储分区),页仍从共享池取。"""
def __init__(self, pool):
self._pool = pool
self.contexts = []
def new_context(self, storage_state=None, viewport=None):
ctx = FakeContext(self._pool, browser=self)
self.contexts.append(ctx)
return ctx
class FakeEntered:
@@ -112,7 +160,9 @@ class FakePlaywright:
def _agent(pages, tmp, full_browsers_dir=None):
ctx = FakeContext(pages)
pool = FakePagePool(pages)
browser = FakeBrowser(pool)
ctx = FakeContext(pool, browser=browser)
entered_holder = {}
class PW:
@@ -127,6 +177,7 @@ def _agent(pages, tmp, full_browsers_dir=None):
agent = BROWSER.BrowserAgent(
playwright_factory=lambda: PW(), shots_dir=tmp,
profile_dir=os.path.join(tmp, "profile"),
sessions_dir=os.path.join(tmp, "sessions"),
full_browsers_dir=full_browsers_dir)
return agent, ctx, entered_holder
+421
View File
@@ -0,0 +1,421 @@
"""阶段 5(多会话并行)测试:具名会话独立 context + storage-state
落盘恢复 + 会话 id 校验 + 关会话只动本会话 + execd 组杀。
只测本轨交付物(chv-guest/chv-browser.py、chv-guest/chv-execd.py、
chv-guest/chv-data-mount.sh)。全部用假 playwright/假文件系统,
绝不连真实网络、不起真实 VM。execd 组杀用本机真实 sleep 进程
组验证(start_new_session 的进程组长 + 组内子进程均须死)。
"""
import importlib.util
import json
import os
import signal
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _load_script(name):
path = ROOT / "chv-guest" / name
spec = importlib.util.spec_from_file_location(
"chv_guest_s5_" + name[:-3], str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
BROWSER = _load_script("chv-browser.py")
EXECD = _load_script("chv-execd.py")
class FakePage:
def __init__(self, title="Example Domain"):
self.url = "about:blank"
self._title = title
self._closed = False
def goto(self, url, timeout=None):
self.url = url.rstrip("/") + "/"
def wait_for_load_state(self, state, timeout=None):
pass
def title(self):
return self._title
def aria_snapshot(self):
return "- heading \"%s\" [level=1]" % self._title
def screenshot(self, path=None, type=None, quality=None, clip=None):
if type == "png":
return b"\x89PNG-fake"
return b"\xff\xd8\xff\xe0FAKEJPEG-" + self._title.encode()
def close(self):
self._closed = True
class FakeContext:
"""假 BrowserContext:new_page/storage_state/close;
.browser 指回所属 FakeBrowser(与真实 Playwright 的
BrowserContext.browser 属性同款)。"""
def __init__(self, browser):
self._browser = browser
self._pages = []
self._closed = False
self.storage_calls = []
@property
def browser(self):
return self._browser
def new_page(self):
page = FakePage()
self._pages.append(page)
return page
def storage_state(self, path=None):
if path is None:
raise TypeError("storage_state 需要 path")
self.storage_calls.append(path)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
json.dump({"cookies": [], "origins": []}, fh)
def close(self):
self._closed = True
class FakeBrowser:
"""假 Browser:new_context 每次建全新 FakeContext(独立存
储分区),记录 storage_state 入参以便断言恢复。"""
def __init__(self):
self.contexts = []
self.new_context_calls = []
def new_context(self, storage_state=None, viewport=None):
self.new_context_calls.append(
{"storage_state": storage_state, "viewport": viewport})
ctx = FakeContext(self)
self.contexts.append(ctx)
return ctx
class FakeEntered:
def __init__(self, shared_context):
self.chromium = _MockChromium(shared_context)
class _MockChromium:
def __init__(self, shared_context):
self._shared = shared_context
self.launch_calls = []
def launch_persistent_context(self, user_data_dir, **kwargs):
self.launch_calls.append((user_data_dir, kwargs))
return self._shared
def _agent(tmp, profile_name="profile"):
"""建一个带假 playwright 的 agent;共享持久上下文的
browser 是同一个 FakeBrowser(具名会话从它 new_context)。"""
browser = FakeBrowser()
shared = FakeContext(browser)
holder = {}
class PW:
def __enter__(self):
entered = FakeEntered(shared)
holder["entered"] = entered
return entered
def __exit__(self, *exc):
return False
agent = BROWSER.BrowserAgent(
playwright_factory=lambda: PW(),
shots_dir=os.path.join(tmp, "shots"),
profile_dir=os.path.join(tmp, profile_name),
sessions_dir=os.path.join(tmp, "sessions"))
return agent, browser, shared, holder
class SessionIdTest(unittest.TestCase):
def test_valid_ids(self):
for sid in ("default", "s1", "bot-2", "a_b", "A" * 64):
ok, _ = BROWSER.validate_session_id(sid)
self.assertTrue(ok, sid)
def test_invalid_ids(self):
for sid in ("../x", "a b", "a/b", "-lead", "_lead", "x" * 65,
"a.b", "a:b"):
ok, err = BROWSER.validate_session_id(sid)
self.assertFalse(ok, sid)
self.assertIn("非法会话 id", err)
def test_none_and_empty_mean_default(self):
ok, _ = BROWSER.validate_session_id(None)
self.assertTrue(ok)
ok, _ = BROWSER.validate_session_id("")
self.assertTrue(ok)
def test_open_rejects_bad_session_id(self):
with tempfile.TemporaryDirectory() as tmp:
agent, _b, _s, _h = _agent(tmp)
resp = agent.open("https://example.com", "../x")
self.assertFalse(resp["ok"])
self.assertIn("非法会话 id", resp["error"])
def test_snapshot_and_close_reject_bad_session_id(self):
with tempfile.TemporaryDirectory() as tmp:
agent, _b, _s, _h = _agent(tmp)
self.assertFalse(agent.snapshot("a b")["ok"])
self.assertFalse(agent.close("a/b")["ok"])
class SessionsDirTest(unittest.TestCase):
def test_data_disk_profile_uses_workspace_sessions(self):
path = BROWSER.resolve_sessions_dir(
"/home/chv/workspace/.chv-browser-profile", True)
self.assertEqual(
path, "/home/chv/workspace/.chv-browser-sessions")
def test_fallback_profile_uses_var_lib(self):
path = BROWSER.resolve_sessions_dir(
"/var/lib/chv-browser/profile", False)
self.assertEqual(path, "/var/lib/chv-browser/sessions")
def test_env_override_wins(self):
path = BROWSER.resolve_sessions_dir(
"/home/chv/workspace/.chv-browser-profile", True,
env={"CHV_BROWSER_SESSIONS_DIR": "/data/sess"})
self.assertEqual(path, "/data/sess")
class IsolatedContextTest(unittest.TestCase):
def test_default_session_uses_shared_context(self):
with tempfile.TemporaryDirectory() as tmp:
agent, _b, shared, _h = _agent(tmp)
resp = agent.open("https://example.com")
self.assertTrue(resp["ok"], resp)
self.assertEqual(resp["context"], "shared")
self.assertEqual(agent._page_context.get("default"),
"shared")
self.assertEqual(agent._contexts, {})
def test_named_sessions_get_distinct_contexts(self):
with tempfile.TemporaryDirectory() as tmp:
agent, browser, _s, _h = _agent(tmp)
ra = agent.open("https://example.com", "s1")
rb = agent.open("https://example.org", "s2")
self.assertTrue(ra["ok"], ra)
self.assertTrue(rb["ok"], rb)
self.assertEqual(ra["context"], "isolated")
self.assertEqual(rb["context"], "isolated")
self.assertEqual(len(browser.contexts), 2)
self.assertIsNot(browser.contexts[0],
browser.contexts[1])
# 同一具名会话重开复用自己的 context,不另建。
agent.open("https://httpbin.org/html", "s1")
self.assertEqual(len(browser.contexts), 2)
# 两会话的页 URL 各自独立。
self.assertTrue(agent.snapshot("s1")["url"].startswith(
"https://httpbin.org/html"))
self.assertTrue(agent.snapshot("s2")["url"].startswith(
"https://example.org"))
def test_sessions_and_status_report_isolation(self):
with tempfile.TemporaryDirectory() as tmp:
agent, _b, _s, _h = _agent(tmp)
agent.open("https://example.com", "s1")
agent.open("https://example.org", "s2")
agent.open("https://example.com")
sess = agent.sessions()["sessions"]
by_id = {s["session"]: s for s in sess}
self.assertEqual(by_id["s1"]["context"], "isolated")
self.assertEqual(by_id["s2"]["context"], "isolated")
self.assertEqual(by_id["default"]["context"], "shared")
st = agent.status()
self.assertEqual(st["isolated_sessions"], ["s1", "s2"])
self.assertEqual(st["sessions_dir"],
os.path.join(tmp, "sessions"))
def test_open_saves_storage_state_immediately(self):
with tempfile.TemporaryDirectory() as tmp:
agent, _b, _s, _h = _agent(tmp)
resp = agent.open("https://example.com", "s1")
self.assertTrue(resp["storage_saved"], resp)
self.assertFalse(resp["storage_restored"], resp)
path = os.path.join(tmp, "sessions", "s1",
"storage-state.json")
self.assertTrue(os.path.isfile(path))
with open(path) as fh:
self.assertEqual(json.load(fh)["cookies"], [])
def test_close_saves_state_and_reopen_restores(self):
with tempfile.TemporaryDirectory() as tmp:
agent, browser, _s, _h = _agent(tmp)
agent.open("https://example.com", "s1")
closed = agent.close("s1")
self.assertTrue(closed["ok"], closed)
self.assertTrue(closed["storage_saved"], closed)
self.assertFalse(agent.snapshot("s1")["ok"])
# 全新 agent(模拟浏览器重启后的服务)重开同一会话:
# new_context 须带上已落盘的 storage-state 文件。
agent2, browser2, _s2, _h2 = _agent(tmp)
resp = agent2.open("https://example.com", "s1")
self.assertTrue(resp["ok"], resp)
self.assertTrue(resp["storage_restored"], resp)
self.assertEqual(
browser2.new_context_calls[0]["storage_state"],
os.path.join(tmp, "sessions", "s1",
"storage-state.json"))
def test_new_session_id_does_not_restore_others_state(self):
with tempfile.TemporaryDirectory() as tmp:
agent, _b, _s, _h = _agent(tmp)
agent.open("https://example.com", "s1")
agent.close("s1")
resp = agent.open("https://example.com", "s2")
self.assertTrue(resp["ok"], resp)
self.assertFalse(resp["storage_restored"], resp)
def test_close_one_session_leaves_others_alone(self):
with tempfile.TemporaryDirectory() as tmp:
agent, browser, _s, _h = _agent(tmp)
agent.open("https://example.com", "s1")
agent.open("https://example.org", "s2")
ctx_s2 = browser.contexts[1]
closed = agent.close("s1")
self.assertTrue(closed["ok"], closed)
self.assertTrue(browser.contexts[0]._closed)
self.assertFalse(ctx_s2._closed)
snap = agent.snapshot("s2")
self.assertTrue(snap["ok"], snap)
self.assertTrue(snap["url"].startswith(
"https://example.org"))
self.assertEqual(agent.status()["isolated_sessions"],
["s2"])
def test_close_context_saves_all_isolated_states(self):
with tempfile.TemporaryDirectory() as tmp:
agent, browser, _s, _h = _agent(tmp)
agent.open("https://example.com", "s1")
agent.open("https://example.org", "s2")
agent._close_context()
for sid in ("s1", "s2"):
path = os.path.join(tmp, "sessions", sid,
"storage-state.json")
self.assertTrue(os.path.isfile(path), sid)
self.assertTrue(all(c._closed for c in browser.contexts))
self.assertEqual(agent._contexts, {})
self.assertEqual(agent.status()["isolated_sessions"], [])
class ExecdKillGroupTest(unittest.TestCase):
def _spawn_group(self, seconds=300):
"""起一个进程组:bash 组长 + 组内 sleep 子进程。"""
proc = subprocess.Popen(
["bash", "-c", "sleep %d & wait" % seconds],
start_new_session=True, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
# 等子 sleep 出现(组内除组长外至少一个进程)。
deadline = time.time() + 10
child = None
while time.time() < deadline:
out = subprocess.run(
["ps", "-o", "pid=", "--ppid", str(proc.pid)],
capture_output=True, text=True).stdout.strip()
if out:
child = int(out.split()[0])
break
time.sleep(0.2)
self.assertIsNotNone(child, "子 sleep 未出现")
return proc, child
@staticmethod
def _alive(pid):
"""组内非直系子进程的存活判断(kill(pid, 0) 对僵尸也报
存活,故直系子进程须用 Popen.poll() 判断,见下)。"""
try:
os.kill(pid, 0)
return True
except OSError:
return False
def _leader_dead(self, proc):
"""直系子进程:kill(pid, 0) 对僵尸仍报存活,须 poll()
收走僵尸后看返回码。"""
return proc.poll() is not None
def test_task_kill_kills_whole_group(self):
with tempfile.TemporaryDirectory() as tmp:
execd = EXECD.Execd(
tasks_path=os.path.join(tmp, "tasks.json"))
proc1, child1 = self._spawn_group()
proc2, child2 = self._spawn_group()
execd.store.add({"id": "t1", "kind": "exec",
"pid": proc1.pid, "state": "running"})
execd.store.add({"id": "t2", "kind": "exec",
"pid": proc2.pid, "state": "running"})
resp = execd.op_task_kill({"id": "t1"})
self.assertTrue(resp["ok"], resp)
self.assertEqual(resp["pid"], proc1.pid)
time.sleep(0.5)
self.assertTrue(self._leader_dead(proc1))
self.assertFalse(self._alive(child1))
# 另一个任务的组完全不受影响。
self.assertIsNone(proc2.poll())
self.assertTrue(self._alive(child2))
self.assertEqual(
execd.store.get("t1")["state"], "killed")
self.assertEqual(
execd.store.get("t2")["state"], "running")
execd.op_task_kill({"id": "t2", "signal": "KILL"})
time.sleep(0.5)
self.assertTrue(self._leader_dead(proc2))
self.assertFalse(self._alive(child2))
def test_task_kill_missing_pid_falls_back_and_reports(self):
with tempfile.TemporaryDirectory() as tmp:
execd = EXECD.Execd(
tasks_path=os.path.join(tmp, "tasks.json"))
execd.store.add({"id": "gone", "kind": "exec",
"pid": 999999, "state": "running"})
resp = execd.op_task_kill({"id": "gone"})
self.assertFalse(resp["ok"])
self.assertIn("kill failed", resp["error"])
self.assertEqual(
execd.store.get("gone")["state"], "running")
def test_task_kill_no_pid_reports(self):
with tempfile.TemporaryDirectory() as tmp:
execd = EXECD.Execd(
tasks_path=os.path.join(tmp, "tasks.json"))
execd.store.add({"id": "nopid", "kind": "exec",
"state": "running"})
resp = execd.op_task_kill({"id": "nopid"})
self.assertFalse(resp["ok"])
self.assertIn("no pid", resp["error"])
class DataMountSessionsDirTest(unittest.TestCase):
def test_mount_script_precreates_sessions_dir(self):
path = ROOT / "chv-guest" / "chv-data-mount.sh"
text = path.read_text()
self.assertIn(".chv-browser-sessions", text)
self.assertIn(
'chown chv-browser:chv-browser '
'"$MOUNT/.chv-browser-sessions"', text)
self.assertIn(
'chmod 0700 "$MOUNT/.chv-browser-sessions"', text)
if __name__ == "__main__":
unittest.main()