Theme / v0.10.8

Codebase Memory

程式碼記憶與知識圖譜 MCP

實戰範例

實戰範例 002:利用 Codebase Memory 追蹤 PLC 連線死結

在一個 10 萬行 C# .NET WinForms 遺留專案中,修復因為多執行緒嵌套鎖定 (lock) 導致 UI 介面隨機卡死、PLC 通訊中斷的死結 Bug。

實戰背景

本案例源於一個真實的工業自動化控制系統專案。該專案主要使用 C# .NET Framework 4.8 與 Windows Forms 構建,專門負責工廠機台的運動控制(Motion Control)與 PLC 通訊。隨著開發年限增長,專案代碼量已逼近 10 萬行。

現場調試工程師回報了一個嚴重的隨機性故障:機台在啟動運行 10 至 15 分鐘後,WinForms 的主 UI 介面會突然當死(無法點擊、視窗顯示未響應),PLC 連線也隨之斷開,必須強制結束行程重啟。

由於系統中包含大量的背景執行緒輪詢、非同步 TCP Socket 通訊以及複雜的同步鎖(lock),傳統的 ripgrep 搜尋只能找到代碼中的字串,卻完全無法梳理出多個執行緒在執行期是如何互相卡死的。因此,我們將使用 OpenSpec 完整工作流 配合 Codebase Memory MCP 來進行調查、定位並徹底解決此 Bug。


規格定義

本次變更計畫的 ID 為 fix-plc-deadlock。我們在 OpenSpec 的增量規格 (Spec Delta) 中定義了以下嚴格的驗收標準:

# 實戰範例 002:openspec/changes/fix-plc-deadlock/specs/plc-communication/spec.md

## ADDED Requirements

### Requirement: Non-blocking UI communication
系統進行任何 PLC 暫存器讀寫通訊時,WinForms 主 UI 執行緒必須(SHALL)保持完全非阻塞(Non-blocking)。
- 執行寫入動作時,主執行緒的平均回應時間必須(SHALL)小於 5 毫秒。

#### Scenario: Write trigger during network timeout
- **WHEN** 使用者在網路中斷或通訊逾時期間點擊「寫入暫存器」按鈕
- **THEN** 主 UI 介面必須(SHALL)維持可自由拖曳、點擊的響應狀態,且在 2 秒內於畫面顯示「通訊逾時」錯誤,絕不可卡死。

### Requirement: Deadlock-free threading model
背景輪詢服務與 UI 通訊執行緒之間,必須(SHALL)採用無死結(Deadlock-free)的非阻塞佇列(Queue)通訊模型,嚴禁在多重鎖嵌套(Nested locks)中進行同步網路 I/O。

端到端 8 輪 AI 協作紀錄

以下是 You (開發者) 與 AI 助手在實作此 Bug 修復時的完整對話歷史。

── 第 1 輪:進入探索模式與專案掃描 ──

  • You:
    /opsx:explore "現場反應 PlcControlSystem 專案運行 10 分鐘後 UI 會突然卡死且 PLC 斷線。我想用 codebase-memory 調查。幫我列出專案列表並分析高階架構。"
  • AI:

    好的,已進入探索模式。我將呼叫 codebase-memory 的 list_projects 工具來驗證已索引的專案。

    CM 呼叫: list_projects()
    CM 回傳: [ { "name": "PlcControlSystem", "path": "D:/Repo/PlcSystem", "file_count": 328 } ]

    專案確認存在。接下來,我將呼叫 get_architecture 來取得 PlcControlSystem 的系統拓撲與核心元件關係。

    CM 呼叫: get_architecture(project_name: "PlcControlSystem")
    CM 回傳:

    • UI Entrypoint: UI/MainForm.cs (主視窗)
    • Communication Service: Services/PlcConnection.cs (負責 Socket 通訊)
    • Background Polling Service: Services/MonitorService.cs (背景輪詢執行緒)

    架構診斷
    這是一個傳統的三層 WinForms 架構。UI 執行緒 (MainForm) 會直接調用 PlcConnection 進行暫存器設定;同時,背景執行緒 (MonitorService) 也在無休止地輪詢 PlcConnection 以讀取狀態。由於這兩個執行緒同時存取同一個 Socket,必定存在同步鎖定。我將進一步搜尋 PlcConnection 內部的方法結構。


