Someone plugged a 1 TB external drive into their Mac, dragged a 243 MB video onto my app, and got this:
Needs 268.6 MB, only Zero KB free
The drive had 462 GB free.
It was not intermittent, not a race, not a permissions problem. Every conversion on that drive was refused, and every conversion on the internal disk worked. The difference was the filesystem: the external drive was exFAT, the way almost every drive is when it comes out of the box.
The API returns zero, not nil
Checking free space before a large write is ordinary defensive code. The recommended way to do it on Apple platforms is a resource value:
let values = try url.resourceValues(
forKeys: [.volumeAvailableCapacityForImportantUsageKey]
)
let free = values.volumeAvailableCapacityForImportantUsage
volumeAvailableCapacityForImportantUsage is genuinely the right key on Apple
filesystems, and it is what Apple's documentation steers you toward. It is
better than the plain capacity because it counts space the system can reclaim
on demand — purgeable caches, local snapshots, files already in iCloud. On APFS
it reports more than the naive figure, which is correct: that space really is
available to your write.
On exFAT, FAT32, NTFS and SMB it returns 0.
Not nil. Zero. The property's type is Int64?, so a nil would be the honest
signal for "this volume cannot answer that question" — and nil is exactly what
I had guarded against. Zero sails straight through an optional check and lands
in your arithmetic as a fact about the disk.
Measured on the two volumes side by side:
| Volume | Filesystem | ForImportantUsage | AvailableCapacity |
|---|---|---|---|
/Volumes/Poseidon |
exFAT | 0 | 462.3 GB |
/Users/… |
APFS | 27.3 GB | 25.3 GB |
Note the APFS row too: the important-usage figure is larger than the plain
one, which is the whole reason to prefer it. And note what the exFAT row does
to code that says if free < needed { refuse }.
Why every test passed
The check itself was tested. The tests passed. They ran on APFS, because every volume I own is APFS, because every volume on every Mac sold in the last several years is APFS unless you deliberately format one otherwise.
This is a specific and unglamorous category of bug: the code was correct for the environment it was written in, and the environment was not representative. No amount of testing the logic harder finds it. You need either a volume of the wrong filesystem, or a reason to doubt the reading in the first place.
The fix, and the line it must not cross
The temptation is to fall back whenever the number looks unhelpful. That is wrong in one important case, and getting it wrong replaces a bug that annoys people with a bug that loses their data: a disk that is genuinely full also reports zero. If you treat every zero as "unreadable, proceed anyway", you will happily start a 4 GB export onto a volume with nothing left on it.
So the two zeros have to be told apart by which key produced them:
/// The decision, separated from the reading so it can be tested — the bug
/// was in this choice, and it needed a volume of the wrong filesystem to
/// reproduce.
static func resolveCapacity(
important: Int64?, available: Int64?, systemFree: Int64?
) -> Int64? {
if let important, important > 0 { return important }
if let available { return available }
if let systemFree { return systemFree }
return nil
}
Read it in order:
- A positive important-usage figure is the best answer there is. Take it.
- A zero from that key means "not supported on this filesystem", so fall through — it is not a measurement.
volumeAvailableCapacityis a plain reading, so its zero is believed. A full disk stays detectable.systemFreeSizefromattributesOfFileSystemis the last resort, for network volumes that answer neither.nilmeans nothing could be read at all — and the caller treats unknown as "do not block". Refusing a job because you could not measure something is worse than attempting it and failing honestly.
That last point matters as much as the rest. ensureCapacity returns without
complaint when the capacity is unknown:
public func ensureCapacity(_ required: Int64, at url: URL) throws {
guard let available = availableCapacity(at: url) else { return }
guard available >= required else {
throw ConversionError.insufficientDiskSpace(
required: required, available: available
)
}
}
An app that refuses to work because a check was inconclusive has converted a missing feature into a broken product.
Making it testable at all
The original code read the volume and made the decision in one function, which is why there was nothing to write a test against short of owning an exFAT drive. Splitting the decision out — a pure function over three optional integers — is what let the actual bug be pinned in a test:
@Test("exFAT: a zero from important-usage means unsupported, not full")
func zeroImportantUsageFallsBack() {
// The exact readings from the drive in the bug report.
#expect(FileManagerService.resolveCapacity(
important: 0, available: 462_337_081_344, systemFree: 462_337_081_344
) == 462_337_081_344)
}
@Test("A genuinely full disk is still reported as full")
func realZeroIsBelieved() {
#expect(FileManagerService.resolveCapacity(
important: 0, available: 0, systemFree: 0
) == 0)
}
Those two tests are the whole point, and they only work as a pair. The first proves the bug is fixed. The second proves the fix did not go too far. A fix like this one — where the change is "trust this number less" — always needs the second test, because the failure it introduces is silent and the failure it removes is loud.
There is a third, kept deliberately: reading the real home volume and asserting it reports something above zero. It is the only test in the file that touches an actual filesystem, and it is the one that would notice if the whole chain broke.
The general shape
This was the second bug in two days that appeared only on a machine that was not mine. The first was a SwiftPM resource bundle resolving through a hardcoded path in my own build directory, which crashed the app on launch for every customer while running perfectly here. Both were invisible to a green test suite for exactly the same reason.
So the question worth asking repeatedly is not "are my tests passing" but:
What is true of my machine that is not true of a customer's?
Filesystem is a good one to start with, because it is invisible, you did not choose it, and the API lies to you about it. Others in the same family: a Homebrew library that happens to be on your link path, a font you installed years ago, a permission granted once and never revoked, a locale that formats numbers the way your parser expects, a display scale factor.
You cannot enumerate all of them. You can make a habit of asking, and you can build the answers you find into the release rather than into your memory.
Written while building Arazio, a Mac app that converts files from the drag itself. This was version 1.0.3, shipped about an hour after the report came in.