---
title: Your Mac app thinks the external drive is full
source: https://arazioformac.com/notes/your-mac-app-thinks-the-external-drive-is-full
---

# Your Mac app thinks the external drive is full

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:

```swift
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*:

```swift
/// 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.
- `volumeAvailableCapacity` is a plain reading, so its zero **is** believed. A
  full disk stays detectable.
- `systemFreeSize` from `attributesOfFileSystem` is the last resort, for network
  volumes that answer neither.
- `nil` means 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:

```swift
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:

```swift
@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](/notes/bundle-module-crashes-outside-your-mac),
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.*