── 第 2 輪:搜尋核心通訊 Symbol ──

  • You:
    在知識圖譜中搜尋 PlcConnection 這個類別,看看它宣告了哪些方法,以及它使用的是什麼鎖。
  • AI:

    好的,我將呼叫 search_graph 定位 PlcConnection 的詳細節點。

    CM 呼叫: search_graph(query: "PlcConnection", type: "class")
    CM 回傳:

    • Class: PlcConnection (於 Services/PlcConnection.cs 定義)
    • 內部成員方法:Connect(), ReadRegister(int addr), WriteRegister(int addr, int val)
    • 關聯欄位:private readonly object _socketLock = new object();

    為了看清它是如何實作同步控制的,我將呼叫 get_code_snippet 讀取 PlcConnection.cs 中關於鎖定的實作。

    CM 呼叫: get_code_snippet(file_path: "Services/PlcConnection.cs", start_line: 30, end_line: 75)
    CM 回傳:

    public void WriteRegister(int address, int value)
    {
        lock (_socketLock)
        {
            byte[] packet = BuildModbusPacket(address, value);
            _socket.Send(packet);
            byte[] response = new byte[256];
            // 警告:這是一個阻塞式的 Socket 接收,且沒有設定 Timeout
            _socket.Receive(response); 
            ProcessResponse(response);
        }
    }

    分析發現
    WriteRegister 使用了同步阻塞的 lock (_socketLock),且在持鎖期間執行了沒有超時限制的 _socket.Receive。如果 PLC 連線因實體原因斷開或未回應,此鎖將被永久佔用。 下一步,我需要查明有哪些執行緒在呼叫這個 WriteRegister


── 第 3 輪:追蹤入站呼叫鏈 ──

  • You:
    追蹤 PlcConnection.WriteRegister 被哪些外部方法呼叫。我們要找出是哪兩個執行緒產生了資源競爭。
  • AI:

    我將呼叫 trace_path 以追蹤 WriteRegister 的入站(Inbound)呼叫路徑。

    CM 呼叫: trace_path(node_id: "PlcConnection:WriteRegister", direction: "inbound")
    CM 回傳:

    • 路徑 A: UI/MainForm.cs -> btnSave_Click -> PlcConnection.WriteRegister (主 UI 執行緒)
    • 路徑 B: Services/MonitorService.cs -> RunPollingLoop -> PlcConnection.WriteRegister (背景輪詢執行緒)

    呼叫鏈分析

    1. 當用戶點擊儲存按鈕,UI 執行緒會進入 WriteRegister 並嘗試取得 _socketLock
    2. 背景輪詢執行緒在 RunPollingLoop 中也會不斷呼叫 WriteRegister

    這邊極易發生競爭。我們還需要調查背景服務 MonitorService.cs 內部是否還持有其它的鎖,看看是否存在鎖的嵌套衝突。


── 第 4 輪:分析背景輪詢鎖定 ──

  • You:
    讀取 MonitorService.cs 的核心代碼,看看它在呼叫 PlcConnection 時持有了什麼鎖。
  • AI:

    好的,我讀取 Services/MonitorService.cs 的代碼片段。

    CM 呼叫: get_code_snippet(file_path: "Services/MonitorService.cs", start_line: 45, end_line: 85)
    CM 回傳:

    public class MonitorService
    {
        private readonly object _stateLock = new object();
        public void RunPollingLoop()
        {
            while (_running)
            {
                lock (_stateLock)
                {
                    int status = _plc.ReadRegister(200);
                    if (status == -1) 
                    {
                        // 警告!在持有 _stateLock 的情況下,調用了會去拿 _socketLock 的方法
                        _plc.WriteRegister(100, 99); 
                    }
                }
                Thread.Sleep(100);
            }
        }
    }

    發現重大的互鎖衝突
    背景執行緒在持有 _stateLock 時,呼叫了需要取得 _socketLockWriteRegister
    如果 UI 執行緒在某個地方,以相反的順序(先拿 _socketLock,再拿 _stateLock)進行了調用,就會瞬間引發雙執行緒死結 (Deadlock)!我將使用 Cypher 查詢圖譜中是否存在這種反向鎖定。


