diff --git a/chv-guest/chv-browser.py b/chv-guest/chv-browser.py index 83e0da7..ee0f060 100644 --- a/chv-guest/chv-browser.py +++ b/chv-guest/chv-browser.py @@ -12,9 +12,21 @@ 未挂载时回落到 /var/lib/chv-browser/profile(本机盘,不跨 恢复保留——status 会明说当前 profile 在哪,绝不假装在数据盘)。 服务 crash/重启后重新打开同一 profile,cookie/登录态仍在。 - * 多会话:同一浏览器上下文内每会话一页(session id → page), - 会话 A/B 各自导航、互不干扰;登录态(cookie)同上下文共享, - 是既定取舍(方案:同 VM 内 Bot 共享登录态,隔离边界是 VM 本身)。 + * 多会话:默认会话("default")走单一持久上下文(profile 在 + 数据盘,登录态跨浏览器重启保留,阶段 3 口径不变);具名 + 会话各建一个独立 BrowserContext(同浏览器、不同存储分区, + cookie/localStorage 互不串),每会话的 storage-state 落 + 数据盘 .chv-browser-sessions/<会话>/storage-state.json, + open 后与 close 时各存一次;重开同一会话 id 时从文件恢复, + 不同会话 id 永远看不到对方的态。登录态跨会话保留的含义 + 是“同一会话 id 重开能读回自己的态”,不是“新会话能读别 + 人的态”(那正是隔离要防的)。localStorage 的隔离与 + cookie 同源:同一浏览器的不同 context 自带独立存储分区。 + * 阶段 5(feat-chv-opt-landing):具名会话独立 context + + storage-state 落盘恢复(见上);会话 id 只允许 + [A-Za-z0-9_-](首字符须为字母数字,最长 64),防路径穿越 + 进 storage-state 目录;close/kill 只动该会话自己的 + context 与任务,不动其它会话。 * 双浏览器:默认 chromium-headless-shell(随 base 常驻);完整 Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完 卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base; @@ -113,6 +125,14 @@ PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright" WORKSPACE_MOUNT = "/home/chv/workspace" PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile" PROFILE_FALLBACK_DIR = "/var/lib/chv-browser/profile" +# 具名会话的 storage-state 目录:profile 在数据盘上时跟 profile +# 走数据盘(与 profile 同级);回落本机盘时跟回落 profile 走 +# /var/lib/chv-browser/sessions(chv-browser 家目录,服务可写)。 +SESSIONS_FALLBACK_DIR = "/var/lib/chv-browser/sessions" +# 会话 id 白名单(阶段 5):只允许字母数字/_/-,首字符须为字母 +# 数字,最长 64——storage-state 按会话 id 落子目录,非法 id +# 绝不能拼出 ../ 穿越。 +SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") # 完整 Chromium 不 bake 进 base、不装根盘:根盘只剩 ~600MB, # playwright install chromium 会 ENOSPC(2026-11-08 构建 #8 驱动 # 实证:109 秒后写满失败)。按需装到数据盘(9.3G 空闲),用完可删。 @@ -223,6 +243,36 @@ def resolve_full_browsers_dir(profile_on_data_disk, env=None): return FULL_BROWSERS_FALLBACK_DIR +def resolve_sessions_dir(profile_dir, profile_on_data_disk, env=None): + """具名会话 storage-state 的根目录(阶段 5)。 + + env 显式指定(CHV_BROWSER_SESSIONS_DIR)时优先;否则 + profile 在数据盘上时取 profile 的同级目录 + /home/chv/workspace/.chv-browser-sessions(数据盘,随 + 快照恢复保留),回落时取 /var/lib/chv-browser/sessions。""" + if env is None: + env = os.environ + explicit = (env.get("CHV_BROWSER_SESSIONS_DIR") or "").strip() + if explicit: + return explicit + if profile_on_data_disk: + return os.path.join(os.path.dirname(profile_dir), + ".chv-browser-sessions") + return SESSIONS_FALLBACK_DIR + + +def validate_session_id(session): + """会话 id 校验(阶段 5):空/None 视为 default;非法返回 + (False, error),合法返回 (True, "")。storage-state 按 id + 落子目录,校验是防穿越的唯一关口。""" + if session is None or session == "": + return True, "" + if not isinstance(session, str) or not SESSION_ID_RE.match(session): + return False, ("非法会话 id:%r(只允许字母数字/_/-," + "首字符须为字母数字,最长 64)" % (session,)) + return True, "" + + def truncate_a11y(node, max_nodes=MAX_A11Y_NODES, max_chars=MAX_A11Y_CHARS): """截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。 @@ -429,7 +479,8 @@ class BrowserAgent: _load_playwright()。""" def __init__(self, playwright_factory=None, shots_dir=SHOTS_DIR, - profile_dir=None, cdp_port=None, full_browsers_dir=None): + profile_dir=None, cdp_port=None, full_browsers_dir=None, + sessions_dir=None): self._factory = playwright_factory or _load_playwright self._shots_dir = shots_dir self._profile_explicit = bool(profile_dir) @@ -440,6 +491,10 @@ class BrowserAgent: else: self._profile_dir, self._profile_on_data_disk = \ resolve_profile_dir() + self._sessions_dir = ( + sessions_dir or resolve_sessions_dir( + self._profile_dir, self._profile_on_data_disk)) + self._sessions_explicit = bool(sessions_dir) self._full_browsers_explicit = bool(full_browsers_dir) self._full_browsers_dir = ( full_browsers_dir or resolve_full_browsers_dir( @@ -453,6 +508,13 @@ class BrowserAgent: self._entered = None self._context = None self._pages = {} + # 阶段 5:具名会话的独立 context(session → context)与 + # 每页所属 context 种类("shared" 默认持久上下文 / + # "isolated" 独立 context);_storage_restored 记本次 + # context 创建时是否从 storage-state 文件恢复过。 + self._contexts = {} + self._page_context = {} + self._storage_restored = {} self._engine = "shell" self._mounted_full = False self._last_shot = {} @@ -463,6 +525,18 @@ class BrowserAgent: # -- 浏览器生命周期 ---------------------------------------------- def _close_context(self): + # 阶段 5:先把各具名会话的 storage-state 落盘,再关它们 + # 的独立 context——引擎切换/服务退出时态不丢,下次重开 + # 同一会话 id 能恢复;关不掉的只跳过,不挡持久上下文关闭。 + for session in list(self._contexts): + self._save_storage_state(session) + ctx = self._contexts.pop(session) + try: + ctx.close() + except Exception: + pass + self._page_context = {} + self._storage_restored = {} for page in list(self._pages.values()): try: page.close() @@ -476,6 +550,78 @@ class BrowserAgent: pass self._context = None + def _norm_session(self, session): + """会话 id 归一化 + 校验:返回 (session, error)。 + error 非空时调用方直接返回 {"ok": False, "error": error}。""" + session = session or DEFAULT_SESSION + ok, err = validate_session_id(session) + if not ok: + return None, err + return session, "" + + def _session_storage_path(self, session): + """具名会话的 storage-state 文件路径(阶段 5)。""" + return os.path.join(self._sessions_dir, session, + "storage-state.json") + + def _save_storage_state(self, session): + """把具名会话的 context 状态落盘(阶段 5)。 + 成功返回 True;无独立 context 或写失败返回 False(调用 + 方只记录,不因此谎报 open/close 失败——存储失败由 + storage_saved 字段如实报告)。""" + ctx = self._contexts.get(session) + if ctx is None: + return False + try: + path = self._session_storage_path(session) + os.makedirs(os.path.dirname(path), exist_ok=True) + ctx.storage_state(path=path) + except (OSError, ValueError, TypeError, AttributeError): + return False + except Exception: + return False + return True + + def _ensure_isolated_context(self, session): + """具名会话的独立 context(阶段 5):已建则复用;未建则 + 从持久上下文的 browser 上 new_context(同浏览器、独立 + 存储分区),storage-state 文件在位时恢复。返回 + (context, restored, error)。""" + ctx = self._contexts.get(session) + if ctx is not None: + return ctx, self._storage_restored.get(session, False), "" + if self._context is None: + return None, False, "浏览器尚未启动" + browser = getattr(self._context, "browser", None) + if callable(browser): + browser = browser() + if browser is None or not hasattr(browser, "new_context"): + return None, False, ("当前浏览器不支持独立 context" + "(无 browser.new_context)") + kwargs = {"viewport": {"width": VIEWPORT_WIDTH, + "height": VIEWPORT_HEIGHT}} + state_path = self._session_storage_path(session) + restored = os.path.isfile(state_path) + if restored: + kwargs["storage_state"] = state_path + try: + ctx = browser.new_context(**kwargs) + except Exception as e: + return None, False, "创建独立 context 失败:%s" % e + self._contexts[session] = ctx + self._storage_restored[session] = restored + return ctx, restored, "" + + def _context_for(self, session): + """会话对应的 context:default 走共享持久上下文,具名走 + 独立 context。返回 (context, kind, error)。""" + if session == DEFAULT_SESSION: + return self._context, "shared", "" + ctx, restored, err = self._ensure_isolated_context(session) + if err: + return None, "isolated", err + return ctx, "isolated", "" + def _ensure_browser(self, engine=None): """确保浏览器上下文已起(指定引擎时切换)。返回 (ok, error)。 切换时先关旧上下文再起新上下文——两个引擎共用同一 profile @@ -494,6 +640,11 @@ class BrowserAgent: # 上下文不受影响(上面已提前返回)。 self._profile_dir, self._profile_on_data_disk = \ resolve_profile_dir() + if not self._sessions_explicit: + # 阶段 5:sessions 目录跟 profile 走——迟挂载自愈 + # 到数据盘时,storage-state 也落数据盘而非回落盘。 + self._sessions_dir = resolve_sessions_dir( + self._profile_dir, self._profile_on_data_disk) if not self._full_browsers_explicit: self._full_browsers_dir = resolve_full_browsers_dir( self._profile_on_data_disk) @@ -563,10 +714,15 @@ class BrowserAgent: ok, err = validate_url(url) if not ok: return {"ok": False, "error": err} - session = session or DEFAULT_SESSION + session, err = self._norm_session(session) + if err: + return {"ok": False, "error": err} ok, err = self._ensure_browser() if not ok: return {"ok": False, "error": err} + ctx, kind, err = self._context_for(session) + if err: + return {"ok": False, "error": err} try: old = self._pages.get(session) if old is not None: @@ -574,8 +730,9 @@ class BrowserAgent: old.close() except Exception: pass - page = self._context.new_page() + page = ctx.new_page() self._pages[session] = page + self._page_context[session] = kind # 注意:open 不清 diff 基线——diff_shot 永远与“上一帧” # 逐像素比对,不管上一帧是哪一页;导航后调 diff_shot # 得到的正是新旧两页渲染的真实差异区(跨页局部变化 @@ -587,10 +744,26 @@ class BrowserAgent: except Exception as e: return {"ok": False, "error": "打开 %s 失败:%s" % (url[:80], e)} - return self.snapshot(session) + # 阶段 5:具名会话导航成功后立即把 storage-state 落盘—— + # 导航里刚置的 cookie 当场进文件,不等 close;浏览器 + # crash 后重开同一会话 id 仍能恢复(Chromium 刷盘竞态 + # 绕过去了:storage_state() 读的是 context 的实时态)。 + saved = False + if kind == "isolated": + saved = self._save_storage_state(session) + resp = self.snapshot(session) + if resp.get("ok"): + resp["context"] = kind + if kind == "isolated": + resp["storage_saved"] = saved + resp["storage_restored"] = \ + self._storage_restored.get(session, False) + return resp def snapshot(self, session=None): - session = session or DEFAULT_SESSION + session, err = self._norm_session(session) + if err: + return {"ok": False, "error": err} page = self._page(session) if page is None: return {"ok": False, @@ -624,10 +797,13 @@ class BrowserAgent: "a11y": tree, "truncated": truncated, "a11y_roles": roles, "a11y_interactive": interactive, "a11y_chars": chars, "a11y_hash": digest, - "session": session} + "session": session, + "context": self._page_context.get(session, "shared")} def screenshot(self, session=None, diff_only=False): - session = session or DEFAULT_SESSION + session, err = self._norm_session(session) + if err: + return {"ok": False, "error": err} page = self._page(session) if page is None: return {"ok": False, @@ -674,7 +850,9 @@ class BrowserAgent: 首帧(本会话无上一帧)视为整帧变化,bbox 为全视口。 Pillow 缺失时 ok:false 并指引回落 screenshot 全帧, 不计入计数器、不落盘、不假装裁剪。""" - session = session or DEFAULT_SESSION + session, err = self._norm_session(session) + if err: + return {"ok": False, "error": err} page = self._page(session) if page is None: return {"ok": False, @@ -747,7 +925,9 @@ class BrowserAgent: def verify_shot(self, session=None): """三级全屏校验(阶段 4):全帧 JPEG q70,只用于检查点。 计 shots_verify。日常动作后用 diff_shot,检查点才调它。""" - session = session or DEFAULT_SESSION + session, err = self._norm_session(session) + if err: + return {"ok": False, "error": err} page = self._page(session) if page is None: return {"ok": False, @@ -778,16 +958,37 @@ class BrowserAgent: "session": session} def close(self, session=None): - session = session or DEFAULT_SESSION + session, err = self._norm_session(session) + if err: + return {"ok": False, "error": err} page = self._pages.pop(session, None) if page is not None: try: page.close() except Exception: pass + kind = self._page_context.pop(session, "shared") + saved = False + if kind == "isolated": + # 阶段 5:关会话先落盘 storage-state,再关独立 + # context——态进文件,下次重开同一会话 id 恢复; + # 只动本会话的 context,其它会话的页与 context + # 原样不动。 + saved = self._save_storage_state(session) + ctx = self._contexts.pop(session, None) + if ctx is not None: + try: + ctx.close() + except Exception: + pass + self._storage_restored.pop(session, None) self._last_shot.pop(session, None) self._last_png.pop(session, None) - return {"ok": True, "closed": True, "session": session} + resp = {"ok": True, "closed": True, "session": session, + "context": kind} + if kind == "isolated": + resp["storage_saved"] = saved + return resp def sessions(self): out = [] @@ -800,7 +1001,9 @@ class BrowserAgent: url = page.url except Exception: url = "" - out.append({"session": sid, "url": url, "title": title}) + out.append({"session": sid, "url": url, "title": title, + "context": self._page_context.get( + sid, "shared")}) return {"ok": True, "sessions": out} # -- 服务化:状态 / CDP / 引擎 -------------------------------------- @@ -817,6 +1020,8 @@ class BrowserAgent: "cdp_port": self._cdp_port, "sessions": [s["session"] for s in self.sessions()["sessions"]], + "isolated_sessions": sorted(self._contexts), + "sessions_dir": self._sessions_dir, "mounted_full": self._mounted_full, "full_present": full_chromium_present( self._full_browsers_dir), diff --git a/chv-guest/chv-data-mount.sh b/chv-guest/chv-data-mount.sh index 0ca666a..6d6dfeb 100755 --- a/chv-guest/chv-data-mount.sh +++ b/chv-guest/chv-data-mount.sh @@ -52,6 +52,14 @@ chown chv:chv "$MOUNT" || true mkdir -p "$MOUNT/.chv-browser-profile" || true chown chv-browser:chv-browser "$MOUNT/.chv-browser-profile" 2>/dev/null || true chmod 0700 "$MOUNT/.chv-browser-profile" 2>/dev/null || true +# feat-chv-opt-landing(阶段 5):具名会话的 storage-state 落 +# 数据盘 .chv-browser-sessions/<会话>/storage-state.json。工作区 +# 根属 chv,chv-browser 自己建不进子目录,此处以 root 预建并 +# 定好属主与 0700;浏览器服务自己建目录失败时只记 +# storage_saved=false,不谎报 open/close 成功。 +mkdir -p "$MOUNT/.chv-browser-sessions" || true +chown chv-browser:chv-browser "$MOUNT/.chv-browser-sessions" 2>/dev/null || true +chmod 0700 "$MOUNT/.chv-browser-sessions" 2>/dev/null || true # 阶段 3 live 实证(2026-11-08,构建 #8 驱动):/home/chv 默认 # 0750 chv:chv,chv-browser 穿不过家目录去够数据盘 profile—— # ismount 判假、profile 静默回落本机盘(服务本身不报错)。 diff --git a/chv-guest/chv-execd.py b/chv-guest/chv-execd.py index 27513cc..b87c9f5 100644 --- a/chv-guest/chv-execd.py +++ b/chv-guest/chv-execd.py @@ -11,7 +11,10 @@ `python3 /app/host.py `(单元隔离口径不变,不绕 cell 直跑 host.py);子进程 setsid 脱离,execd 崩溃不 带走它;任务登记落盘 /run/chv/execd-tasks.json,execd - 重启后按 pid 重新认领(reattach),任务状态不丢。 + 重启后按 pid 重新认领(reattach),任务状态不丢。阶段 5: + task-kill 先杀整个进程组(任务 pid 即组长,spawn 一律 + start_new_session),组不存在时回落单杀——只动该任务的 + 组,不动其它任务。 客户端鉴权:SO_PEERCRED 的 uid 须是 0 或 1000(root / chv), 其余一律拒绝。socket /run/chv/execd.sock 0600 root:root。 @@ -365,13 +368,24 @@ class Execd: return {"ok": False, "error": "task not running: %s" % task.get("state")} pid = task.get("pid") + if not isinstance(pid, int): + return {"ok": False, + "error": "task has no pid: %r" % (pid,)} sig = signal.SIGTERM if req.get("signal") == "KILL": sig = signal.SIGKILL + # 阶段 5:先杀整个进程组——spawn 一律 start_new_session, + # 任务 pid 即组长,组杀才能带走 runuser/cell fork 出的 + # 子进程(单杀组长会留孤儿继续跑);组不存在(如已收走) + # 时回落单杀 pid,仍失败才如实报错。 try: - os.kill(pid, sig) - except OSError as exc: - return {"ok": False, "error": "kill failed: %s" % exc} + os.killpg(pid, sig) + except OSError: + try: + os.kill(pid, sig) + except OSError as exc: + return {"ok": False, + "error": "kill failed: %s" % exc} self.store.update(task_id, state="killed", ended_at=time.time()) return {"ok": True, "id": task_id, "pid": pid} diff --git a/cloud/tests/test_chv_opt_stage3.py b/cloud/tests/test_chv_opt_stage3.py index 5a6a4fe..89ebf15 100644 --- a/cloud/tests/test_chv_opt_stage3.py +++ b/cloud/tests/test_chv_opt_stage3.py @@ -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 diff --git a/cloud/tests/test_chv_opt_stage5.py b/cloud/tests/test_chv_opt_stage5.py new file mode 100644 index 0000000..cae8855 --- /dev/null +++ b/cloud/tests/test_chv_opt_stage5.py @@ -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()