构建工具在云端 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、节点与计费周期。机型与可用性以控制台实时返回为准。