· macOS / 工程 / 文件监听

macOS FSEvents 实测:在访达里移动、删除照片时,系统到底报了什么

P-Pass 桌面端把手机传来的照片存进电脑上的一个普通文件夹,路径形如 ~/Pictures/P-Pass 家庭照片库/originals。这是一个能在访达里直接打开的目录,谁都可以进去看、拖、删、建子文件夹。

于是就有了一个绕不开的要求:用户在访达里动了这些文件,P-Pass 的索引和照片墙要跟着变。

要做到这一点,桌面端的 daemon 得知道目录里发生了什么。我们用的是 Rust 的 notify crate(版本 7),它在 macOS 上的实现是 FSEvents。写监听代码时,有一件事我们一直没有真正核实过:在访达里做这些操作,FSEvents 到底会报出什么事件。

2026 年 8 月 21 日我们做了一次实测,把 11 种文件操作收到的事件逐条打印出来。结果推翻了排查删除问题时的头号假设,也改掉了 watcher 处理事件和入库的规则。

为什么要监听照片库目录

照片库目录有两个写入方。一个是 P-Pass 自己:手机传来的照片先落在 staging 区,再放进 originals/,当时的目录结构是 <设备>/年/月/。另一个是用户:他随时可以在访达里整理、删除、补充照片。

索引里的 asset 表记着每张照片的内容 hash 和它在库里的相对路径 rel_path。照片墙、时间线都从索引读。文件变了而索引没变,界面上就会出两种错:删了的还在,或者还在的不见了。这两种我们后来都撞上了。

监听的整体结构在最初实现 watcher 时就定下了:收到事件后等一个静默窗口,默认 500 毫秒,窗口内再有事件就重新计时;然后把这一批事件涉及的路径合并到父目录并去重,对这些目录做增量扫描。扫描分两个方向,新增方向把磁盘上还没入库的媒体文件入库,删除方向把索引里有、磁盘上已经没有的记录清掉。

这套结构能不能成立,取决于我们对事件本身的理解对不对。8 月 20 日排查一个”删了照片但照片墙不变”的问题时(后面细说),我们发现自己对 FSEvents 的不少认识是默认出来的,没有验证过。第二天就专门做了这次实测。

实测方法

我们写了一个诊断测试,平时默认跳过,需要时手动运行。它在临时目录里建两个文件夹:watched 被监听,outside 模拟库外的位置。然后按顺序做 11 种操作,每做一步停 900 毫秒,把这段时间里收到的事件原样打印出来。

这些操作没有在访达里手动做,全部由测试代码调用等价的文件系统接口复现:std::fs::write 写文件,std::fs::rename 改名和移动,remove_file、remove_dir_all 删除。访达里的动作落到文件系统上也是这几类调用。同一个卷里把文件拖到另一个文件夹,是一次改名,inode 保持不变;“移到废纸篓”是把文件或目录改名进 ~/.Trash,也是同卷改名。所以表里的 ⑥ 和 ⑦ 用”改名进出被监听目录”来复现”从别处拖进来”和”丢进废纸篓”。

测试开头会打印当前的 watcher 实现,输出是 Fsevent,确认走的是 FSEvents。

实测表

操作收到的事件
① 新建文件 a.jpgCreate(File) + Modify(Metadata) + Modify(Data)
② 改写已有文件的内容Create(File) + Modify(Metadata) + Modify(Data)
③ 同目录改名 a.jpg → b.jpga.jpg 上的 Create(File) + a.jpg 上的 Modify(Name) + b.jpg 上的 Modify(Name)
④ 新建嵌套目录 sub/deepCreate(Folder) ×2
⑤ 移到子目录(库内分类)旧路径上的 Modify(Name) + 新路径上的 Modify(Name)
⑥ 从库外移进来新路径上的 Modify(Name),只有这一条
⑦ 移到库外(相当于丢进废纸篓)旧路径上的 Modify(Name),只有这一条
⑧ 删单个文件Remove(File) + Modify(Name)
⑨ 连写 5 个文件,再 rm -rf sub5 组 Create + Modify;随后 sub/deep 同时报 Create(Folder) 和 Remove(Folder)
⑩ 删掉被监听的根目录Remove(Folder)
⑪ 重建同名根目录并写入文件事件照常投递

