Kobako:用 Coding Agent 時測試能做多少防護
Kobako 的開發過程中有蠻多有趣的案例,延續上一篇 Kobako:用 AI 開發終究會翻車?的經歷,後續又再次撞到新的問題,而且是過去開發很擔心看到的 Segmentation Fault 錯誤,這表示在 Rust 和 WebAssembly 這段非 Ruby 的地方,很高機率做錯。
不穩定測試
Kobako 大概在 0.5.0 左右開始減少功能擴充收窄,核心功能完善後就該朝穩定版本前進,沒想到反而出現了不穩定測試(Flaky Test)的狀況,測試在沒有任何改動下有機率出錯。
因為在對功能收尾,也就繼續往下開發,很快的測試就沒有出現 Segmentation Fault 的錯誤訊息,這反而讓我更加緊張,因為如果不能重現那要捕捉到問題會變得困難很多。
使用 Coding Agent 開發的風險確實如此,即使我們認為 Rust 是記憶體安全的語言,整個過程也花費不少力氣減少 unsafe 使用把大部分 mruby C API 封裝起來,最終還是遇到這樣的狀況。
但是,至少我們還有測試,這比完全沒有任何測試,到正式被使用或者釋出的時候才被捕捉到,已經好上非常多。
垃圾回收
原本對排除問題不太期待,至少以過去的經驗來說是這樣。沒想到問題比預期還快被捕捉到,讓 Opus 4.7 跑幾次測試後,很快就確認是可以重現,而且給出「可能是垃圾回收造成」這樣的判斷。
而且運氣非常好,在 0.4.x 也就是上一個預定的版本中,並不會發生問題。只需要鎖定 0.4.x ~ Head 這一個範圍的提交,而這個提交剛好是針對 RPC -> Transport 重構所造成的問題,在 Ruby 和 Ruby Extension 這個邊界上,剛好出現一個物件標記的失誤。
在 Rust 我們對 Runtime 設計了一個 #on_dispatch 的 Handler 來處理從 Guest 向 Host 的方法呼叫,像是下面的情境
1Utils::Time.now # sandbox.define(:Utils).bind(:Time, Time)
2# Kobako::Transport::Proxy -> #method_missing -> __kobako__dispatch簡單來說,會透過 C API 從 WebAssembly 穿透,呼叫 Host Linker 來尋找符合 Utils::Time.now 這樣的方法,此時 #on_dispatch 負責處理如何呼叫。
但是,初始化順序是 Sandbox -> Runtime + Transport + Catalog 來進行的,對 Runtime 並不能事先知道 Transport 上的 Dispatcher 方法是什麼,也就需要借助 Sandbox 來幫忙安裝大致上如下
1 def install_dispatch_proc!
2 @runtime.on_dispatch = lambda do |request_bytes|
3 ...
4 end
5 end當時 Rust 的實作是這樣處理 #on_dispatch= 的行為
1pub(crate) fn set_on_dispatch(&self, proc_value: Value) -> Result<(), MagnusError> {
2 let mut store_ref = self.store.borrow_mut();
3 store_ref
4 .data_mut()
5 .bind_on_dispatch(Opaque::from(proc_value));
6 Ok(())
7}簡單來說,我們在 Rust 將 lambda do ... end 轉成一個指標,然後保存在 Rust 的特定物件上,當 Ruby 的垃圾回收啟動時,因為沒有任何 Ruby 變數持有這個 Lambda 物件,所以就被判斷成「可以回收」清理掉,當 mruby 真正呼叫時,就變成對空指標操作,進而觸發 Segmentation Fault 的問題。
這個用法對於 Magnus 來說很常見,大多數狀況也都不會出錯,只要是跟 Ruby 互動就不太會有問題,但我們剛好需要一些跟 WebAssembly 整合的邊界,這就讓 LLM(大型語言模型)訓練資料中覺得「不常見」而沒有採用另一種方式處理,在這個地方用了錯誤的設計。
至少 Opus 4.7 在定位到問題後,搭配 WebSearch 確認文件很快的就理解到 Magnus 提供的 Opaque::from 不會做垃圾回收的標記,那麼對 Ruby 來說就是可回收的物件。
解決方法也不複雜,我們將這個物件的生命週期跟 Runtime 綁定,當 Runtime 被拋棄後就自動釋放掉 #on_dispatch= 設定的 Ruby 變數,而透過 #on_dispatch= 的 Ruby 變數則標記為不可回收,那麼問題就被排除。
測試的防護力
這次剛好是一個蠻少見,但確實又是寫測試可以防禦到的案例。但也不是有測試就能完美的防禦,這就很看測試案例的設計,以及怎樣去覆蓋到關鍵的節點。
在過去的軟體開發流程上,測試大多還是一種成本,所以會優先做理想路線(Happy Path)的測試,確保在預期內行為的使用不太會出錯,如果還有餘力就會去覆蓋邊界情境(Edge Cases)來進一步加強。
到了使用 Coding Agent 後,寫程式碼本身的成本非常低,那就很適合用大量的測試做覆蓋。但覆蓋的範圍如果都是破碎、不完整的,實際上也不一定有用。像是只有單元測試,如果只測 Ruby 和 Rust 各自的物件、方法,這次的 Rust 保存 Ruby 變數的情境,就不會被捕捉到。
也因此,選擇「覆蓋的路徑」是一個很重要的判斷,走單元測試跟整合測試都能讓測試覆蓋到達 90% 以上,但是意義卻不同,中間只要有一條路線沒有完整跑完,就有機會漏掉「整合過程中的問題」而產生新的風險。
如果環境許可,我會推薦把端對端測試(End-to-End Testing)都覆蓋進去更理想,走 Behavior-Driven Development(行為驅動開發)也是不錯的方式,即使沒有要這樣做,也推薦設定理想路線的行為定義,像是 Kobako 的 docs/behavior.md 就有提供每一種使用情境的規範,這次的問題也是這樣捕捉到的。
總而言之,不要太怕麻煩而覺得不做,這類文件交給 AI 處理的成本已經非常低,但是有沒有測試覆蓋跟覆蓋在脆弱的地方,會對這類 AI 驅動開發的軟體穩定性和產出的可信度有很大的影響。
喜歡這篇文章?請我喝杯奶茶 🧋