简

原本想修一個後台點擊失效的 Issue,卻發現它早已解決。繼續讀代碼後,我追到了臨時輸入喚醒與持久後台 lease 的交互邊界,也在 Review 和故障注入中重新理解了狀態所有權。

Issue 已經修好了,為什麼還要補一個 PR:BrowserSkill 後台輸入的狀態加固
22 分鐘
4422 字

這次貢獻,是從一個已經修好的 Issue 開始的。

我最初在 Tencent BrowserSkill 的 Issue 列表裏看到 #242:後台執行點擊時,CLI 返回成功,頁面卻沒有收到任何點擊事件。對自動操作瀏覽器的 Agent 來説,這比直接報錯更麻煩——它以為自己已經點到了,可能就會沿着這個錯誤的前提繼續操作。

我本來準備從這裏入手。但查看當前 main 後發現,原問題已經由 PR #261 修復了。

按以前的想法,我可能會換一個還沒解決的 Issue。這次我又往下讀了一些:當初為什麼這樣修?現在的命令還是沿着同一條路徑執行嗎?結果發現,項目還引入了另一套持久後台運行機制,兩層邏輯都會控制同一個瀏覽器狀態,卻沒有完全對齊彼此的所有權。

最後,這段排查變成了 PR #311。它不是重新修一遍 #242,而是為已有修復補上一層組合場景下的保護。這個過程裏,我第一次比較具體地體會到:一個 Issue 標記為已解決,不代表圍繞它的設計就不需要繼續理解了。

已修復的 Issue,也值得先弄清楚它為什麼被修好h2

BrowserSkill 讓 AI Agent 通過 CLI 和瀏覽器擴展操作真實瀏覽器。這裏討論的後台輸入,是讓 Agent 在不搶走用户窗口焦點的情況下,仍能完成頁面交互。

Issue #242 報告的是 0.2.1:使用 bsk session start --no-focus 啓動後台 Agent Window 後,再執行點擊,CLI 看起來成功了,座標也正確,但頁面沒有收到 pointerdown、mousedown、mouseup 或 click。

問題在於,CDP 接受輸入命令,並不等於頁面真的處理了輸入。原來的鏈路缺少對頁面輸入狀態的確認。

後來 #261 加入了 withInputReady()。它先讀取 document.visibilityState;如果頁面是 hidden,就臨時打開 Emulation.setFocusEmulationEnabled(true),等待渲染端準備好,再發送輸入,最後清理這次臨時開啓的狀態。

這套輸入 readiness 邏輯已經包含在 0.3.0 中,維護者也確認了原問題的修復。因此,我需要先把自己的判斷擺正:接下來發現的問題,不能直接説成“#242 沒有修好”。

臨時喚醒和持久運行,不能各自管理同一個狀態h2

繼續看調用鏈時,我注意到了 PR #249 引入的 BackgroundExecution。

它處理的是另一個需求:後台頁面加載完成後,一些依賴可見性或 requestAnimationFrame 的邏輯仍可能停住。於是,當前 Session 控制的 tab 會獲得一份持久後台執行 lease,讓相關 override 跨命令保持生效,直到釋放控制時再清理。

這裏的 lease,可以先理解成“這個 Session 持有這項後台運行狀態的控制權”。它與 withInputReady() 的生命週期不同:一層只負責一次輸入,另一層需要維持多個命令之間的狀態。

真實的工具執行也不是直接進入 click handler。ToolDispatcher 會先準備後台執行、獲取持久 lease,再進入 handler 和 withInputReady()。

兩套機制各自解決的問題都合理。但放在同一條鏈路裏,就需要回答一個之前沒有説清楚的問題:這個 focus emulation 狀態,究竟由誰來關閉?

緩存還記着 ON,瀏覽器卻已經變成 OFFh2

當時 withInputReady() 並不知道當前 Session 是否已經持有 persistent lease。如果 lease 已被持有,頁面卻仍報告 hidden,它就可能繼續進入臨時喚醒路徑。

先發一次 true,完成輸入,再在 finally 裏發一次 false。對臨時邏輯而言,這像是正常收尾;對持久機制而言,卻相當於別人把它負責維持的狀態關掉了。

更麻煩的是,臨時邏輯直接通過 CDP 修改狀態,沒有同步更新 BackgroundExecution 的 applied-state cache。於是控制器內部仍記錄着“應該開啓,也已經開啓”,Chrome 裏的實際 override 卻已經關閉。