表里的事件名是 notify 的 EventKind,括号里是它的子类型。

从表里能读出什么

事件类型靠不住

有三行最能说明问题。② 改写一个已经存在的文件,报的是 Create。③ 改名时,旧名字 a.jpg 上报了一条 Create,可那一刻 a.jpg 正在消失。⑨ 删目录时,sub/deep 这同一个路径上同时出现了 Create 和 Remove。

原因在 FSEvents 的投递方式。它在一个时间窗内按路径合并所有变化,交出来的是一个标志位掩码,notify 再把掩码拆成多条事件。所以一条事件能确定的只有”这个路径上发生过事情”,发生的具体是哪件事,是从掩码里推出来的。

如果代码按事件类型分支,比如看到 Remove 就删索引、看到 Create 就入库,③ 这种情况就会把一个刚被改名走的路径当成新文件去处理,⑨ 则会让同一个目录既被当成新建又被当成删除。

移动没有来源和去向

把 ⑥ 和 ⑦ 放在一起看。从库外移进来,新路径上只有一条 Modify(Name);移到库外,旧路径上只有一条 Modify(Name)。两种操作的事件形状一模一样,都是一条改名事件,落在被监听目录里那一侧的路径上。

系统不告诉你文件从哪里来、到哪里去。一个路径收到 Modify(Name),要知道文件是来了还是走了,只能去 stat 这个路径。⑤ 库内移动报了新旧两个路径,也没有任何把两条事件配成一对的信息。

对照片备份来说,这一条的影响最大。用户把照片从一个文件夹拖到另一个文件夹,我们在事件层面看到的是”某处少了一个文件,某处多了一个文件”,要认出这是同一张照片,得靠别的办法。

批量操作会被压成一批

⑨ 里 5 次写入加一次 rm -rf,在同一次收取里全部到齐。事件有多少条其实无所谓,把受影响的目录去重后扫一遍就够了。静默窗口、父路径合并、增量扫描这套结构,处理的正是这种形状的输入。

根目录删了再建,监听还在

⑩ 和 ⑪ 推翻了我们排查删除问题时列的头号假设。当时我们认为最可能的原因是:整棵子树被删掉以后,FSEvents 的监听句柄失效,后面的事件都收不到。实测里把被监听的根目录整个删掉,再建一个同名目录并写入文件,事件照常投递。

这条我们用一个带断言的测试 watch_survives_the_root_being_deleted_and_recreated 固定了下来。哪天平台行为真的变了,这个测试会先失败。

由此定下的处理原则

几条合起来,就是我们后来遵守的原则:事件只提醒去看这个路径,以磁盘上的实际文件为准。

落到 P-Pass 的 watcher 上:事件类型不参与判断,只取路径;路径合并成目录之后,新增方向扫描磁盘上实际存在的媒体文件,删除方向把索引里记录的路径逐一拿去检查存在性,不在了才删记录。文件经历了什么操作,事件给不出可靠答案,watcher 也就不去推断。

落地时踩到的两个坑

原则很简单,写成代码之后还是出了两个问题,都跟路径字符串有关。

一个多出来的斜杠

8 月 20 日,我们在访达里把 originals 下的照片全部删掉,目录里只剩一个 .DS_Store,索引里的 186 条记录却一条没少,照片墙也纹丝不动。

局部对账会把受影响的目录转成一个相对前缀,再去索引里查这个前缀下有哪些记录:

let prefix = format!("originals/{}", rel.to_string_lossy());
// list_asset_paths_under 里再补一层:
let like = format!("{prefix}/%");

删单个文件时,受影响的目录是 <设备>/2026/08 这样的子目录,rel 非空,前缀正常。整棵子树被删时,被删目录的父目录还在,父路径合并保留最浅的那一层,受影响的目录就收敛到了 originals 本身,rel 是空串。拼出来的查询是:

prefix = "originals/"  →  LIKE 'originals//%'  →  命中 0 行

库里明明有记录,查出来 0 条,于是什么都不删,也不通知界面刷新。临时加的探针当场打印出 rel="" 和 matched rows = 0。

