OfficeCLI 开源项目的写盘底线:原子包写入与临时目录守卫
本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。
让 Agent 直接改你磁盘上的真实文档,最该防的不是它改错内容,而是它改到一半没了。 内容改错你能看出来、能回滚;写到一半进程被 kill,落在磁盘上的是一个既不是旧文件、也不是新文件的半截 zip,Word 打不开,原始字节也已经没了。OfficeCLI 这个开源项目(Apache-2.0,仓库 NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护)在 src/officecli/Core/ 下用两个很小的类把这条底线兜住:一个管”写盘这一刻的原子性”,一个管”常驻进程的临时目录别把自己搞死”。这两块加起来不到 170 行,但它们是”允许 Agent 直接动真实文件”这件事成立的前提。
先交代一个背景概念,后面全靠它:Word/Excel/PowerPoint 的 .docx/.xlsx/.pptx 本质是一个 zip 压缩包(OOXML 包),里面装着一堆 XML 分部件,比如正文是 word/document.xml。改文档 = 改包里的若干条目再重新写出整个 zip。这意味着”保存”从来不是改几个字节,而是重写整个文件——被打断的窗口远比你想象的大。
先看它到底在防什么。
AtomicPackageWriter.cs 的类注释把动机写得很直白:常驻模式(resident)会把文档一直开在内存里,把写盘推迟到 save / close / 空闲自动保存这几个时刻;这些写入绝不能就地重写目标文件,因为进程中途死亡会留下一个截断的、打不开的文件,而且没有退路——原始字节已经被覆盖掉了。
Excel 那条路上还留着旧实现的尸体记录。src/officecli/Handlers/ExcelHandler.cs 的 WriteBackFilteredPackage 上方注释写着:老路径是先对原文件 SetLength(0) 截断、再把新字节拷进去,两步之间进程死掉,原文件已经没了、盘上只剩一段残片,unrecoverable。这不是假想,是被改掉的真实代码路径。
这里要区分两种”崩”:
- 进程级死亡:kill、OOM、异常退出。操作系统还活着,已经写给内核的字节仍会落盘。
- 机器级掉电:内核缓冲里没刷到盘的东西全部消失。
记住这个区分,讲到 fsync 时会用到——OfficeCLI 明确只保证第一种。
分工先说清楚:调用层怎么重试而不重复生效,看 重试与幂等;把 Agent 关进哪块地盘里干活,看 Agent 工作区隔离;会话状态本身怎么持久化,看 会话存储的落盘设计。本文只钻一层——落到磁盘那一瞬间的字节完整性,前面三层都做对了,这一层塌了照样毁文件。
一、原子包写入:写兄弟临时文件,再一次换过去
AtomicPackageWriter.Flush 的签名只有五个参数:完整的内存包 package、目标路径 path、releaseLock、reopenLock,以及可选的 postProcessTemp。流程按顺序是这样的:
- 在目标文件的同一个目录下拼出临时名
.{原文件名}.savetmp-{Guid:N}(取不到目录名就退到.)。同目录是硬要求——跨盘的 rename 不是原子操作。 - 用
FileMode.CreateNew+FileShare.None新建临时文件,把内存包整个拷进去,然后tf.Flush()。 - 如果调用方传了
postProcessTemp,在这一步对临时文件做就地 zip 改写。原文件此刻一根毛都没动过,所以这些改写再怎么折腾都安全。 - 调
releaseLock()松开调用方自己持有的可写句柄——只有在临时文件完全写好并处理完之后才松。 - 目标文件还在,就
File.Replace(tmp, path, destinationBackupFileName: null)一次换过去;目标文件已经被外部mv走或删掉了,就退化成File.Move。
第 5 步的分支值得单独说。File.Replace 要求目标必须存在,否则抛 FileNotFoundException,而 catch 块会把临时文件删掉——那就等于把内存里所有编辑静悄悄扔了。代码注释里明确点了这个坑,所以补了 File.Exists 分支:目标被人搬走时用普通 move 把它重建出来,数据落在会话管理的那个路径上。
失败路径同样有讲究。catch 里尽力删掉临时文件再把异常抛出去;finally 里把 package.Position 归零,并且只要松过锁就一定重开,哪怕替换本身抛了异常——这样会话还握着那个完好无损的原文件,下一次 save/close 可以重试。重开失败也只是吞掉,注释说”下次 flush 会重新暴露出来”。
现在说那个必须如实交代的取舍:tf.Flush() 只把托管缓冲推给操作系统,代码里明确注释掉了 Flush(flushToDisk: true) 这个 fsync,理由是要让大文件的自动保存保持便宜。所以这套机制的保证边界是:进程被 kill 不会撕裂文件;机器掉电,不保证。这一条你必须知道,否则会高估它。
postProcessTemp 这个钩子解释了为什么”两步操作也能一次落地”。WordHandler.Dispose 里传进去的是两件事:FlushPendingWholeParts(把 Open-XML SDK 在流式打开的包上不肯持久化的 docProps 整块写回)和 NormalizeSelfClosingInDocx(把 <w:br /> 规范成 <w:br/>,两种都是合法 OOXML)。它们都作用在临时文件上,最后由同一次 File.Replace 一起生效。顺带一个可观察的行为差异:中途 save 走的是 WordHandler.Save,那里故意跳过自闭合规范化(需要以读写方式重开 zip,而后备流还占着文件),所以中途快照里是 <w:br /> 形式,关闭时才收敛成短形式。
二、批量那一层:整批不绿就整批不落地
单次写盘原子了,还有一个更大的粒度问题:一个 batch 里跑 30 条改动,第 18 条失败了怎么办。src/officecli/CommandBuilder.Batch.cs 给出的默认答案是整批回滚,理由写在注释里——半应用的批次是最脏的状态。
它的做法是同一目录下先做一份临时副本,整批改动全跑在副本上,全绿才 File.Replace promote 回去,任一条失败就删掉副本、原文件一个字节都没动。几个细节能看出这段是踩过坑的:
- 临时副本用流式拷贝而不是
File.Copy,因为后者会保留源文件的旧 mtime,而 macOS 上把 mtime 设得比 birthtime 早会把 birthtime 一起拖下去,任何按时间戳判断”这是不是遗留垃圾”的逻辑都会失效。 - 有一个孤儿清扫:崩溃的批次没机会自清理,下次运行时按本文档的临时名模式扫描,候选必须同时通过独占打开探测和 15 分钟的年龄下限才会被删——太年轻的一律假定是并发批次还活着。
- 目标是符号链接时先
ResolveLinkTarget解析到真身,否则最后的 rename 会把链接本身覆盖掉。 - 非 Windows 下用
File.SetUnixFileMode把原文件权限抄到临时文件上(rename 会把临时文件的默认权限带过去);Windows 的File.Replace自己保留目标 ACL。 - 临时名比原名多约 45 字节的前后缀,逼近 255 字节的文件名上限,所以有个
TruncateStemForTempName把主干按 UTF-8 字节截到 180,并且按码点走以免劈开代理对。
还有两个默认值影响你的调用方式:--best-effort 会退回旧的就地执行语义(成功的项目保留);全是只读动词(get、query、view、validate、dump、raw)的批次直接跳过拷贝——没东西要保护。注释特意说明,不在这份只读名单里的动词(包括以后新增的)一律 fail open 当作会改文件处理。
批量失败之后该重试还是该放弃,是调用层的策略问题;这里讲的是文件层的兜底——两层是叠加关系,不能互相替代。
三、另一条守卫:临时目录太长会把常驻进程搞成半死
PipeTempDirGuard.cs 表面上跟”别改坏文档”无关,实际上它守的是同一件事的另一头:常驻进程活着,你的编辑才在内存里,才有机会被完整地刷下去。
背景一句话:Unix 上 .NET 的”命名管道”其实是 Unix 域套接字,文件落在 $TMPDIR/CoreFxPipe_<pipeName>。内核对这个套接字路径(sun_path)有硬上限,Linux 108 字节、macOS/BSD 104 字节。而 Agent 沙箱特别喜欢导出一个层层嵌套的 TMPDIR。注释里记了这个 bug 的两种表现(issue #263):要么常驻进程直接以 ArgumentOutOfRangeException 死掉,要么更糟——主管道绑上了、更长的 -ping 管道没绑上,留下一个僵尸服务端,客户端只会告诉你”启动了但不响应”。
守卫的逻辑是纯算术:最长的套接字文件名被写死成常量 MaxSocketFileName = 43(CoreFxPipe_ 11 字节,加上 officecli-watch-<16 hex> 32 字节;常驻管道更短,26 或加 -ping 后 31)。ComputeFallbackDir 用 UTF-8 字节数而非字符数去比——按内核视角算,比 .NET 自己的字符数检查更保守,所以永远不会比 .NET 更晚发现问题。装不下就返回 /tmp/officecli-{uid}。
EnsurePipePathFits 拿到这个结果后创建目录并设成 0700;这里如果撞上 EPERM(名字被别的用户占了),它选择直接放弃、保留原 TMPDIR——注释说得很清楚,宁可回到原来的失败面,也不跟别人共享套接字。成功了才 SetEnvironmentVariable("TMPDIR", fallback)。
两个工程细节值得抄走。一是纯决策被单独切出来:ComputeFallbackDir(tempPath, isLinux, uid) 不碰任何进程状态,所以长度规则可以直接单测,副作用留在外层。二是收敛靠同一条确定性规则:所有管道端点(常驻的客户端/服务端、watch 的服务端/通知端)以及 .lock/.port 文件都以 Path.GetTempPath() 为准,子进程继承环境变量,于是各方跑同一条规则自然落到同一个目录。也正因为它必须最早生效,Program.cs 把这次调用放在任何管道端点和临时文件之前。
沙箱把临时目录搞得又深又长,本身就是隔离手段的副作用之一:你为了限制 Agent 的可见范围套了几层目录,代价落在了内核那个 104/108 字节的硬上限上。
四、这几块各管什么
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
AtomicPackageWriter | 把内存中的完整包写到同目录临时文件,可选后处理,再一次性替换目标 | src/officecli/Core/AtomicPackageWriter.cs | 任何一次可写会话的 save / close / 空闲自动保存 |
| 三个 handler 的写回入口 | 各自持有后备文件句柄,把释放/重开句柄的动作交给 writer 回调 | src/officecli/Handlers/WordHandler.cs、src/officecli/Handlers/ExcelHandler.cs、src/officecli/Handlers/PowerPointHandler.cs | 三种格式各走一次,读会话不触发 |
| 批量临时副本 + promote | 整批全绿才把副本换回原文件,含孤儿清扫与符号链接解析 | src/officecli/CommandBuilder.Batch.cs | 跑 batch 且含改动动词时(默认开,--best-effort 关) |
PipeTempDirGuard | 进程启动时判断套接字路径是否超内核上限,超了就把 TMPDIR 指到短目录 | src/officecli/Core/PipeTempDirGuard.cs | Unix 下起常驻/watch,尤其在沙箱里 |
ResidentFlushPolicy | 决定常驻模式什么时候真的写盘(each/auto/off/固定秒数) | src/officecli/Core/ResidentFlushPolicy.cs | 你关心”改完到底落盘没有”的时候 |
CompoundFile | 读写 OLE 内嵌用的复合文件容器,只在内存里产字节 | src/officecli/Core/CompoundFile.cs | 内嵌/取出 OLE 对象时,落盘仍走上面那条原子路径 |
最后一行需要解释一下什么是复合文件。CFB(Compound File Binary,以 D0 CF 11 E0 开头的那种容器)是 OOXML 之前的老式”文件里的文件系统”,OLE 内嵌对象至今仍用它包装。CompoundFile.cs 把这块从第三方依赖 OpenMcdf 换成了自己实现,读侧的姿态尤其能说明问题:解析 V3 和 V4 两种容器、每个偏移都做边界检查、链式遍历有迭代上限,遇到畸形输入返回 null 而不是抛异常,让调用方回退到原始字节。写侧只产出一个 byte[](WriteSingleStream),自己不碰磁盘——所以它天然继承了原子写入那条路的保证。读侧不炸、写侧不撕,是同一种防御姿态的两端。
五、边界与代价:它明确不管的事
这套设计放弃了不少东西,写清楚比夸它有用。
不保证掉电持久性。 上面说过,fsync 是有意省掉的。机器断电或内核 panic,落盘状态不在保证范围内。要这一层,得自己在外面加。
要求同一个卷。 临时文件必须和目标同目录,File.Replace 才是原子的。目标目录不可写(只给了文件写权限、或者目录被容器挂成只读),这条路直接走不通。
替换的是路径,不是 inode。 inode 可以理解成文件在文件系统里的”身份证号”,跟文件名是两回事:改名不换号,但换一个新文件顶上去就换号了。一次 replace 之后这个路径上挂的已经是新 inode,靠 inode 追踪这个文件的外部工具(某些同步客户端、编辑器的文件监听)会发现自己盯着的那个”号”已经没人用了,需要自己处理重新绑定。符号链接的情况被单独解掉了,硬链接没有。
多出一份完整副本的磁盘峰值。 大文件在替换瞬间盘上有两份,空间紧张时会失败。批量路径更明显——它先整个复制一遍。
不是版本控制,不留备份。 destinationBackupFileName 传的是 null,旧字节替换即弃。它保证”要么旧要么新”,不保证”你能翻回昨天”。想要历史,靠外面的机制。这跟 Agent 检查点与长任务 讲的可回溯是两码事。
不管并发。 两个进程同时改同一个文档,各自的原子替换都成立,但后一次会整个盖掉前一次——包级替换的粒度就是整个文件,没有合并。批量路径的孤儿清扫也只是”尽力”,用了独占打开加 15 分钟年龄两道闸来躲开并发的 TOCTOU 窗口——TOCTOU 是”检查完到真正动手之间世界变了”的经典竞态,这里就是”探测时那个临时文件没被占用,删除时它其实属于另一个正在跑的批次”,注释里坦白这仍是概率性的。
不管你改的内容对不对。 它只保证写入这个动作不撕裂。改错一句话、选错一个单元格,它照样原子地帮你写下去。
六、上手与避坑清单
先确认你到底在改原文件还是副本。 单条命令直接作用于原文件(写盘那一刻原子);默认的批量模式跑在同目录副本上、全绿才 promote。为什么会踩:你以为 batch 里前 17 条已经生效了,其实失败回滚后一条都没生效。怎么避:把批量当成一个事务看,别在中途去读原文件的状态。
别默认改动已经落盘。 常驻模式下真正写盘的时机由 ResidentFlushPolicy 决定:each 是每条改动返回前都刷,auto(默认)是空闲去抖——改动停下来一小段时间才真写一次,连续改就一直往后推,间隔按实测保存耗时自适应,off/0 则只有显式 save/close/shutdown 才写。为什么会踩:你在脚本里改完文档立刻用别的工具去读那个文件,读到的是旧内容。怎么避:外部消费者要读之前显式 save,或者把策略切到 each。
留意目录里的隐藏临时文件。 崩溃可能留下 .<文件名>.savetmp-<guid> 或 .<主干>.batch-<guid>.<扩展名> 这类同目录残留。为什么会踩:把文档目录整个打包发出去时会带上它们,或者你的构建脚本按扩展名通配文件时会把它们也吃进去。怎么避:清理时确认对应的批次确实已经结束,别手贱删掉正在运行的那份。
沙箱里起常驻,先看 TMPDIR 有多长。 为什么会踩:套接字路径超内核上限时,症状是”启动了但不响应”,很容易被误判成模型卡住或者工具坏了。怎么避:Unix 下确认进程实际用的临时目录;守卫只在超限时才改指向,它不改的时候你原来的 TMPDIR 就是生效值。
只读命令不该改文件的 mtime。 项目里专门修过这个:以更新模式打开 zip 会无条件重写整个归档,哪怕没改任何条目,于是一次纯查看就让文件的字节和修改时间变了(对外部生产的文件尤其明显)。为什么会踩:你的增量构建或同步工具会因为一次只读查询而误判文件变更。怎么避:确认走的是只读会话(不带编辑意图),别顺手加上会话级的写标志。
磁盘和目录权限要按峰值准备。 为什么会踩:临时文件跟目标同目录,目录只读或空间只够一份文件时,保存会失败——好消息是失败时原文件仍然完好。怎么避:给文档所在目录留出至少一倍文件大小的空间和写权限。
收束:拿这几个文件当自检清单
想判断一个”让 Agent 直接改真实文件”的工具靠不靠谱,可以照着这几个问题去读它的源码:写盘是就地覆盖还是临时文件加替换?替换用的是不是同卷 rename?失败路径上临时文件会不会泄漏、调用方的句柄会不会重开?多步后处理是各写各的,还是一起塞进同一次替换?有没有诚实地说清 fsync 做没做?
OfficeCLI 这边,这些答案分别落在 Core/AtomicPackageWriter.cs(80 多行,从 Flush 的 try/catch/finally 三段读起最快)、CommandBuilder.Batch.cs 的 promote 段落,以及 Core/PipeTempDirGuard.cs。想接着往下读,建议顺着 WordHandler.Dispose 看 postProcessTemp 怎么把两次 zip 改写并进一次替换,再去看 ResidentFlushPolicy 里那对不对称的平滑系数(涨得快、落得慢,专门防保存风暴)。
顺带说一句边界:本文只谈”文件写坏不写坏”。这个工具还有常驻进程与文件监听模式,按指令还可能去拉外部资源,那部分的暴露面(谁能让它去拉什么、拉回来的东西会不会进文档)是另一个话题。真要让 Agent 动你的合同和报表,写盘这层是底线,不是全部。
本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 开源项目 OfficeCLI 的文档节点模型:三类文档共用一套接口 和 OfficeCLI 开源仓库:Excel 处理器 47 个文件怎么分组。