How apps save changed files

I recently looked at how the Finder copies and moves files, and how it can create APFS clones. This doesn’t share the same process as when apps save their documents. This article examines that.

The simplest approach to saving a file in an app is to write out the changed contents to the original file, and let the file system handle any protection it can provide. As we currently can’t see inside APFS, it’s hard to know what that might be, and Apple’s current documentation on APFS doesn’t give any details either. This might involve cloning, but as that is currently transparent, and no information is left in macOS events logs, it’s impossible to know for sure. If you know of a tool which can cast light on this, please let me know. One tell-tale sign of this type of save is that the saved file has the same inode as the original.

For a great many apps, each time that you save a document, its file assumes a new inode, indicating that the old file has been replaced by a new one. This is because of the ‘atomic’ or ‘safe’ save which has taken place. My example here is my free Rich Text editor DelightEd, which relies on AppKit to perform its saving, and is here saving to the internal SSD of a T2 Mac running macOS 10.15.4, thus using APFS.

The original test document had a volfs reference of /.vol/16777232/103183697, which changed to /.vol/16777232/103184040 when a small change had been saved to it. This confirms that the saved file was created afresh for the purposes of the save process. Saving updated one of its extended attributes too: com.apple.lastuseddate#PS, and com.apple.macl may also change, depending on the app used to save the file.

When an open document is to be saved, the app first creates a temporary folder in /private/var/folders/sz/[long name]/T/TemporaryItems in which to keep its temporary files. It then creates a temporary file in that folder with the same name as the document, and makes that the destination for the save to take place. Once that save has successfullly completed, the new version in the temporary folder is ‘moved’ to the location of the original file by changing its path, and the temporary file and folder are deleted.

Because the updated file was originally created in the temporary folder, it has a new inode, but has copied across to it the same attributes and extended attributes as the original, subject to the LastUsedDate stored in com.apple.lastuseddate#PS being updated.

All isn’t complete, though, as AppKit provides full support for the macOS version system, which also needs to store a copy of the changed file. For that, macOS first creates a new folder and file in a staging folder inside that volume’s .DocumentRevisions-V100 folder. The updated file is copied there, and it’s marked as ‘dirty’ for the version management system to deal with.

safesave

There are some twists to this account. When you edit a file stored on an external volume, temporary files created for its safe save aren’t normally stored in /private/var/folders/sz/[long name]/T/TemporaryItems but locally on that volume. When you edit documents which are stored not in single files but folders which pose as files, such as RTFD, then only the file(s) which are actually changed are copied into the temporary folder, together with the folder which contains them. This should make folder-based file formats more efficient than those which wrap all the contents into single files.

What happens when a safe save goes wrong, then, which makes it any safer?

Writing the changed data to the original file is dangerous if any error occurs before that process completes. For example, if there is insufficient free disk space as the save is taking place, it will fail, leaving the file in an intermediate state, and probably corrupted. This can be mitigated by features such as copy on write, which APFS uses when it can.

If there is an error during a safe save, the original version of the file remains unscathed. Only when the new version of the file has been saved successfully is it swapped into place as the current version. As users of older versions of Microsoft Word will recall, this doesn’t always help: if you’re told that a save has failed, you want to quit the app, and you don’t want to do that until you’ve saved your work. But at least closing the app without saving the latest changes successfully doesn’t compromise the document’s existence.

Appendix: File system events transcribed from Crescendo

This is a simple save to an existing RTF file using DelightEd, which uses AppKit to handle its file saving using standard methods, in macOS 10.15.4 to the startup internal SSD (APFS).

File save
file::create path = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems/(A Document Being Saved By DelightEd)";
file::create path = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems/(A Document Being Saved By DelightEd)/COWtest1.rtf";
file::rename destdir = "/Users/hoakley/Documents/COWtest1.rtf"; destfile = ""; srcpath = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems/(A Document Being Saved By DelightEd)/COWtest1.rtf";
file::rename destdir = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems/(A Document Being Saved By DelightEd)/COWtest1.rtf"; destfile = ""; srcpath = "/Users/hoakley/Documents/COWtest1.rtf";
file::unlink dir = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems/(A Document Being Saved By DelightEd)"; path = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems/(A Document Being Saved By DelightEd)/COWtest1.rtf";
file::unlink dir = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems"; path = "/private/var/folders/sz/qb23fyss56v96vmh60p8ft7r0000gn/T/TemporaryItems/(A Document Being Saved By DelightEd)";

Version save
file::create path = "/System/Volumes/Data/.DocumentRevisions-V100/staging/501-77493-zmUHkH2l/laAZPBi8";
file::create path = "/System/Volumes/Data/.DocumentRevisions-V100/staging/501-77493-zmUHkH2l/laAZPBi8/66D46788-4066-49D0-ABD4-62AB5BC769BF.rtf";
file::rename destdir = "/System/Volumes/Data/.DocumentRevisions-V100/PerUID/501/3373/com.apple.documentVersions"; destfile = "66D46788-4066-49D0-ABD4-62AB5BC769BF.rtf"; srcpath = "/System/Volumes/Data/.DocumentRevisions-V100/staging/501-77493-zmUHkH2l/laAZPBi8/66D46788-4066-49D0-ABD4-62AB5BC769BF.rtf";
file::unlink dir = "/System/Volumes/Data/.DocumentRevisions-V100/staging/501-77493-zmUHkH2l"; path = "/System/Volumes/Data/.DocumentRevisions-V100/staging/501-77493-zmUHkH2l/laAZPBi8";
file::create path = "/System/Volumes/Data/.DocumentRevisions-V100/.cs/ChunkStoreDirty";
file::unlink dir = "/System/Volumes/Data/.DocumentRevisions-V100/.cs"; path = "/System/Volumes/Data/.DocumentRevisions-V100/.cs/ChunkStoreDirty";