排查时我们列过三条假设:FSEvents 句柄在整树删除后失效;入库流程里过滤临时删除的逻辑吞掉了真删除;daemon 没启动,或者跑的是旧版本。三条都排除了,第一条就是上面实测推翻的那个。

集成测试一直是绿的,因为测试删的是单个文件,跟用户的操作形状不一样。负责按前缀查记录的 list_asset_paths_under 此前没有任何测试,双斜杠就这样一直留在代码里。

同一次还修了第二处:扫描函数碰到已经消失的目录会返回 ENOENT 错误,处理流程随即提前退出,删除方向的对账被一起跳过。整棵目录被删的时候,读不到目录是必然结果,现在按”没有东西可扫”处理,删除方向照常执行。

修法是查询前先去掉前缀末尾的斜杠。新增的测试覆盖三种删除形状:rm -rf 整棵子树、访达”删除”(顶层目录改名进 ~/.Trash)、整树删除后必须通知界面刷新。我们把每处修复改回去验证过,对应的测试都会失败。

删除方向的这两处修复代码已经合并,测试覆盖了上面几种形状,真机上还没有验证。要验证的是两件事:在访达里删几张照片、删整个日期目录,照片墙都应在 5 秒内消失,索引行数同步减少。

/var 和 /private/var

第二个坑出在库内移动上。它是修删除问题时顺带挖出来的,严重程度更高。

先说库内移动本身的问题。用户在访达里把一张已备份的照片从日期目录拖进自己建的 originals/我的婚礼/,文件好好地在盘上,照片却从照片墙和手机时间线上消失了,而且再也回不来。

watcher 在一次处理里先跑新增方向,再跑删除方向:

  1. 新增方向在新位置扫到这个文件,算出 hash,发现索引里已经有这份内容,判为重复,没有更新 rel_path,索引仍指向旧位置。
  2. 删除方向检查旧目录,旧路径上的文件不存在,于是删记录、删缩略图、记一条外部删除的审计。

结果是照片从索引里消失。之后也不会再有新的文件系统事件,没有谁会去重新扫描。

问题出在身份的口径上。P-Pass 按内容寻址,hash 是一张照片的身份,rel_path 只是它当前的位置,位置变了,要做的是更新这条记录的路径。现在 hash 命中时,先看索引记录的那个文件还在不在:

索引记录的文件新发现的文件在哪处理
还在任意位置判为重复,来源文件不动
不在了已经在 originals/ 里判为移动,采用用户放的位置
不在了库外(手机传来、还在 staging)判为移动,按当时的日期布局放好

判断”新发现的文件是否已经在 originals/ 里”,要从它的路径里去掉库根目录这段前缀。坑就在这里。macOS 上 /var 是 /private/var 的符号链接,FSEvents 报来的是解析过符号链接的真实路径,所以 watcher 的监听根在创建时做过 canonicalize,库根目录 library_root 却没做。只要库路径里有一段是符号链接,比如 /var,两边就一个带 /private、一个不带,strip_prefix 始终失败,库内移动会被误判成”来自库外”,文件会被搬回日期目录,用户建好的分类随之失效。

修法是两边都先 canonicalize 再比较。这一处同样做了反证:去掉 canonicalize,端到端测试 moving_a_file_inside_originals_keeps_it_indexed 立即失败。这个测试走真实的 FSEvents,除了数记录条数,还断言 rel_path 指向新位置、文件确实在那里。只数条数的话,“记录指向哪里”这类错误会漏掉。

库内移动的修复 8 月 27 日已在真机上验证通过:把已备份的照片拖进自建文件夹,照片墙和时间线仍然可见,文件停在放下的位置。

用户在访达里整理照片时,P-Pass 会怎样

实测结果还推动了一个产品上的决定:宽容入库。

旧的入库策略把 originals/ 当成只有 P-Pass 能写的目录:不符合日期布局的文件,入库时一律搬回日期目录。用户自建文件夹分类,或者手动放新照片进来,第二天会发现照片全跑回了 2026/08/。对一个备份工具来说,把用户的文件从他放的位置挪走,比丢照片更让人不敢用。

我们把规则改成:originals/ 下任何位置的媒体文件都原样入索引;只有从手机收到、还在 staging 里的文件,才由我们找地方放。

