fs.watch 收到 rename 后,不再发现文件修改
Linux、macOS原子保存可能把文件换成新inode。检查真实保存方式,监听稳定父目录并重新读取当前内容,处理缺失文件名和读取竞态。
程序直接监听一个文件,编辑器保存后收到一次 rename,随后再修改同一路径却没有通知。在 Linux、macOS 上,这可能是保存时替换了文件:编辑器先写临时文件,再把它重命名到目标路径。文件名没有变,监听对象却可能仍是旧 inode。
先确认实际保存方式和运行文件系统。单个 rename 事件不能直接证明文件已经被替换;文件被移走、删除等情况也需要检查。
路径相同,不等于仍是同一个文件
Linux、macOS 上的 fs.watch(filename) 会把路径解析为 inode 并监听它。目标删除后重新创建,会得到新的 inode。原监听器可能报告删除,却不会自动转而监听新对象,所以之后写入同名文件也没有新的通知。
应比较保存前后的文件身份,以及编辑器是否启用了原子保存。Linux 上可查看 inode;应用已有 stat 诊断时,可记录目标的 dev、ino。不要仅凭修改时间变化判断监听对象是否相同,也不要为验证监听而直接改写生产配置。
如果监听器创建时就报 ENOSPC,应先查Linux 文件监听配额。已经收到事件、随后因为文件替换而失效,与创建监听项失败属于不同问题。
经常替换的文件,监听稳定的父目录
对于编辑器会替换的配置文件,可以监听其父目录,在相关事件到来后重新读取目标路径。这样处理的是“现在这个路径上有什么内容”,而不是假设每次保存都修改同一个 inode。
下面只演示事件筛选;scheduleConfigRead 是应用需要提供的函数,负责合并重复通知、读取与验证当前配置,并处理读取错误。它不是 Node 内置 API:
const { watch } = require('node:fs');
const path = require('node:path');
const target = path.resolve('config.json');
const directory = path.dirname(target);
const basename = path.basename(target);
const watcher = watch(directory, (eventType, filename) => {
if (filename === null || filename === basename) {
scheduleConfigRead(target);
}
});
这段采用默认的 UTF-8 文件名编码。回调中的 filename 即使在支持的平台上也可能为 null,因此不能把缺失名称的事件全部丢掉;此时重新检查目标当前状态。
父目录必须确实存在并保持稳定,且应用有权访问。目录自身也被整体替换时,还需要重新建立对应监听。启动监听前后应协调一次当前内容检查,避免只等待未来事件而漏掉已有配置。
scheduleConfigRead 应按业务安排短暂合并和串行读取,避免多次保存使异步读取结果倒序覆盖。读取时文件可能暂时不存在,JSON 也可能不完整。应明确保留上一个有效配置还是拒绝继续运行,并记录必要的错误,不把事件回调当成“内容已经有效”的信号。
继续监听单文件时,要处理重新注册的空窗
也可以在确认目标身份改变后,关闭旧 watcher 并重新注册,但不能只在 rename 后无条件调用一次 watch。目标当时可能尚未存在;先 stat 后注册之间也仍有竞态,需要处理注册失败,并在注册完成后重新检查当前文件。
fs.watchFile() 使用 stat 轮询,可作为某些环境的替代方式。它需要权衡检查间隔、文件系统开销和刷新延迟,也不是逐次记录全部修改的接口。网络文件系统、虚拟化挂载上的事件监听可能另有可靠性限制,需按真实部署环境选择。
修改监听代码后,在隔离环境分别验证原地写入、原子替换、删除后恢复和快速连续保存。检查应用最终加载的内容,而不只是事件次数。监听通知可能合并,不能保证每个短暂中间状态都被应用读到。退出或重建监听时关闭旧 watcher,避免重复回调与资源积累。


