建置工具在雲端 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、節點與計費週期。實際機型與供應狀況請以控制台即時資訊為準。