- test_chv_muse_round12.py: drop unused socket/time imports, drop unnecessary sys.path.insert (P3 pattern needs none), sort method-local imports (activity/capabilities, approval_card/channel/sentinel) - audit_trail.py: single-line method-local imports (I001) - swarm.py / presentation.py / browser_tasks.py: store-path fail-safe except narrowed from bare Exception to (sqlite3.Error, OSError) (DB/file failures only); transport spawn/ping/close handlers in browser_tasks.py deliberately stay bare Exception (arbitrary transport errors must map to failed/False/close_error) - NOT fixed (pre-existing style, left as-is): UP031 % formatting (used throughout the codebase incl. the copied lines), BLE001/ TRY004 in untouched code, F401 atomic_write + RUF059 swarm (P7/P8 pre-existing)
265 lines
9.6 KiB
Python
265 lines
9.6 KiB
Python
"""P8 browser_tasks:每浏览器任务一个实例——建/绑/毁生命周期。
|
||
|
||
对齐 Muse B4(官方安全文):每个浏览器任务由专门浏览器子代理
|
||
完成。P2 已把浏览器子代理独立成进程(chv-browser.py,常驻、
|
||
close 只关页不杀进程);本轨改为每任务一个实例:建实例、绑
|
||
定一个浏览器任务、用完即毁,状态绝不跨任务复用。
|
||
|
||
transport 接口:真实 transport 走 P2 的 broker 协议
|
||
(open/snapshot/screenshot/close/ping,经 /run/chv/browser.sock);
|
||
客机侧“每任务起一个全新 chv-browser.py、close 后杀进程”
|
||
是差距表 §3/§6 的跟进项(P2 的 chv-broker.py 里改,本轨不
|
||
复制 P2 的文件)。本层只管生命周期、单实例单任务与状态隔离,
|
||
transport 注入(测试用 fake)。
|
||
|
||
存储:宿主级 AppStateStore(round-12 已迁移,见
|
||
docs/chv-muse-p8-gap.md §6):state_dir/app-state.sqlite 的
|
||
``p8-browser-tasks`` 命名空间,一行存整份登记;旧
|
||
browser-tasks.json 首次加载时自动导入并改名 ``.migrated``;
|
||
行损坏/库打不开时回空结构(fail-safe,orphans 同款口径),
|
||
下次成功保存即覆盖自愈。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
import logging
|
||
import os
|
||
import time
|
||
import uuid
|
||
|
||
from cloud.chv.app_state import AppStateStore, host_state_db_path
|
||
from cloud.chv.compat215 import resolve_inside
|
||
|
||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||
|
||
_REGISTRY_FILENAME = "browser-tasks.json"
|
||
_KV_NAMESPACE = "p8-browser-tasks"
|
||
_KV_KEY = "registry"
|
||
|
||
|
||
class BrowserTransport:
|
||
"""浏览器实例 transport 接口:真实实现走 P2 的 broker 协议。
|
||
|
||
spawn 建实例、close 毁实例、ping 探活。子类须实现三者;
|
||
基类方法一律 NotImplementedError(绝不静默假装成功)。"""
|
||
|
||
def spawn(self, instance: dict) -> None:
|
||
"""建一个浏览器实例(instance 含 instance_id/task_id)。"""
|
||
raise NotImplementedError
|
||
|
||
def close(self, instance: dict) -> None:
|
||
"""毁一个浏览器实例(进程退出、状态清理)。"""
|
||
raise NotImplementedError
|
||
|
||
def ping(self, instance: dict) -> bool:
|
||
"""实例是否还活着。"""
|
||
raise NotImplementedError
|
||
|
||
|
||
def _open_store(state_dir: str) -> AppStateStore:
|
||
"""打开宿主级状态库(每次调用新建连接,用完即关)。"""
|
||
store = AppStateStore(host_state_db_path(state_dir))
|
||
store.open()
|
||
return store
|
||
|
||
|
||
def _registry_path(state_dir: str) -> str:
|
||
"""旧版实例登记文件路径(只用于一次性迁移导入)。"""
|
||
return os.path.join(state_dir, _REGISTRY_FILENAME)
|
||
|
||
|
||
def _quarantine(path: str) -> None:
|
||
"""把损坏的登记文件挪到 <path>.corrupt 旁(尽力而为)。"""
|
||
try:
|
||
os.replace(path, path + ".corrupt")
|
||
except OSError:
|
||
_LOGGER.warning("chv_browser_tasks_quarantine_failed "
|
||
"path=%s", path)
|
||
|
||
|
||
def _load_registry(state_dir: str) -> dict:
|
||
"""读登记:KV 行缺失时走一次性旧文件导入;行损坏/库打不
|
||
开时回空结构(fail-safe,orphans 同款口径;下次成功保存
|
||
即覆盖自愈)。"""
|
||
try:
|
||
with _open_store(state_dir) as store:
|
||
raw = store.kv_get(_KV_NAMESPACE, _KV_KEY)
|
||
except (sqlite3.Error, OSError):
|
||
_LOGGER.warning("chv_browser_tasks_store_unreadable "
|
||
"state_dir=%s", state_dir)
|
||
return {"instances": {}}
|
||
if raw is None:
|
||
return _import_legacy(state_dir)
|
||
try:
|
||
data = json.loads(raw)
|
||
except ValueError:
|
||
_LOGGER.warning("chv_browser_tasks_corrupt state_dir=%s",
|
||
state_dir)
|
||
return {"instances": {}}
|
||
if not isinstance(data, dict) \
|
||
or not isinstance(data.get("instances"), dict):
|
||
return {"instances": {}}
|
||
return data
|
||
|
||
|
||
def _import_legacy(state_dir: str) -> dict:
|
||
"""一次性导入旧 browser-tasks.json:合法即进 KV 并把文件
|
||
改名 ``.migrated``;无文件回空;损坏则 quarantine 后回空
|
||
(旧口径)。导入失败时返回读到的数据、文件保留,下次加
|
||
载重试。"""
|
||
path = _registry_path(state_dir)
|
||
try:
|
||
with open(path, "r") as fh:
|
||
data = json.load(fh)
|
||
except FileNotFoundError:
|
||
return {"instances": {}}
|
||
except (OSError, ValueError):
|
||
_quarantine(path)
|
||
return {"instances": {}}
|
||
if not isinstance(data, dict) \
|
||
or not isinstance(data.get("instances"), dict):
|
||
_quarantine(path)
|
||
return {"instances": {}}
|
||
try:
|
||
with _open_store(state_dir) as store:
|
||
store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data))
|
||
except (sqlite3.Error, OSError):
|
||
_LOGGER.warning("chv_browser_tasks_import_failed "
|
||
"state_dir=%s", state_dir)
|
||
return data
|
||
try:
|
||
os.replace(path, path + ".migrated")
|
||
except OSError:
|
||
pass
|
||
return data
|
||
|
||
|
||
def _save_registry(state_dir: str, data: dict) -> None:
|
||
"""落盘登记到 KV 行;写失败只记日志。"""
|
||
try:
|
||
with _open_store(state_dir) as store:
|
||
store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data))
|
||
except (sqlite3.Error, OSError):
|
||
_LOGGER.warning("chv_browser_tasks_save_failed "
|
||
"state_dir=%s", state_dir)
|
||
|
||
|
||
def _get_or_raise(data: dict, task_id: str) -> dict:
|
||
"""取某任务的实例;不存在即 ValueError。"""
|
||
instance = data.get("instances", {}).get(task_id)
|
||
if not isinstance(instance, dict):
|
||
raise ValueError("browser instance not found")
|
||
return instance
|
||
|
||
|
||
def create_instance(state_dir: str, task_id: str,
|
||
transport: BrowserTransport,
|
||
now: float | None = None) -> dict:
|
||
"""为一个浏览器任务建一个实例:单任务单实例(该任务已有
|
||
任何实例记录——active、destroyed 乃至 failed——即
|
||
ValueError;一个任务一生只有一个实例,毁后不复建,审计
|
||
轨迹留在登记里);先落盘 creating、再调 transport.spawn、
|
||
成功后置 active;spawn 抛错时记 failed 并原样抛出
|
||
(不留半个实例)。返回实例 dict。"""
|
||
if now is None:
|
||
now = time.time()
|
||
if not task_id or not isinstance(task_id, str):
|
||
raise ValueError("task_id required")
|
||
data = _load_registry(state_dir)
|
||
existing = data["instances"].get(task_id)
|
||
if isinstance(existing, dict):
|
||
raise ValueError("browser instance already exists")
|
||
instance_id = uuid.uuid4().hex
|
||
instance = {
|
||
"instance_id": instance_id,
|
||
"task_id": task_id,
|
||
"status": "creating",
|
||
"shots_dir": "browser-tasks/%s/shots" % instance_id,
|
||
"created_at": now,
|
||
}
|
||
data["instances"][task_id] = instance
|
||
_save_registry(state_dir, data)
|
||
try:
|
||
transport.spawn(instance)
|
||
except Exception:
|
||
instance["status"] = "failed"
|
||
_save_registry(state_dir, data)
|
||
raise
|
||
instance["status"] = "active"
|
||
_save_registry(state_dir, data)
|
||
return dict(instance)
|
||
|
||
|
||
def get_instance(state_dir: str, task_id: str) -> dict | None:
|
||
"""取某任务的实例;不存在回 None。"""
|
||
data = _load_registry(state_dir)
|
||
instance = data.get("instances", {}).get(task_id)
|
||
if not isinstance(instance, dict):
|
||
return None
|
||
return dict(instance)
|
||
|
||
|
||
def list_instances(state_dir: str) -> list[dict]:
|
||
"""列出全部实例(按创建时间排序)。"""
|
||
data = _load_registry(state_dir)
|
||
out = [dict(v) for v in data["instances"].values()
|
||
if isinstance(v, dict)]
|
||
out.sort(key=lambda i: (i.get("created_at") or 0,
|
||
i.get("task_id") or ""))
|
||
return out
|
||
|
||
|
||
def destroy_instance(state_dir: str, task_id: str,
|
||
transport: BrowserTransport,
|
||
now: float | None = None) -> dict:
|
||
"""毁一个实例:须是 active(不存在/已毁即 ValueError);
|
||
调 transport.close,close 抛错也记下并照常置 destroyed
|
||
(清理不能因一次 close 失败卡住),返回实例 dict。"""
|
||
if now is None:
|
||
now = time.time()
|
||
data = _load_registry(state_dir)
|
||
instance = _get_or_raise(data, task_id)
|
||
if instance.get("status") != "active":
|
||
raise ValueError("browser instance is not active")
|
||
close_error = ""
|
||
try:
|
||
transport.close(instance)
|
||
except Exception as exc:
|
||
close_error = str(exc)[:300]
|
||
instance["status"] = "destroyed"
|
||
instance["destroyed_at"] = now
|
||
instance["close_error"] = close_error
|
||
_save_registry(state_dir, data)
|
||
return dict(instance)
|
||
|
||
|
||
def ping_instance(state_dir: str, task_id: str,
|
||
transport: BrowserTransport) -> bool:
|
||
"""实例是否还活着:非 active 一律 False;active 时问
|
||
transport。"""
|
||
data = _load_registry(state_dir)
|
||
instance = data.get("instances", {}).get(task_id)
|
||
if not isinstance(instance, dict):
|
||
return False
|
||
if instance.get("status") != "active":
|
||
return False
|
||
try:
|
||
return bool(transport.ping(instance))
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def instance_shots_dir(state_dir: str, task_id: str) -> str | None:
|
||
"""取某任务实例的截图目录(state_dir 内、resolve_inside
|
||
校验,越界回 None);实例不存在回 None。"""
|
||
data = _load_registry(state_dir)
|
||
instance = data.get("instances", {}).get(task_id)
|
||
if not isinstance(instance, dict):
|
||
return None
|
||
rel = instance.get("shots_dir")
|
||
if not isinstance(rel, str) or not rel:
|
||
return None
|
||
return resolve_inside(state_dir, rel)
|