── 第 5 輪:Cypher 查詢死結對稱路徑 ──

  • You:
    在圖譜中執行 Cypher 查詢,找出是否有任何方法同時存取了 _socketLock 與 _stateLock。
  • AI:

    我將使用圖譜的 Cypher 查詢功能,尋找既存取 _socketLock 又存取 _stateLock 的方法節點。

    CM 呼叫: query_graph(cypher_query: "MATCH (m:Method) WHERE m.content CONTAINS '_socketLock' AND m.content CONTAINS '_stateLock' RETURN m.file_path, m.name")
    CM 回傳:

    • file_path: UI/MainForm.cs
    • name: timerUI_Tick

    讓我們立刻讀取 UI/MainForm.cstimerUI_Tick 的程式碼。

    CM 呼叫: get_code_snippet(file_path: "UI/MainForm.cs", start_line: 110, end_line: 130)
    CM 回傳:

    private void timerUI_Tick(object sender, EventArgs e)
    {
        // UI 執行緒定時更新介面
        lock (_plc.SocketLock) // 取得 _socketLock
        {
            lock (_monitorService.StateLock) // 取得 _stateLock
            {
                lblStatus.Text = _monitorService.GetDeviceStatus();
            }
        }
    }

    死結成因完全確認!

    • UI 執行緒 (timerUI_Tick):先取得 _socketLock,準備去拿 _stateLock
    • 背景執行緒 (RunPollingLoop):先取得 _stateLock,準備去拿 _socketLock

    當這兩件事在 10 分鐘左右的定時週期中重合時,兩個執行緒就會互鎖,導致 UI 當死且通訊停擺。 診斷完成,我現在退出探索模式,並為您建立變更提案。


── 第 6 輪:建立提案 ──

  • You:
    /opsx:propose "修復多執行緒互鎖死結 Bug,將 PlcConnection 重構為執行緒安全佇列與專屬通訊執行緒,並在 Info.plist/UI 中移除巢狀鎖定"
  • AI:

    好的,已成功結束探索模式!已為您在 openspec/changes/fix-plc-deadlock/ 建立了變更提案:

    1. proposal.md: 說明 Why/What 變更與影響。
    2. specs/plc-communication/spec.md: 定義了 UI 非阻塞與無死結的驗收標準。
    3. tasks.md: 拆分為以下實作任務:
      • 1.1 重構 PlcConnection.cs 使用非同步 ConcurrentQueue 與專屬通訊背景執行緒。
      • 1.2 重構 MonitorService.cs 縮小鎖粒度,消除巢狀鎖定。
      • 1.3 重構 MainForm.cs 定時更新,避免跨執行緒鎖佔用。
      • 1.4 執行建置並驗證。

    提案已就緒!請輸入 /opsx:apply 開始實作。


── 第 7 輪:實作非同步通訊重構 (Task 1.1) ──

  • You:
    /opsx:apply
  • AI:

    Implementing: fix-plc-deadlock (schema: spec-driven)

    正在處理任務 1.1:重構 PlcConnection.cs,引進消費者佇列模式,消除直接鎖定。

    我已將 Services/PlcConnection.cs 重構為非同步佇列模式。UI 執行緒的寫入請求只需加入佇列,背景執行緒會單獨處理,並且為 Socket 加上了 2 秒超時限制:

    // Services/PlcConnection.cs 重構片段
    public class PlcConnection
    {
        private readonly ConcurrentQueue<PlcRequest> _queue = new ConcurrentQueue<PlcRequest>();
        private readonly AutoResetEvent _event = new AutoResetEvent(false);
    
        public void WriteRegisterAsync(int address, int value)
        {
            _queue.Enqueue(new PlcRequest(address, value));
            _event.Set(); // 瞬間完成,不卡 UI
        }
        
        private void ProcessQueueLoop()
        {
            while (_running)
            {
                _event.WaitOne(2000);
                while (_queue.TryDequeue(out var req))
                {
                    _socket.SendTimeout = 2000;
                    _socket.ReceiveTimeout = 2000;
                    // 進行實際 TCP socket 讀寫...
                }
            }
        }
    }

    ✓ 任務 1.1 實作完成。已將其在 tasks.md 中勾選。 接下來進行 1.2「修改背景輪詢服務以消除巢狀鎖」。是否繼續?


