云端 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 租用配置