feat(chv): 阶段 5 多会话并行——具名会话独立 context + storage-state 落盘 + execd 组杀
This commit is contained in:
1 parent
f723eb8a88
commit
6466ea6024
5 files changed
+723
-24
No files matched your search
+220
-15
@@ -12,9 +12,21 @@
|
|||||||
未挂载时回落到 /var/lib/chv-browser/profile(本机盘,不跨
|
未挂载时回落到 /var/lib/chv-browser/profile(本机盘,不跨
|
||||||
恢复保留——status 会明说当前 profile 在哪,绝不假装在数据盘)。
|
恢复保留——status 会明说当前 profile 在哪,绝不假装在数据盘)。
|
||||||
服务 crash/重启后重新打开同一 profile,cookie/登录态仍在。
|
服务 crash/重启后重新打开同一 profile,cookie/登录态仍在。
|
||||||
* 多会话:同一浏览器上下文内每会话一页(session id → page),
|
* 多会话:默认会话("default")走单一持久上下文(profile 在
|
||||||
会话 A/B 各自导航、互不干扰;登录态(cookie)同上下文共享,
|
数据盘,登录态跨浏览器重启保留,阶段 3 口径不变);具名
|
||||||
是既定取舍(方案:同 VM 内 Bot 共享登录态,隔离边界是 VM 本身)。
|
会话各建一个独立 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-headless-shell(随 base 常驻);完整
|
||||||
Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完
|
Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完
|
||||||
卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base;
|
卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base;
|
||||||
@@ -113,6 +125,14 @@ PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright"
|
|||||||
WORKSPACE_MOUNT = "/home/chv/workspace"
|
WORKSPACE_MOUNT = "/home/chv/workspace"
|
||||||
PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile"
|
PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile"
|
||||||
PROFILE_FALLBACK_DIR = "/var/lib/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,
|
# 完整 Chromium 不 bake 进 base、不装根盘:根盘只剩 ~600MB,
|
||||||
# playwright install chromium 会 ENOSPC(2026-11-08 构建 #8 驱动
|
# playwright install chromium 会 ENOSPC(2026-11-08 构建 #8 驱动
|
||||||
# 实证:109 秒后写满失败)。按需装到数据盘(9.3G 空闲),用完可删。
|
# 实证:109 秒后写满失败)。按需装到数据盘(9.3G 空闲),用完可删。
|
||||||
@@ -223,6 +243,36 @@ def resolve_full_browsers_dir(profile_on_data_disk, env=None):
|
|||||||
return FULL_BROWSERS_FALLBACK_DIR
|
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,
|
def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
|
||||||
max_chars=MAX_A11Y_CHARS):
|
max_chars=MAX_A11Y_CHARS):
|
||||||
"""截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。
|
"""截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。
|
||||||
@@ -429,7 +479,8 @@ class BrowserAgent:
|
|||||||
_load_playwright()。"""
|
_load_playwright()。"""
|
||||||
|
|
||||||
def __init__(self, playwright_factory=None, shots_dir=SHOTS_DIR,
|
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._factory = playwright_factory or _load_playwright
|
||||||
self._shots_dir = shots_dir
|
self._shots_dir = shots_dir
|
||||||
self._profile_explicit = bool(profile_dir)
|
self._profile_explicit = bool(profile_dir)
|
||||||
@@ -440,6 +491,10 @@ class BrowserAgent:
|
|||||||
else:
|
else:
|
||||||
self._profile_dir, self._profile_on_data_disk = \
|
self._profile_dir, self._profile_on_data_disk = \
|
||||||
resolve_profile_dir()
|
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_explicit = bool(full_browsers_dir)
|
||||||
self._full_browsers_dir = (
|
self._full_browsers_dir = (
|
||||||
full_browsers_dir or resolve_full_browsers_dir(
|
full_browsers_dir or resolve_full_browsers_dir(
|
||||||
@@ -453,6 +508,13 @@ class BrowserAgent:
|
|||||||
self._entered = None
|
self._entered = None
|
||||||
self._context = None
|
self._context = None
|
||||||
self._pages = {}
|
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._engine = "shell"
|
||||||
self._mounted_full = False
|
self._mounted_full = False
|
||||||
self._last_shot = {}
|
self._last_shot = {}
|
||||||
@@ -463,6 +525,18 @@ class BrowserAgent:
|
|||||||
|
|
||||||
# -- 浏览器生命周期 ----------------------------------------------
|
# -- 浏览器生命周期 ----------------------------------------------
|
||||||
def _close_context(self):
|
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()):
|
for page in list(self._pages.values()):
|
||||||
try:
|
try:
|
||||||
page.close()
|
page.close()
|
||||||
@@ -476,6 +550,78 @@ class BrowserAgent:
|
|||||||
pass
|
pass
|
||||||
self._context = None
|
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):
|
def _ensure_browser(self, engine=None):
|
||||||
"""确保浏览器上下文已起(指定引擎时切换)。返回 (ok, error)。
|
"""确保浏览器上下文已起(指定引擎时切换)。返回 (ok, error)。
|
||||||
切换时先关旧上下文再起新上下文——两个引擎共用同一 profile
|
切换时先关旧上下文再起新上下文——两个引擎共用同一 profile
|
||||||
@@ -494,6 +640,11 @@ class BrowserAgent:
|
|||||||
# 上下文不受影响(上面已提前返回)。
|
# 上下文不受影响(上面已提前返回)。
|
||||||
self._profile_dir, self._profile_on_data_disk = \
|
self._profile_dir, self._profile_on_data_disk = \
|
||||||
resolve_profile_dir()
|
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:
|
if not self._full_browsers_explicit:
|
||||||
self._full_browsers_dir = resolve_full_browsers_dir(
|
self._full_browsers_dir = resolve_full_browsers_dir(
|
||||||
self._profile_on_data_disk)
|
self._profile_on_data_disk)
|
||||||
@@ -563,10 +714,15 @@ class BrowserAgent:
|
|||||||
ok, err = validate_url(url)
|
ok, err = validate_url(url)
|
||||||
if not ok:
|
if not ok:
|
||||||
return {"ok": False, "error": err}
|
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()
|
ok, err = self._ensure_browser()
|
||||||
if not ok:
|
if not ok:
|
||||||
return {"ok": False, "error": err}
|
return {"ok": False, "error": err}
|
||||||
|
ctx, kind, err = self._context_for(session)
|
||||||
|
if err:
|
||||||
|
return {"ok": False, "error": err}
|
||||||
try:
|
try:
|
||||||
old = self._pages.get(session)
|
old = self._pages.get(session)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
@@ -574,8 +730,9 @@ class BrowserAgent:
|
|||||||
old.close()
|
old.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
page = self._context.new_page()
|
page = ctx.new_page()
|
||||||
self._pages[session] = page
|
self._pages[session] = page
|
||||||
|
self._page_context[session] = kind
|
||||||
# 注意:open 不清 diff 基线——diff_shot 永远与“上一帧”
|
# 注意:open 不清 diff 基线——diff_shot 永远与“上一帧”
|
||||||
# 逐像素比对,不管上一帧是哪一页;导航后调 diff_shot
|
# 逐像素比对,不管上一帧是哪一页;导航后调 diff_shot
|
||||||
# 得到的正是新旧两页渲染的真实差异区(跨页局部变化
|
# 得到的正是新旧两页渲染的真实差异区(跨页局部变化
|
||||||
@@ -587,10 +744,26 @@ class BrowserAgent:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False,
|
return {"ok": False,
|
||||||
"error": "打开 %s 失败:%s" % (url[:80], e)}
|
"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):
|
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)
|
page = self._page(session)
|
||||||
if page is None:
|
if page is None:
|
||||||
return {"ok": False,
|
return {"ok": False,
|
||||||
@@ -624,10 +797,13 @@ class BrowserAgent:
|
|||||||
"a11y": tree, "truncated": truncated,
|
"a11y": tree, "truncated": truncated,
|
||||||
"a11y_roles": roles, "a11y_interactive": interactive,
|
"a11y_roles": roles, "a11y_interactive": interactive,
|
||||||
"a11y_chars": chars, "a11y_hash": digest,
|
"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):
|
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)
|
page = self._page(session)
|
||||||
if page is None:
|
if page is None:
|
||||||
return {"ok": False,
|
return {"ok": False,
|
||||||
@@ -674,7 +850,9 @@ class BrowserAgent:
|
|||||||
首帧(本会话无上一帧)视为整帧变化,bbox 为全视口。
|
首帧(本会话无上一帧)视为整帧变化,bbox 为全视口。
|
||||||
Pillow 缺失时 ok:false 并指引回落 screenshot 全帧,
|
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)
|
page = self._page(session)
|
||||||
if page is None:
|
if page is None:
|
||||||
return {"ok": False,
|
return {"ok": False,
|
||||||
@@ -747,7 +925,9 @@ class BrowserAgent:
|
|||||||
def verify_shot(self, session=None):
|
def verify_shot(self, session=None):
|
||||||
"""三级全屏校验(阶段 4):全帧 JPEG q70,只用于检查点。
|
"""三级全屏校验(阶段 4):全帧 JPEG q70,只用于检查点。
|
||||||
计 shots_verify。日常动作后用 diff_shot,检查点才调它。"""
|
计 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)
|
page = self._page(session)
|
||||||
if page is None:
|
if page is None:
|
||||||
return {"ok": False,
|
return {"ok": False,
|
||||||
@@ -778,16 +958,37 @@ class BrowserAgent:
|
|||||||
"session": session}
|
"session": session}
|
||||||
|
|
||||||
def close(self, session=None):
|
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)
|
page = self._pages.pop(session, None)
|
||||||
if page is not None:
|
if page is not None:
|
||||||
try:
|
try:
|
||||||
page.close()
|
page.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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_shot.pop(session, None)
|
||||||
self._last_png.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):
|
def sessions(self):
|
||||||
out = []
|
out = []
|
||||||
@@ -800,7 +1001,9 @@ class BrowserAgent:
|
|||||||
url = page.url
|
url = page.url
|
||||||
except Exception:
|
except Exception:
|
||||||
url = ""
|
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}
|
return {"ok": True, "sessions": out}
|
||||||
|
|
||||||
# -- 服务化:状态 / CDP / 引擎 --------------------------------------
|
# -- 服务化:状态 / CDP / 引擎 --------------------------------------
|
||||||
@@ -817,6 +1020,8 @@ class BrowserAgent:
|
|||||||
"cdp_port": self._cdp_port,
|
"cdp_port": self._cdp_port,
|
||||||
"sessions": [s["session"] for s in
|
"sessions": [s["session"] for s in
|
||||||
self.sessions()["sessions"]],
|
self.sessions()["sessions"]],
|
||||||
|
"isolated_sessions": sorted(self._contexts),
|
||||||
|
"sessions_dir": self._sessions_dir,
|
||||||
"mounted_full": self._mounted_full,
|
"mounted_full": self._mounted_full,
|
||||||
"full_present": full_chromium_present(
|
"full_present": full_chromium_present(
|
||||||
self._full_browsers_dir),
|
self._full_browsers_dir),
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ chown chv:chv "$MOUNT" || true
|
|||||||
mkdir -p "$MOUNT/.chv-browser-profile" || true
|
mkdir -p "$MOUNT/.chv-browser-profile" || true
|
||||||
chown chv-browser:chv-browser "$MOUNT/.chv-browser-profile" 2>/dev/null || true
|
chown chv-browser:chv-browser "$MOUNT/.chv-browser-profile" 2>/dev/null || true
|
||||||
chmod 0700 "$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 默认
|
# 阶段 3 live 实证(2026-11-08,构建 #8 驱动):/home/chv 默认
|
||||||
# 0750 chv:chv,chv-browser 穿不过家目录去够数据盘 profile——
|
# 0750 chv:chv,chv-browser 穿不过家目录去够数据盘 profile——
|
||||||
# ismount 判假、profile 静默回落本机盘(服务本身不报错)。
|
# ismount 判假、profile 静默回落本机盘(服务本身不报错)。
|
||||||
|
|||||||
+18
-4
@@ -11,7 +11,10 @@
|
|||||||
`python3 /app/host.py <args>`(单元隔离口径不变,不绕
|
`python3 /app/host.py <args>`(单元隔离口径不变,不绕
|
||||||
cell 直跑 host.py);子进程 setsid 脱离,execd 崩溃不
|
cell 直跑 host.py);子进程 setsid 脱离,execd 崩溃不
|
||||||
带走它;任务登记落盘 /run/chv/execd-tasks.json,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),
|
客户端鉴权:SO_PEERCRED 的 uid 须是 0 或 1000(root / chv),
|
||||||
其余一律拒绝。socket /run/chv/execd.sock 0600 root:root。
|
其余一律拒绝。socket /run/chv/execd.sock 0600 root:root。
|
||||||
@@ -365,13 +368,24 @@ class Execd:
|
|||||||
return {"ok": False,
|
return {"ok": False,
|
||||||
"error": "task not running: %s" % task.get("state")}
|
"error": "task not running: %s" % task.get("state")}
|
||||||
pid = task.get("pid")
|
pid = task.get("pid")
|
||||||
|
if not isinstance(pid, int):
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "task has no pid: %r" % (pid,)}
|
||||||
sig = signal.SIGTERM
|
sig = signal.SIGTERM
|
||||||
if req.get("signal") == "KILL":
|
if req.get("signal") == "KILL":
|
||||||
sig = signal.SIGKILL
|
sig = signal.SIGKILL
|
||||||
|
# 阶段 5:先杀整个进程组——spawn 一律 start_new_session,
|
||||||
|
# 任务 pid 即组长,组杀才能带走 runuser/cell fork 出的
|
||||||
|
# 子进程(单杀组长会留孤儿继续跑);组不存在(如已收走)
|
||||||
|
# 时回落单杀 pid,仍失败才如实报错。
|
||||||
try:
|
try:
|
||||||
os.kill(pid, sig)
|
os.killpg(pid, sig)
|
||||||
except OSError as exc:
|
except OSError:
|
||||||
return {"ok": False, "error": "kill failed: %s" % exc}
|
try:
|
||||||
|
os.kill(pid, sig)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "kill failed: %s" % exc}
|
||||||
self.store.update(task_id, state="killed",
|
self.store.update(task_id, state="killed",
|
||||||
ended_at=time.time())
|
ended_at=time.time())
|
||||||
return {"ok": True, "id": task_id, "pid": pid}
|
return {"ok": True, "id": task_id, "pid": pid}
|
||||||
|
|||||||
@@ -71,19 +71,67 @@ class FakePage:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FakeContext:
|
class FakePagePool:
|
||||||
|
"""多 context 共享的页轮换池:具名会话各建独立 context,
|
||||||
|
但页按 open 顺序从同一池里取(A 取第 1 页、B 取第 2 页),
|
||||||
|
与阶段 3 旧测试“两会话各拿一页”的期望一致。"""
|
||||||
|
|
||||||
def __init__(self, pages):
|
def __init__(self, pages):
|
||||||
self._pages = list(pages)
|
self._pages = list(pages)
|
||||||
self._idx = 0
|
self._idx = 0
|
||||||
self.launches = []
|
|
||||||
|
|
||||||
def new_page(self):
|
def next_page(self):
|
||||||
page = self._pages[self._idx % len(self._pages)]
|
page = self._pages[self._idx % len(self._pages)]
|
||||||
self._idx += 1
|
self._idx += 1
|
||||||
return page
|
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):
|
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:
|
class FakeEntered:
|
||||||
@@ -112,7 +160,9 @@ class FakePlaywright:
|
|||||||
|
|
||||||
|
|
||||||
def _agent(pages, tmp, full_browsers_dir=None):
|
def _agent(pages, tmp, full_browsers_dir=None):
|
||||||
ctx = FakeContext(pages)
|
pool = FakePagePool(pages)
|
||||||
|
browser = FakeBrowser(pool)
|
||||||
|
ctx = FakeContext(pool, browser=browser)
|
||||||
entered_holder = {}
|
entered_holder = {}
|
||||||
|
|
||||||
class PW:
|
class PW:
|
||||||
@@ -127,6 +177,7 @@ def _agent(pages, tmp, full_browsers_dir=None):
|
|||||||
agent = BROWSER.BrowserAgent(
|
agent = BROWSER.BrowserAgent(
|
||||||
playwright_factory=lambda: PW(), shots_dir=tmp,
|
playwright_factory=lambda: PW(), shots_dir=tmp,
|
||||||
profile_dir=os.path.join(tmp, "profile"),
|
profile_dir=os.path.join(tmp, "profile"),
|
||||||
|
sessions_dir=os.path.join(tmp, "sessions"),
|
||||||
full_browsers_dir=full_browsers_dir)
|
full_browsers_dir=full_browsers_dir)
|
||||||
return agent, ctx, entered_holder
|
return agent, ctx, entered_holder
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user