提交 80bbb6ac authored 作者: 陈泽健's avatar 陈泽健

fix(定时任务): 看门狗失败执行也生成报告 + 单用例超时兜底 + Chromium 崩溃恢复

A 修复(看门狗失败执行也生成报告):
- scheduler_service.py 新增 _has_any_case_results 辅助函数
- UI/安全两条路径报告条件 run_ok -> run_ok or _has_any_case_results
- 解决 5.44 每日任务连续 4 次看门狗失败、报告目录无一文件

B 修复(定位并消除卡死点):
- B1: case_results 写入 start_time(由实际耗时回推),支持定位卡死用例
- B2: 用例级硬超时兜底(_case_start_mono + case_remaining_seconds,
      步骤/等待/微应用轮询均受用例剩余时间约束,防单用例无限挂起)
- B3: Chromium 崩溃自动恢复(崩溃标记检测 + 重建页面复用 context
      保留登录态,失败步骤起重试,每用例最多恢复一次)

部署配套:
- deploy/docker-compose.yml: app 容器日志轮转 50m x 5
- deploy_fix_560.py: 增加 scheduler_service.py / execution_service.py 上传
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 3a088e6a
......@@ -176,6 +176,18 @@ class PlaywrightExecutor:
self.ignore_https_errors: bool = self.config.get("ignore_https_errors", True)
self.auto_login: bool = self.config.get("auto_login", False)
self.step_retry_count: int = self.config.get("step_retry_count", 1)
# B2: 单用例硬超时(秒),由 execution_service 传入:
# 1) execute_case 外层信号超时(run_all_cases_sync 处 600s 强制失败)
# 2) 本执行器内部步骤级硬超时(_step_deadline_mono),防止个别长等待动作挂起
self.case_hard_timeout: int = int(self.config.get("case_timeout", 600))
# B2: 用例级硬超时起点(execute_case 开头置位,步骤额度受用例剩余时间约束)
self._case_start_mono: Optional[float] = None
# B2: 步骤级硬超时截止点(execute_step 每步置位,未开始为 None;
# 直接调用 _do_in_micro_apps 等内部方法时视为无步骤约束)
self._step_deadline_mono: Optional[float] = None
# 内部步骤层面单步硬超时:略小于用例超时,保证内外双保险且内先触发
# (取 min(用例超时, 默认 600) 再减 60s 余量,至少 120s)
self.step_hard_timeout: int = max(120, min(self.case_hard_timeout, 600) - 60)
# SelectorMapper 实例(用于 page_key + element_key 解析)
self._selector_mapper: SelectorMapper = SelectorMapper()
......@@ -208,6 +220,10 @@ class PlaywrightExecutor:
self._execution_id: Optional[str] = None
self._cancel_requested: bool = False
# B3: 崩溃自动恢复标记(每用例在 execute_case 开头重置,整执行器生命周期内
# 每个用例最多恢复一次,防止崩溃风暴无限重试)
self._crash_retried: bool = False
# 确保截图目录存在
os.makedirs(self.screenshot_dir, exist_ok=True)
......@@ -855,6 +871,9 @@ class PlaywrightExecutor:
return False if action != 'text' else None
deadline = time.time() + timeout / 1000
# B2:轮询总时长不得超过步骤硬超时剩余额度,防止长等待动作挂死整执行
if self._step_deadline_mono is not None:
deadline = min(deadline, self._step_deadline_mono)
while True:
try:
result = self._page.evaluate(
......@@ -912,6 +931,175 @@ class PlaywrightExecutor:
return False
# ==================== B2: 硬超时辅助(防单用例挂死整执行) ====================
def _deadline_remaining_ms(self, default_ms: int) -> int:
"""
当前步骤硬超时剩余毫秒(上限为请求方默认超时,下限 1ms)
同时受用例级硬超时剩余时间约束(_case_start_mono):单用例超 600s 后
所有等待动作均被压缩到 1ms 快速失败,由外层信号超时兜底强制失败。
Args:
default_ms (int): 请求方给出的默认超时(毫秒)
Returns:
int: 可用超时毫秒(>=1,超时后给 1ms 让动作正常超时失败而非抛错)
"""
if self._step_deadline_mono is None:
return max(int(default_ms or 0), 1)
remaining_ms = int((self._step_deadline_mono - time.monotonic()) * 1000)
if self._case_start_mono is not None:
case_ms = int(
(self._case_start_mono + (self.case_hard_timeout or 600) - time.monotonic())
* 1000
)
remaining_ms = min(remaining_ms, case_ms)
if remaining_ms <= 0:
return 1
return min(remaining_ms, max(int(default_ms or 0), 1))
def _bounded_wait(self, default_ms: int) -> int:
"""把请求方超时压缩到步骤/用例硬超时剩余额度内(至少 1ms)"""
return self._deadline_remaining_ms(default_ms)
def _remaining_step_ms(self) -> int:
"""当前步骤硬超时剩余毫秒(_wait_for_timeout 用,同时受用例超时约束)"""
if self._step_deadline_mono is None:
return 0
remaining_ms = int((self._step_deadline_mono - time.monotonic()) * 1000)
if self._case_start_mono is not None:
case_ms = int(
(self._case_start_mono + (self.case_hard_timeout or 600) - time.monotonic())
* 1000
)
remaining_ms = min(remaining_ms, case_ms)
return max(remaining_ms, 0)
def case_remaining_seconds(self) -> int:
"""
用例级硬超时剩余秒数(B2)
由 run_all_cases_sync 外层在 execute_case 开头置位 _case_start_mono,
executes 内部监听整体超时,用于约束恢复后重试额度;超时后为 0。
Returns:
int: 剩余秒数(>=0)
"""
if self._case_start_mono is None:
return int(self.case_hard_timeout or 600)
return max(
int((self._case_start_mono + (self.case_hard_timeout or 600)) - time.monotonic()),
0,
)
def _wait_for_timeout(self, ms: int) -> None:
"""
wait_for_timeout 的硬超时封装(B2)
Playwright 的 wait_for_timeout 无超时参数且不可中断,长延时 + 卡死页面时
会挂起数十分钟,导致执行被看门狗判死且无报告输出(5.44 事件形态)。
这里把等待时间压缩到步骤硬超时剩余额度内,超时即短路返回。
"""
if self._step_deadline_mono is not None:
remaining_ms = self._remaining_step_ms()
if remaining_ms <= 0:
return
ms = min(ms, remaining_ms)
try:
self._page.wait_for_timeout(int(ms))
except Exception:
pass
# ==================== B3: Chromium 崩溃自动恢复 ====================
# 崩溃类异常特征(不区分大小写匹配)
_CRASH_ERROR_MARKERS = (
"page crashed",
"target crashed",
"target closed",
"browser has been closed",
"context has been closed",
"target page, context or browser has been closed",
"browser closed",
"session closed",
"connection closed",
"websocket disconnected",
)
def _is_crash_error(self, exc: BaseException) -> bool:
"""
判断异常是否为浏览器崩溃类(Page/Target crashed、Target closed 等)
Args:
exc (BaseException): 待判断异常
Returns:
bool: True 表示崩溃类异常,可尝试重建页面恢复
"""
msg = str(exc or "").lower()
if not msg:
return False
return any(marker in msg for marker in self._CRASH_ERROR_MARKERS)
def _rebuild_page_after_crash(self) -> bool:
"""
B3: 浏览器崩溃后重建页面,恢复可继续执行的状态
恢复策略(由轻到重):
1. 仅重建 page(context/browser 存活时,session cookie 在 context 层保留),
重建后重置登录态标记并重新检测(若会话仍有效则自动恢复登录态);
2. context/browser 已崩溃时整体重启(stop + start)并重新登录。
Returns:
bool: 是否恢复成功(True 表示可从崩溃步骤继续执行)
"""
# 策略 1:context 存活 → 仅重建 page(登录 cookie 保留,重新检测登录态)
if self._context is not None:
try:
try:
if self._page is not None:
self._page.close()
except Exception:
pass
self._page = self._context.new_page()
self._page.set_default_timeout(self.timeout)
# 重建后重挂 webdriver 隐藏脚本(崩溃后 init script 随上下文保留,
# 但防御性重挂避免极端情况丢失)
try:
self._context.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
except Exception:
pass
self._current_scope = None
# 会话 cookie 仍在 context 中,重新检测登录态(而非强制重新登录)
self._detect_login_state()
logger.info(
f"B3: 页面已重建(复用 context),登录态={self._is_logged_in}, "
f"URL={self._page.url[:80]}"
)
return True
except Exception as ce:
logger.warning(f"B3: 仅重建页面失败(尝试整体重启): {ce}")
# 策略 2:context/browser 已崩溃 → 整体重启
try:
logger.warning("B3: context 不可用,执行浏览器整体重启...")
try:
self.stop()
except Exception:
pass
self.start()
self._current_scope = None
# 全新 context 无登录态,标记 False 由调用方/用例内登录步骤重新登录
self._is_logged_in = False
logger.info("B3: 浏览器已整体重启,登录态已重置(需重新登录)")
return True
except Exception as e:
logger.error(f"B3: 崩溃恢复失败: {e}")
return False
def _wait_for_element(
self,
selector: str,
......@@ -997,7 +1185,7 @@ class PlaywrightExecutor:
"""
if override is not None:
try:
self._page.wait_for_timeout(int(override))
self._wait_for_timeout(int(override))
except Exception:
pass
return
......@@ -1016,7 +1204,7 @@ class PlaywrightExecutor:
}
delay = defaults.get(action_type, 300)
try:
self._page.wait_for_timeout(delay)
self._wait_for_timeout(delay)
except Exception:
pass
......@@ -1093,6 +1281,10 @@ class PlaywrightExecutor:
self._ctx = ExecutionContext(case.get("parameters") or {})
self._ctx.set("caseName", case_name)
self._ctx.set("caseId", case_id)
# B3: 每个用例最多允许一次崩溃恢复(防崩溃风暴无限重试)
self._crash_retried = False
# B2: 用例级硬超时起点(本用例所有步骤的等待额度受其约束)
self._case_start_mono = time.monotonic()
# 合并配置
if case_config:
......@@ -1151,7 +1343,11 @@ class PlaywrightExecutor:
try:
logger.info(f"开始执行用例: {case_name} (id={case_id})")
for step in steps:
# 索引式步骤循环:崩溃恢复时可从失败步骤继续,不重跑已成功步骤(B3)
step_idx = 0
case_aborted = False
while step_idx < len(steps) and not case_aborted:
step = steps[step_idx]
order = step.get("order", 0)
# 如果是被标记为跳过的中间导航步骤,跳过执行
......@@ -1187,16 +1383,37 @@ class PlaywrightExecutor:
except Exception:
pass
logger.info(f"⏭ 跳过步骤 {order}: {step.get('name', '')}")
step_idx += 1
continue
# finally/on_failure 步骤不在主流程执行,统一推迟到主流程结束后的
# _run_finally_steps 后置块(否则会被执行两次——主循环一遍 + 后置一遍)
if step.get("run_on", "always") in ("finally", "on_failure"):
logger.debug(f"步骤 {order} run_on={step.get('run_on')},推迟到后置执行")
step_idx += 1
continue
# 执行步骤
# 执行步骤(execute_step 内部单步重试;崩溃类异常向上抛出由本层恢复)
try:
step_result = self.execute_step(step, callback=callback)
except Exception as se:
# B3: 浏览器崩溃 → 重建页面后从本步骤(失败点)重试一次,
# 不重跑已成功步骤;恢复失败则按执行异常处理(不无限重试)
if self._is_crash_error(se) and not self._crash_retried:
self._crash_retried = True
logger.warning(
f"检测到浏览器崩溃,尝试重建页面并重试本步骤: "
f"用例={case_name} 步骤={order} ({step.get('name', '')})"
)
if self._rebuild_page_after_crash():
# 重建后本步骤若立即重跑,步骤硬超时已耗尽(等待动作
# 已短路返回),重设步骤超时额度;同时受用例级超时约束
self._step_deadline_mono = time.monotonic() + min(
self.step_hard_timeout or 600,
self.case_remaining_seconds(),
)
continue
raise
result.steps_result.append(step_result)
# 同步登录态:登录点击/导航后可能已进入主页,
......@@ -1204,7 +1421,7 @@ class PlaywrightExecutor:
if not self._is_logged_in and step_result.status == "passed":
# 登录类点击步骤多等待一小段时间,确保 SPA 跳转完成
if "登录" in step_result.name and step_result.action == "click":
self._page.wait_for_timeout(1500)
self._wait_for_timeout(1500)
logger.debug(f"登录点击步骤 {order} 后等待 SPA 跳转并重新检测登录态")
self._detect_login_state()
......@@ -1238,8 +1455,11 @@ class PlaywrightExecutor:
log="⚠ 前置步骤失败,跳过执行",
)
result.steps_result.append(skipped)
case_aborted = True
break
step_idx += 1
# 所有步骤通过
if result.status == "pending":
result.status = "passed"
......@@ -1495,6 +1715,12 @@ class PlaywrightExecutor:
if isinstance(rendered, dict):
params = rendered
# B2: 单步执行硬超时兜底。个别动作(wait wait_for_timeout 等)可能请求毫秒级
# 长等待,网络异常时可能挂起数十分钟,导致整个执行被看门狗判死却无任何
# 报告输出(5.44 事件形态)。此处把硬超时置入步骤执行上下文,由 _bounded_*
# 辅助函数读取并约束各等待动作的上限。
self._step_deadline_mono = time.monotonic() + (self.step_hard_timeout or 600)
expected = step.get("expected", "")
force = step.get("force", params.get("force", False))
wait_after = step.get("wait_after", params.get("wait_after", None))
......@@ -2839,12 +3065,14 @@ class PlaywrightExecutor:
selectors = self._resolve_selectors(params)
if not selectors:
raise ValueError("选择器不能为空")
total_timeout = params.get("timeout", self.timeout)
# B2:等待总超时受步骤硬超时额度约束,防止个别长等待动作挂死整执行
total_timeout = self._bounded_wait(params.get("timeout", self.timeout))
fast_check_timeout = self._bounded_wait(1000)
# 阶段1:快速检查 — 每个选择器 1s 超时,看元素是否已存在
for selector in selectors:
try:
self._page.wait_for_selector(selector, timeout=1000)
self._page.wait_for_selector(selector, timeout=fast_check_timeout)
logger.debug(f"✓ 主页面等待元素成功(快速): {selector}")
return
except Exception:
......@@ -2854,13 +3082,13 @@ class PlaywrightExecutor:
if frame == self._page.main_frame:
continue
try:
frame.wait_for_selector(selector, timeout=1000)
frame.wait_for_selector(selector, timeout=fast_check_timeout)
logger.debug(f"✓ iframe 中等待元素成功(快速): {selector}")
return
except Exception:
continue
# micro-app 快速检查
if self._do_in_micro_apps('exists', selector, timeout=1000):
if self._do_in_micro_apps('exists', selector, timeout=fast_check_timeout):
logger.debug(f"✓ micro-app 中等待元素成功(快速): {selector}")
return
......
......@@ -19,7 +19,7 @@ import time
import threading
from pathlib import Path
from typing import Optional, List, Tuple, Dict, Any, Callable
from datetime import datetime
from datetime import datetime, timedelta
from concurrent.futures import ThreadPoolExecutor
from sqlalchemy.ext.asyncio import AsyncSession
......@@ -682,6 +682,11 @@ class ExecutionService:
case_result_obj.status = result.status
case_result_obj.duration = result.duration
# B1:由实际耗时回推 start_time,支持定位卡死用例
# (历史数据全为 NULL,修复后生效)
case_result_obj.start_time = datetime.now() - timedelta(
seconds=result.duration or 0
)
case_result_obj.end_time = datetime.now()
case_result_obj.retry_count = result.retry_count
case_result_obj.error_message = result.error_message
......
......@@ -23,12 +23,13 @@ import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy import func, select
from app.database import async_session_maker
from app.models.scheduled_task import ScheduledTask
from app.models.test_case import TestCase
from app.models.execution import Execution
from app.models.case_result import CaseResult
from app.services.execution_service import ExecutionService, is_execution_running
from app.services.report_service import ReportService
from app.utils.id_generator import generate_id
......@@ -146,6 +147,30 @@ async def _has_running_security_execution(db) -> bool:
return result.scalar_one_or_none() is not None
async def _has_any_case_results(db, execution_id: str) -> bool:
"""
执行是否已产生任何用例结果。
看门狗把「长时间无进展」的执行标记为 failed 时,工作线程已被中断,
本执行走到的失败/成功用例结果已实时写库。此时虽非 run_ok,仍应生成
报告,否则 auto_report 任务的每次失败执行都无报告输出(5.44 事件形态:
每日任务连续 4 次看门狗失败、报告目录无一文件)。
Args:
db: 异步数据库会话
execution_id (str): 执行记录 ID
Returns:
bool: 已存在至少一条用例结果
"""
result = await db.execute(
select(func.count(CaseResult.id)).where(
CaseResult.execution_id == execution_id,
).limit(1)
)
return (result.scalar_one() or 0) > 0
async def _run_security_task_once(
task: ScheduledTask,
db,
......@@ -212,8 +237,9 @@ async def _run_security_task_once(
run_ok = False
logger.error(f"[定时任务] 安全任务「{task.name}」执行异常: {e}")
# 5. 自动生成报告(执行成功后才生成)
if task.auto_report and run_ok:
# 5. 自动生成报告(执行成功后才生成;看门狗失败但已有用例结果也生成,
# 避免多次失败执行无报告输出——5.44 每日任务连续 4 次看门狗失败均无报告)
if task.auto_report and (run_ok or await _has_any_case_results(db, execution.id)):
try:
report_service = SecurityReportService()
await report_service.generate_report(execution.id, db)
......@@ -336,8 +362,10 @@ async def run_task_once(task_id: str, manual: bool = False) -> Optional[str]:
run_ok = False
logger.error(f"[定时任务] 任务「{task.name}」执行异常: {e}")
# 5. 自动生成报告(执行成功后才生成,避免冲突失败记录产出空报告)
if task.auto_report and run_ok:
# 5. 自动生成报告(执行成功后才生成,避免冲突失败记录产出空报告;
# 看门狗失败但已有用例结果也生成,避免多次失败执行无报告输出——
# 5.44 每日任务连续 4 次看门狗失败、报告目录无一文件)
if task.auto_report and (run_ok or await _has_any_case_results(db, execution.id)):
try:
report_service = ReportService()
html = await report_service.generate_html(execution.id, db)
......
......@@ -36,6 +36,8 @@ PROD_CASE = "case_13650e0406e64779b66e6fa5de35c24a"
# 本地 → 服务器路径
UPLOADS = [
("app/executors/playwright_executor.py", BACKEND_HOST + "/app/executors/playwright_executor.py"),
("app/services/scheduler_service.py", BACKEND_HOST + "/app/services/scheduler_service.py"),
("app/services/execution_service.py", BACKEND_HOST + "/app/services/execution_service.py"),
("scripts/fix_step12.py", BACKEND_HOST + "/scripts/fix_step12.py"),
("scripts/run_case_verify.py", BACKEND_HOST + "/scripts/run_case_verify.py"),
]
......
......@@ -63,6 +63,13 @@ services:
- /data/third_party/plat-auto-test/logs:/app/logs
ports:
- "80:80"
# 容器日志轮转:限制 json-file 日志大小与文件数,避免宿主磁盘被撑满
# (5.44 卡死时段 docker logs 已因默认无轮转丢失,见执行计划遗留问题 3)
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
- plat-auto-test-net
healthcheck:
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论