圖 1 · 一次臨時清理,讓控制器記錄與瀏覽器實際狀態分開了
持久狀態與臨時清理的交互 獲取持久 lease 後,控制器記錄和瀏覽器實際 override 都為開啓。在頁面仍隱藏的邊界狀態下,臨時 readiness 的清理關閉 override,卻沒有更新持久控制器的緩存。之後控制器可能跳過重新應用,導致狀態持續不一致。 獲取 persistent lease 控制器記錄 ON · Chrome override ON 頁面仍 hidden,進入臨時 readiness 再次開啓 → 等待就緒 → 執行輸入 臨時 finally 發送 OFF 沒有更新持久控制器的緩存 BackgroundExecution:ON desired / applied 記錄仍一致 Chrome override:OFF 實際狀態已被臨時清理關閉 之後 synchronize() 可能依據緩存跳過重新應用
這裏展示 lease 已持有、頁面仍 hidden 的邊界路徑,並非每次後台點擊都會發生。小屏可在圖內左右滑動。

後續 ensureAttached() 觸發 BackgroundExecution.synchronize() 時,它還可能根據緩存與 attachment 的匹配情況提前返回,認為不需要重新應用 override。這樣,狀態失配就不只是一次臨時關閉,還可能影響後面的命令。

不過,讀到這裏還不能把它説成所有後台頁面都會發生的故障。Review 特別提醒了這個邊界:維護者在 macOS 的 Chrome 153 上觀察到,focus emulation 開啓後,頁面會同步變為 visible,最小化窗口也一樣。在這個正常路徑上,臨時 fallback 根本不會被觸發。

我需要保護的,是 lease 已被持有,頁面卻仍然 hidden 的不一致狀態。把這一點説清楚,也讓我沒有把“代碼裏存在的交互缺口”直接等同於“每個平台都能自然復現”。

持有控制權,不代表頁面現在已經準備好h2

我最先想到的辦法,是讓輸入層知道持久 lease 的存在。因此新增了一個只讀查詢:

ownsBackgroundExecution(sessionId, tabId)

最初我的思路很直接:既然後台執行已經有人負責,withInputReady() 就不要再臨時開關 focus emulation,直接發送輸入。

這確實避開了兩個 owner 互相覆蓋的問題,卻把另一層保護一起繞過了。Review 指出:“已經持有 lease,但頁面仍然 hidden”恰恰是不能放心發送輸入的時候。 如果不再檢查實際可見性,就可能重新回到“命令成功,頁面沒處理”的狀態。

所以最終實現仍然讀取 document.visibilityState。ownership 查詢回答的是“這個 Session 是否請求持有這份持久 override”,不是“Chrome 此刻一定已經應用了它”。

我原來很容易把這兩件事混在一起:有人負責,就應該已經生效。但這次它們之間的差別,正好決定了能不能繼續發送輸入。

狀態不確定時,明確失敗比假裝點到了更重要h2

最後的處理規則分成了兩個維度:先區分是否持有持久 lease,再檢查頁面是否 hidden。

持有 lease 且頁面 visible 時,可以直接進入輸入操作,不做臨時 focus toggle,也不額外走 readiness screenshot。持有 lease 卻仍 hidden 時,則在發送輸入前返回明確錯誤,並且不由輸入層私自修改持久狀態。

沒有持久 lease 的路徑,繼續保留原來的行為:頁面可見就正常輸入;頁面隱藏時,由 withInputReady() 完成有界的臨時喚醒、渲染就緒檢查、輸入和清理。

圖 2 · 先確認誰負責狀態,再決定能不能發送輸入
PR 311 的輸入就緒處理規則 持有 lease 和沒有 lease 的頁面都檢查 visibility。兩者在 visible 時都正常輸入;持有 lease 但 hidden 時不發送輸入、不修改 focus emulation,返回 input_not_ready。沒有 lease 且 hidden 時保留臨時喚醒、渲染就緒檢查、輸入和清理路徑。本圖對應 PR 311 的實現,不包含後續自動恢復機制。 是否持有 persistent lease? 持有 未持有 仍然讀取 visibilityState 讀取 visibilityState visible hidden visible hidden 正常輸入 不臨時開關 持久 override 明確拒絕輸入 input_not_ready effect_state: none 不輸入 · 不開關 focus 正常輸入 不需要臨時喚醒 臨時喚醒 ON → 渲染就緒 → 輸入 最後清理臨時狀態
圖中規則對應 #311,不包含後續自動恢復。小屏可在圖內左右滑動。

異常分支返回的結果是:

{
"code": "cdp_failed",
"data": {
"reason": "input_not_ready",
"effect_state": "none"
}
}

這裏的 effect_state: none 指的是這次輸入沒有發送出去,而不是説整個工具調用沒有做過任何準備工作。

