雲端 Mac 檔案監聽驗證:不要用回呼次數推算修改次數

雲端 Mac 檔案監聽驗證:不要用回呼次數推算修改次數

建置工具在雲端 Mac 上監聽原始碼目錄:每次儲存檔案就觸發編譯。但實際執行時,編輯器可能先寫入暫存檔,再重新命名以覆蓋原檔;連續儲存也可能只產生一次回呼。如果工具把「收到一次事件」解讀成「剛好修改了一個檔案」,就很容易漏掉該編譯的變更。以下用一個小實驗,分開驗證通知與最終狀態。

先確定監聽對象

只監聽某個檔案描述符,適合觀察該物件本身,卻不適合判斷路徑上的檔案是否已被新檔取代。許多編輯器採用原子儲存:先寫好暫存檔,再透過重新命名完成替換。這時路徑雖然相同,背後的檔案物件卻可能已經不同。

這個實驗改為監聽上層目錄。做法只有兩步:收到目錄事件時,先標記為「可能有變化」,接著重新讀取目錄,與上次的快照比較。這不保證記錄每一次中途寫入,卻能回答建置工具更在意的問題:目前有哪些輸入檔案與上次檢查時不同。

回呼是重新掃描的提示,不是修改紀錄。即使兩次寫入合併成一次通知,最終的檔案仍必須納入下一輪檢查。

寫一個可執行的最小監聽器

在雲端 Mac 的命令列建立實驗目錄,並將以下內容儲存為 watch.swift。腳本只檢查目錄的直屬項目,不會遞迴掃描;它會記錄檔名、大小、修改時間與檔案編號。檔案編號有助於辨識同名檔案是否遭到替換。

import Darwin
import Dispatch
import Foundation

struct Entry: Equatable {
    let size: UInt64
    let modified: Date
    let inode: UInt64
}

let path = CommandLine.arguments.dropFirst().first ?? ""
guard !path.isEmpty else {
    fputs("usage: swift watch.swift DIRECTORY\n", stderr)
    exit(2)
}
guard FileManager.default.fileExists(atPath: path) else {
    fputs("directory does not exist\n", stderr)
    exit(2)
}

func snapshot() -> [String: Entry] {
    var result: [String: Entry] = [:]
    let names = (try? FileManager.default.contentsOfDirectory(atPath: path)) ?? []
    for name in names {
        let file = (path as NSString).appendingPathComponent(name)
        guard let attributes = try? FileManager.default.attributesOfItem(atPath: file),
              let size = attributes[.size] as? NSNumber,
              let inode = attributes[.systemFileNumber] as? NSNumber,
              let modified = attributes[.modificationDate] as? Date else {
            continue
        }
        result[name] = Entry(
            size: size.uint64Value,
            modified: modified,
            inode: inode.uint64Value
        )
    }
    return result
}

let fd = open(path, O_EVTONLY)
guard fd >= 0 else {
    perror("open")
    exit(1)
}
let queue = DispatchQueue(label: "watch.lab")
let source = DispatchSource.makeFileSystemObjectSource(
    fileDescriptor: fd,
    eventMask: [.write, .rename, .delete],
    queue: queue
)
var previous = snapshot()
source.setEventHandler {
    let current = snapshot()
    let changed = Set(previous.keys).union(current.keys)
        .filter { previous[$0] != current[$0] }
        .sorted()
    if !changed.isEmpty {
        print("changed: \(changed.joined(separator: ", "))")
        fflush(stdout)
    }
    previous = current
}
source.setCancelHandler { close(fd) }
source.resume()
print("watching: \(path)")
dispatchMain()

這裡沒有將 .write 回呼直接對應到某個檔名:目錄事件只表示該檢查目錄了。snapshot() 會略過讀取失敗的項目,因此正式使用的工具還應另外記錄讀取錯誤,並在下一輪重試,而不是把暫時性的權限問題或重新命名時的競態,當成檔案確定已刪除。

用三組操作驗證邊界

先在一個終端機啟動監聽器,再到第二個終端機執行操作。以下路徑僅供實驗使用,不要指向正在使用的儲存庫。

mkdir -p "$HOME/watch-lab"
swift watch.swift "$HOME/watch-lab"
printf 'first\n' > "$HOME/watch-lab/input.txt"
printf 'second\n' > "$HOME/watch-lab/input.txt"
printf 'replacement\n' > "$HOME/watch-lab/.input.tmp"
mv -f "$HOME/watch-lab/.input.tmp" "$HOME/watch-lab/input.txt"
rm "$HOME/watch-lab/input.txt"

觀察輸出時,不要預期它與五條命令一一對應。如果寫入和重新命名發生在兩次快照之間,中途狀態可能完全不會顯示;但最後刪除後,快照應不再包含 input.txt。若要分別檢查三種結果,就在建立、替換、刪除後暫停操作,等各自的輸出出現,再執行下一步。

同名替換為什麼需要檔案編號

只比較檔名,會把「舊檔消失、新檔占用相同路徑」看成沒有變化。檔案大小也可能碰巧相同。實驗額外記錄檔案編號,因此原子替換後,即使名稱和大小都一樣,通常仍能辨識檔案物件已改變。修改時間則用來涵蓋一般的原地寫入。這裡的快照是輕量的變更判斷方式,不是檔案內容驗證;如果發布決策需要確認內容逐位元組一致,應等候選檔案穩定後,再另外計算內容摘要。

將實驗方法接入建置流程

實際建置時,不宜每收到一個事件就立刻啟動一次編譯。更穩妥的狀態流程是「收到通知 → 安排重新掃描 → 收集變更路徑 → 觸發建置」。如果重新掃描期間又收到通知,就在本輪結束後再掃描一次。這樣既能容許事件合併,也不會因為建置正在執行,就直接丟掉新的變更。

目錄的涵蓋範圍也要說清楚。上述腳本只列出直屬項目,無法完整發現巢狀原始碼目錄中的內容變化。若需要涵蓋整棵目錄樹,可採用適合遞迴追蹤目錄變化的機制,但仍須保留「收到通知後重新掃描」的原則。也應排除建置輸出目錄,否則寫入產出檔案可能反覆觸發建置。

最後要檢查監聽目錄本身的生命週期:若該目錄遭到刪除或替換,舊的描述符就不能繼續代表新路徑。工具應確認路徑是否仍指向預期的目錄,必要時重新開啟並建立監聽。

一份可執行的驗收清單

將監聽邏輯投入使用前,請在獨立的暫存目錄完成以下檢查:

這套檢查把問題從「系統為什麼少給了一次事件」,轉成「目前的輸入是否都已完整核對」。對依賴檔案變化觸發工作的雲端 Mac 工作流程而言,後者才是可以驗收的結果。

常見問題

連續修改兩次,為何可能只收到一次通知?

檔案系統事件可能合併。通知只代表需要重新檢查,不能當作逐次修改紀錄。

編輯器原子儲存後,為何原檔案監聽可能失效?

新檔案可能取代原檔案,而原描述符仍指向舊物件。改為監聽父目錄並重新檢查路徑。

為下次實驗準備一台專屬 Mac

依需求選擇實體 Mac mini、節點與計費週期。實際機型與供應狀況請以控制台即時資訊為準。

查看雲端 Mac 租用方案