这个决定有实测撑腰。既然 macOS 分不清”拖进来”和”拖出去”,我们本来就得 stat 每个路径,允许用户随意摆放不需要多做任何事,坚持日期布局反而要多搬一次。另外,从目录树重建索引的逻辑本来就接受手放的文件,入库严格而重建宽容的话,重建一次,库的语义就变了。

改成宽容入库之前,有一个前提必须先补上:同一个路径只能被一条索引记录占用。用户在访达里编辑一张已入库的照片,内容变了,hash 也变了,会插入一条新记录,老记录还指着同一个路径。旧的严格布局下,编辑后的文件会被搬走、老路径变空、对账顺手清掉老记录,这个问题被意外掩盖了。宽容入库之后不再搬文件,所以现在入库时发现目标路径已被别的 hash 占用,会删掉老记录并留下审计。

落到具体操作上:

  • 新建文件夹,把已备份的照片拖进去:照片留在你放的位置,照片墙和时间线照常显示。
  • 往 originals/ 里拖入新照片:几秒内入索引,原地不动,归到这台电脑名下。非媒体文件仍按白名单过滤。
  • 在访达里编辑一张已入库的照片:同一个位置只有一份,缩略图和原图都能打开。
  • 在访达里删除照片或整个文件夹:按设计,照片会从照片墙消失,索引同步减少。这一条的修复代码已合并、有测试覆盖,真机上还没有验证。

前三条 8 月 27 日已在真机上验证通过。

还有几点要说清楚。你建的文件夹名目前在界面上看不到:时间线只按拍摄时间排列,手机端拿到的照片信息里也没有路径字段,目录叫什么对展示没有影响,把文件夹做成相册是另一项还没开始的工作。手放进来的照片归到本机名下,如果以后把整个库搬到另一台电脑上重建索引,这些照片会归到新电脑名下,内容和时间线不受影响。

另外有一个性能上的代价。现在识别”文件搬了位置”靠重新计算 hash。我们在一台真实机器上用 203 张、共 570 MB 的照片测过:只 stat 一遍用了 0.9 毫秒,全部计算 hash 用了 573.2 毫秒,相差约 620 倍。这还是热缓存下的数字,换成机械硬盘或网络盘可能再慢一个数量级。访达移动文件会保留 inode,把 (dev, inode, size, mtime) 记进索引,stat 结果没变就能直接判定为移动,不必重读文件。这涉及表结构变更,已经确定要做,还没有排期。

常见问题

FSEvents 能区分改名和新建吗?

不能可靠区分。实测里同目录改名 a.jpg → b.jpg,旧名字上报了一条 Create(File),两个名字上各有一条 Modify(Name);改写已有文件也报 Create。事件类型是从合并后的标志位推出来的,可信的只有路径。要知道一个路径现在的状态,需要自己去 stat。

notify crate 在 macOS 上能拿到 rename 的来源和目标吗?

在我们的实测里拿不到。从库外移进来、移到库外,都只收到一条 Modify(Name),落在被监听目录那一侧;库内移动能收到新旧两条 Modify(Name),但没有把它们配成一对的信息。要认出”这是一次移动”,得自己想办法,P-Pass 的做法是按内容 hash 认。

在访达里移动了备份好的照片,P-Pass 会把它当成删除吗?

不会。库内移动时索引记录保留,只把路径改到新位置,照片墙和手机时间线照常显示,文件留在你放的地方。这个问题已经修复,8 月 27 日在真机上验证通过。代价是一次移动大量照片时要重读这些文件计算 hash,耗时取决于数量和磁盘速度。

可以在 originals 文件夹里自己建子文件夹整理照片吗?

可以。originals/ 下任何位置的媒体文件都会原样入索引,不会被搬回日期目录。目前子文件夹的名字还不会在界面上显示成相册。

在访达里删掉照片,照片墙多久会更新?

设计目标是秒级:watcher 在事件静默 500 毫秒后处理这一批,对账发现文件不在了,就删掉记录并通知界面刷新。之前整棵目录删除不生效的斜杠问题已经修复,代码已合并,测试覆盖了 rm -rf 和丢进废纸篓两种形状。真机上”5 秒内消失”这一项还没有验证。