── 第 8 輪:重構剩餘模組與完成歸檔 ──

  • You:
    繼續完成剩餘任務,然後進行驗證與歸檔。
  • AI:

    Implementing: fix-plc-deadlock

    正在處理任務 1.2 & 1.3:重構 MonitorService.csMainForm.cs

    • MonitorService:縮小鎖定範圍,讀取狀態與通訊完全分開,絕不在 lock (_stateLock) 中呼叫 WriteRegister
    • MainForm:更新 timerUI_Tick,改為定時從 MonitorService 的安全副本中拉取狀態,不再觸及 SocketLock

    驗證與建置結果

    1. 執行 pnpm run build:靜態教學網站更新無錯誤。
    2. 執行驗收:UI 執行緒寫入 PLC 在網路超時(拔掉網線)時依舊保持 Fluid,UI 無卡死,驗收通過。

    變更同步與歸檔

    1. 已將此 spec 合併至 openspec/specs/plc-communication/spec.md
    2. fix-plc-deadlock 計畫歸檔至 openspec/changes/archive/

    Archive Complete

    死結 Bug 已成功修復並歸檔!程式碼已推播至主線。


產生的檔案結構

openspec/
├── specs/
│   └── plc-communication/
│       └── spec.md         # 已同步:最新 PLC 執行緒安全與 UI 響應規格
└── changes/
    └── archive/
        └── 2026-07-16-fix-plc-deadlock/
            ├── proposal.md # 提案歸檔
            ├── design.md   # 設計文件歸檔
            └── tasks.md    # 已全數完成的任務檢核表

程式碼與設計亮點

  • 執行緒解耦 (Thread Decoupling):藉由導入 ConcurrentQueue 生產者-消費者模式,使 UI 執行緒與 TCP Socket 通訊執行緒完全分離。UI 執行緒不直接參與網路 I/O,從根本上杜絕了 UI 凍結。
  • 超時保全 (Timeout Guard):為底層 Socket 配置 ReceiveTimeout,防止因實體線路中斷或 PLC 斷電所造成的無限等待。
  • 無鎖化定時更新:UI 定時器只拉取狀態快照 (Snapshot),不直接鎖定共用服務執行個體,消除了死結誘因。

學到了什麼

  1. 圖譜追蹤在 Legacy 代碼中的破局力:在複雜的大型系統中,單純靠 grep 很難理清多執行緒之間錯綜複雜的鎖定嵌套。利用 codebase-memory 的 trace_pathquery_graph 能以極低的成本直擊核心死結鏈結。
  2. 規格驅動修復:在動手改代碼前,先寫明「UI 平均響應時間必須小於 5 毫秒」的 Scenario 規格,能確保實作時不跑偏,並給予 AI 明確的重構驗收標準。

常見陷阱

  • 陷阱 1:在 Lock 內進行同步重連
    有些工程師發現連線斷開後,會在 lock(_socketLock) 內部調用 Socket.Connect() 進行斷線重連。重連通常耗時數秒甚至數十秒,這會導致其他所有調用該通訊元件的執行緒瞬間全部卡死。
  • 陷阱 2:忽視 WinForms 訊息循環機制
    WinForms 是單執行緒 UI 模式(Single-Threaded Apartment)。如果在 UI 執行緒上使用了 Thread.Sleep 來等待背景通訊,會直接卡死訊息循環,導致 UI 立即未響應。