這次選擇的是 fail closed:無法確認頁面準備好時,先明確拒絕輸入。對於會根據結果繼續行動的 Agent,我覺得“沒有點到,而且清楚告訴你沒有點到”,比返回一個不可靠的成功更有價值。

組合問題,要沿着真實執行鏈路去測試h2

修復時還有一個讓我印象比較深的地方:直接測 click handler,並不一定能測到這個問題。

handler 級測試可以覆蓋 withInputReady(),但如果繞過 ToolDispatcher,前面就沒有獲取 persistent lease 的步驟。這樣測到的,是臨時輸入邏輯本身,而不是它和持久後台機制相遇後的行為。

圖 3 · 同樣進入 handler,前面有沒有 lease,測到的是不同的場景
Handler 與 Dispatcher 測試鏈路的區別 沒有先獲取 lease 的直接 handler 測試,覆蓋的是獨立輸入 readiness 路徑;Dispatcher 級測試先準備後台執行、獲取 lease,再進入 handler 和 readiness,因此能覆蓋兩個機制的組合邊界。 直接調用 handler,未預先獲取 lease 這次補充的 Dispatcher 級迴歸 ToolDispatcher 準備後台執行,先獲取持久 lease handleClick() handleClick() withInputReady() withInputReady() 覆蓋獨立 readiness 路徑 覆蓋 lease 與輸入的組合邊界
區別不在於測試名稱,而在於是否真正包含“先獲取 lease,再進入輸入層”的前置狀態。

這次補充的 Dispatcher 迴歸測試,先確認獲取 lease、查詢 ownership、讀取可見性、發送輸入的順序。隨後把頁面狀態改為 hidden,驗證返回 input_not_ready,既不發送 Input.dispatchMouseEvent,也不發送臨時 focus 命令。再把可見性改回 visible,下一次點擊應當能夠正常執行。

這組測試檢查的是控制流和組件協作,不是讓真實瀏覽器憑空恢復一個已經丟失的 override。把這兩種驗證分開,我才更清楚每個測試究竟在證明什麼。

故障注入驗證的,是安全失敗,而不是自動恢復h2

Review 還給了一個很具體的驗證建議:在接近原 Issue 的環境裏,人為製造一次“lease 還在,實際 override 卻丟了”的狀態。

我最後在 Windows 11、Chrome for Testing 153、DPR 1.5 的環境中啓動 --no-focus Session,最小化 Agent Window,再從擴展 Service Worker 主動關閉 focus emulation。

這時,Session 仍持有 lease,瀏覽器裏的 override 已關閉,頁面報告 hidden。執行 bsk click 後,返回了 input_not_ready 和 effect_state: none,頁面點擊計數保持為零。

它沒有恢復點擊。這個結果乍看像是“還是沒點到”,但對 #311 來説,區別已經很明確:以前可能返回一個假的成功,現在會在輸入前拒絕,並告訴調用方頁面還沒準備好。

當時我把自動恢復拆到了 Issue #355。先守住“不確定時不要報告成功”的邊界,再討論由持久狀態的管理者恢復 override,而不是讓輸入層重新越權處理。

補充到寫下這篇文章時:恢復丟失 lease 後再嘗試隱藏頁面輸入的後續改動,已通過 PR #374 合併。這裏記錄的規則和故障注入結果,仍然對應我這次 #311 的範圍,而不是項目最新版本的全部行為。

這次讓我學會,在 Fixed 後面多問一步h2

#311 對我比較特別,是因為我最開始並不是衝着這個改動去的。我只是想找一個自己能處理的 Issue,結果先發現它已經解決,再一點點讀到兩個狀態管理層之間的邊界。

以前我更習慣從明確的問題描述開始:找到原因,寫修復,等待 Review。這次讓我意識到,已有修復同樣有值得學習的地方。理解它為什麼成立,才能看見項目繼續演化之後,哪些前提需要重新確認。

Review 也讓我放下了一個很自然、卻不夠嚴謹的想法:“既然有 lease,就説明狀態已經準備好了。”現在再看到類似代碼,我會更願意分開想:誰聲明負責這個狀態,誰實際修改它,底層現在又是什麼樣子。

回頭看,這次最具體的收穫,是我第一次把 ownership、緩存記錄和瀏覽器實際狀態之間的差別,順着代碼、測試和一次故障注入連了起來。

以後再看到一個已修復的 Issue,我想自己會多問一句:

這個修復放到項目現在的設計裏,它依賴的假設還成立嗎?

有時候,繼續把這個問題弄明白,本身就能成為下一次貢獻的起點。