Execution context was destroyed:自动化脚本里的导航竞态与重试封装
Execution context was destroyed:自动化脚本里的导航竞态与重试封装
一、一个看起来很像"失败"的成功
今天的自动化脚本在第 37 秒崩了,堆栈末尾是这一行:
playwright._impl._errors.Error: Page.evaluate:
Execution context was destroyed, most likely because of a navigation
如果只看日志,结论会是"点击没生效、需要重试"。但打开浏览器看,页面已经跳到了目标地址,环境也正常启动了。也就是说:动作成功了,脚本却以为失败了。
这是自动化里最贵的一类 bug——不是脚本没做到,而是脚本不知道自己做到了。它的直接后果是重试,而重试会再点一次"立即进入"、再申请一次容器、再提交一次表单。在按量计费的资源上,这不是多花几秒钟的问题。
二、为什么会抛这个错
CDP 里的 Runtime.evaluate 是绑定在某一个具体 document 的 execution context 上的。导航发生时,浏览器会销毁旧的 context 并创建新的,中间有一个极短的时间窗:
- 点击触发
location变更; - 旧 document 开始卸载,context 被销毁;
- 我们的
evaluate正好落在第 2 步之后、新 context 可用之前。
Playwright、Puppeteer、Selenium 在这一刻都会抛错。它不是网络问题,也不是选择器问题,而是时序问题:evaluate 要求"当前这个文档还活着",而导航正在杀死它。
三、反模式:把所有异常当成"没点中"
最常见的错误写法:
click(enter_btn)
wait(5) # 固定等待
state = page.evaluate(JS) # 导航竞态在这里炸
if not state["ok"]:
click(enter_btn) # 重试 -> 重复动作
两个问题叠在一起:固定等待既可能太短(还没跳完),也可能太长(浪费时间);而没有区分错误类型,让一个纯粹的竞态错误走进了"重试业务动作"的分支。
四、正确的顺序:先收敛状态,再读状态
改造后的思路是三步:
- 点击之后不立刻读页面,先等导航收敛;
- 读状态时只对导航类错误重试,并且重试的是"读",不是"点";
- 判定成败看目标状态是否出现(URL、关键元素、倒计时),而不是"点击有没有抛异常"。
from playwright.sync_api import Error as PWError
NAV_ERRORS = ("Execution context was destroyed",
"Cannot find context with specified id",
"Target page, context or browser has been closed")
def settle(page, timeout_ms=15000):
"""等导航收敛:URL 稳定 + document.readyState 完成。"""
try:
page.wait_for_load_state("domcontentloaded", timeout=timeout_ms)
except PWError:
pass # SPA 软导航不一定触发 load 事件
return page.url
def safe_eval(page, js, arg=None, tries=6, gap_ms=1000):
"""只对导航竞态重试读取,绝不重试业务动作。"""
last = None
for _ in range(tries):
try:
return page.evaluate(js, arg) if arg is not None else page.evaluate(js)
except PWError as e:
if not any(k in str(e) for k in NAV_ERRORS):
raise # 真正的逻辑错误,立刻暴露
last = e
page.wait_for_timeout(gap_ms)
raise last
关键点在于 safe_eval 里的那句 raise:只吞导航错误,其它一律上抛。如果连 TypeError: Cannot read properties of null 也一起重试六次,日志会被六条同样的错误淹没,真正的 bug 反而藏得更深。
五、成败判据要落在"结果"上
点击是一次意图,URL 才是事实。所以:
before = page.url
click(enter_btn)
settle(page)
after = safe_eval(page, "() => location.href")
if after != before:
log("entered:", after) # 已跳转 = 动作成功,无需重试点击
else:
log("no navigation; inspect popup/modal before retrying")
今天这次跳转就是 developer.huaweicloud.com/devspace → devstation.developer.huaweicloud.com/aidevspace。用 after != before 一句判断,就替代了原来那段"点—等—读—错—再点"的循环。
六、顺带一个同类坑:evaluate(expr, None)
排查时我还在脚本里留了一句"noop safety":
page.evaluate("(el) => el.scrollIntoView({block:'center'})", None)
本意是"什么都不做",结果 Playwright 把 None 当作参数传进函数,el 就是 null,直接抛 TypeError: Cannot read properties of null (reading 'scrollIntoView')。它的错误长相和导航问题完全不像,却发生在同一段流程里,很容易被误判成"页面结构变了"。
教训很朴素:自动化脚本里不要写"看起来无害"的空操作。要么真的做事,要么删掉。
七、可观测性:让日志能自证
改造后每个关键节点都打三元组:时间戳 + URL + 正文字符数。
[8s] cd=02:59:45 prep=False len=82
[16s] cd=02:59:37 prep=False len=82
len 恒为 82 说明文档结构稳定,cd 在递减说明环境真的在跑。只看"没报错"是不够的——一段 110 秒里一个字都没变的页面,日志同样可以写得干干净净、exit 0。
八、小结
- 导航竞态是读操作的问题,重试的也应该是读,而不是写。
- 用
URL 变化、关键元素出现、计数在变这类外部事实判定成功,别用"调用没抛异常"。 - 重试要按错误类型白名单,其它错误直接冒泡。
- 别写空操作语句;
None参数在 JS 里就是null。
一句可以带走的经验:自动化脚本真正的可靠性,来自它区分"动作失败"和"观测失败"的能力。
- 点赞
- 收藏
- 关注作者
评论(0)