# Exercise 06 — The state file that is corrupt one launch in fifty

**Failure mode:** non-atomic publication — a reader observing a partially written file
**Language:** C · **Runtime:** about 6 seconds · **Difficulty:** the one with no threads in it

---

## The prompt

> An app persists its window state to a small file whenever it changes. A helper process
> reads that file to restore the layout. The file carries a length and a checksum, so a
> reader can tell whether it is intact.
>
> The field reports: "about one launch in fifty, the helper reports the state file as
> corrupt and we fall back to defaults. The file is perfectly valid by the time anyone
> looks at it."
>
> There is no thread race here. Find the window, close it, and tell me what the `fsync` in
> your fix is actually for.

Answer before you run anything: **between `open(O_TRUNC)` and the last `write`, what is on
disk?**

---

## What you are given

| Path | What it is |
| --- | --- |
| `broken/statefile.c` | The starting point. Truncate, then stream the new bytes. |
| `fixed/statefile.c` | One correct repair. |
| `solution.patch` | The diff between the two, applies with `patch -p1`. |
| `check.sh` | Runs both builds twice and counts torn reads. |

The fixture forks a reader process that hammers the file while the writer rewrites it 3,000
times, so the race is reproduced deterministically rather than waited for.

---

## Reproduce the measurement

```bash
cd exercises/06-atomic-write
clang -O2 -g -Wall -Wextra broken/statefile.c -o /tmp/sf_broken
clang -O2 -g -Wall -Wextra fixed/statefile.c  -o /tmp/sf_fixed
/tmp/sf_broken
/tmp/sf_fixed
```

Observed on an Apple M4 Pro, macOS 26.3 (25D125), APFS:

```
                 broken            fixed
rewrites          3,000            3,000
readerAttempts   12,000           12,000
tornReads         2,393                0
missingFile           0                0
tornFraction     0.1994           0.0000
verdict            torn           atomic
```

**Reproduce these; do not quote them** — except the zero, which is structural.

---

## The evidence to collect

1. **`tornFraction = 0.1994`.** One reader in five caught the file mid-rewrite. The
   production report said one launch in fifty; the fixture simply reads far more often. A
   rare bug and a common bug can be the same bug observed at different rates.

2. **`missingFile = 0`.** The file always existed. The failure is not "the file was not
   there yet" — the name always resolved, and what it resolved to was a file whose contents
   were part old and part new, or simply short.

3. **The window, precisely.** `open(O_TRUNC)` empties the file *immediately*. Every byte of
   the new state is then written over several `write(2)` calls. Between those two events,
   any reader that opens the name sees a file that is neither the old state nor the new one.
   Adding a checksum does not close the window; it only lets the reader *notice*.

4. **`tornReads = 0`, exactly, in the fixed build.** Not "rare" — impossible. That is the
   difference between reducing a race and removing one, and it is the distinction worth
   insisting on in an interview.

---

## Success criteria

- [ ] The broken build still tears — you have not edited it.
- [ ] Your fixed build reports `tornReads = 0` on **two consecutive runs**.
- [ ] Your temporary file is in the **same directory** as the target, and you can say why.
- [ ] You can explain what the `fsync(2)` is for, given that `rename(2)` is already atomic.
- [ ] You can say what your fix does **not** guarantee, and what `F_FULLFSYNC` would add.

Run `./check.sh` to have all of that checked for you.

---

## Hints

<details>
<summary>Hint 1 — you cannot make several writes atomic</summary>

There is no system call that publishes 32 KiB of new content in one step to a file a reader
already has a path to. Stop looking for one. Something else in the filesystem interface is
atomic — what?
</details>

<details>
<summary>Hint 2 — do not modify the thing being read</summary>

If the reader's `open` resolves a name, and the name is made to point at a different,
already-complete file in one step, the reader gets all of one or all of the other. Which
call replaces a name?
</details>

<details>
<summary>Hint 3 — same filesystem</summary>

Whatever call you reached for is only atomic within one filesystem, and fails with `EXDEV`
across devices. `$TMPDIR` is not necessarily the same filesystem as the target. Where must
the temporary live?
</details>

---

## Solution

<details>
<summary>Reveal the solution</summary>

### The repair

```c
char tmp[512];
snprintf(tmp, sizeof tmp, "%s.tmp", path);     /* SAME directory */

int fd = open(tmp, O_CREAT | O_WRONLY | O_TRUNC, 0600);
write(fd, &h, sizeof h);
for (...) write(fd, body + off, CHUNK);
fsync(fd);                                      /* contents before name */
close(fd);
rename(tmp, path);                              /* publish, atomically */
```

### Why it works

`rename(2)` replaces a directory entry in a single step. A reader's `open(2)` resolves the
name either entirely before or entirely after that step. There is no interval during which
the name points at a half-written file, so there is no window to be unlucky in — the
guarantee is structural, not probabilistic.

### What the `fsync` is for — and what it is not for

`rename` is atomic on its own; `fsync` does not make it more atomic. The `fsync` exists to
**order the contents against the name**. Without it, a crash between the writes and the
rename can leave the new name published while the new bytes are still only in the page
cache — a perfectly-named file full of nothing. The `fsync` forces the data out before the
name that promises it.

### The durability ladder, measured

`fsync(2)`'s own manual page on macOS 26.3 is unusually blunt about its limits:

> "while `fsync()` will flush all data from the host to the drive … the drive itself may not
> physically write the data to the platters for quite some time and it may be written in an
> out-of-order sequence … This is not a theoretical edge case."

The stronger guarantees cost real time. A thousand 256-byte appends, each followed by the
named call:

| Mode | µs per append | versus no sync |
| --- | --- | --- |
| no sync | 1.2 | 1x |
| `fsync(2)` | 23.0 | 19x |
| `F_BARRIERFSYNC` | 168.7 | 140x |
| `F_FULLFSYNC` | 4,012.6 | **3,340x** |

`F_BARRIERFSYNC` orders without promising durability ("no assumption should be made on what
has been persisted or not when this call returns"); `F_FULLFSYNC` "asks the drive to flush
all buffered data to permanent storage" and "may take quite a while to complete". Those are
the manual page's words, and the measurements are the reason to believe them.

**What this exercise's fix does and does not give you.** It makes the file's *visibility*
atomic to concurrent readers — guaranteed, deterministically. It does not make the write
durable across a power failure; for that you need `F_FULLFSYNC` before the rename, and you
should reach for it only where the answer to "what happens if this is lost?" is bad enough
to justify four milliseconds. A window-position file is not that.

### The repairs that do not work

- **A bigger single `write`.** A single `write(2)` is not guaranteed atomic against a
  concurrent reader for arbitrary sizes, and `O_TRUNC` has already emptied the file before
  it runs. This narrows the window without closing it, which is the worst outcome: a bug
  that now reproduces only in the field.
- **A lock file.** Correct, and it makes every reader participate in a protocol it might
  not honour — including readers you did not write, like a backup tool or the user's editor.
  `rename` needs no cooperation from the reader at all.
- **Writing in place with a "valid" flag written last.** Closer, but the flag and the data
  are separate writes with no ordering guarantee between them unless you add barriers, and
  a reader can still see a stale flag with new data. You have reinvented journalling, worse.
</details>

---

## Going further

- Delete the `fsync` from your fix and re-run. The check still passes — `tornReads` is still
  zero — because the fixture cannot simulate a power failure. Being able to say "my test
  does not cover the thing this line is for" is exactly the honesty an interview is probing.
- Replace `rename` with `renamex_np(..., RENAME_SWAP)` and consider what it buys: an atomic
  *exchange* of two files, which lets you keep the previous version as a rollback.
