原本想修一個後台點擊失效的 Issue,卻發現它早已解決。繼續讀代碼後,我追到了臨時輸入喚醒與持久後台 lease 的交互邊界,也在 Review 和故障注入中重新理解了狀態所有權。
這次貢獻,是從一個已經修好的 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 卻已經關閉。
後續 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() 完成有界的臨時喚醒、渲染就緒檢查、輸入和清理。
異常分支返回的結果是:
{ "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 的步驟。這樣測到的,是臨時輸入邏輯本身,而不是它和持久後台機制相遇後的行為。
這次補充的 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,我想自己會多問一句:
這個修復放到項目現在的設計裏,它依賴的假設還成立嗎?
有時候,繼續把這個問題弄明白,本身就能成為下一次貢獻的起點。