--- a/statefile.c 2026-09-23 13:39:22 +++ b/statefile.c 2026-09-23 13:39:22 @@ -1,20 +1,24 @@ -/* EXERCISE 06 — BROKEN ON PURPOSE. Do not copy this shape into real code. +/* EXERCISE 06 — REPAIRED. * * An app that persists its window state to a small file whenever it changes, - * and a helper process that reads that file to restore the layout. The file - * carries a length and a checksum so a reader can tell whether it is intact. + * and a helper process that reads that file to restore the layout. * - * Symptom as reported by the field: "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." + * The repair: never modify the file a reader might be opening. Write the new + * state to a sibling temporary file, force it out with fsync(2), then rename(2) + * it over the target. A rename within one filesystem replaces the directory + * entry in a single step, so every open(2) sees either the whole old file or + * the whole new one and never a state in between. * + * Note what the fsync is FOR. It does not make rename atomic — rename is + * atomic by itself. It orders the contents before the name, so a crash cannot + * leave the new name pointing at a file whose bytes never reached storage. + * fsync(2) on macOS flushes to the device but does not force the device's own + * cache; F_FULLFSYNC does, at a cost measured in milliseconds. + * * Build and run: - * clang -O2 -g -Wall -Wextra statefile.c -o /tmp/statefile_broken - * /tmp/statefile_broken + * clang -O2 -g -Wall -Wextra statefile.c -o /tmp/statefile_fixed + * /tmp/statefile_fixed * - * There is no race between THREADS here. The race is between one process's - * sequence of write(2) calls and another process's open(2). - * * Every measurement is printed as key=value so a script can assert on it. */ #include @@ -50,20 +54,31 @@ } /* ------------------------------------------------------------------ writer */ -/* Rewrite the state file in place: truncate it, then stream the new bytes. - * Between the truncate and the last write, the file on disk is neither the old - * state nor the new one. */ +/* Write the new state somewhere nobody is looking, make it durable, and then + * publish it by replacing the name in one step. A reader's open(2) resolves + * the name either before or after the rename, never during it. */ static int write_state(const char *path, unsigned char fill) { unsigned char *body = malloc(BODY_BYTES); memset(body, fill, BODY_BYTES); struct header h = { BODY_BYTES, sum_of(body, BODY_BYTES) }; - int fd = open(path, O_CREAT | O_WRONLY | O_TRUNC, 0600); + /* The temporary must be in the SAME directory: rename(2) is only atomic + * within one filesystem, and a cross-device rename fails with EXDEV. */ + char tmp[512]; + snprintf(tmp, sizeof tmp, "%s.tmp", path); + + int fd = open(tmp, O_CREAT | O_WRONLY | O_TRUNC, 0600); if (fd < 0) { free(body); return -1; } if (write(fd, &h, sizeof h) != (ssize_t)sizeof h) { close(fd); free(body); return -1; } for (size_t off = 0; off < BODY_BYTES; off += CHUNK) if (write(fd, body + off, CHUNK) != (ssize_t)CHUNK) { close(fd); free(body); return -1; } + + /* Order the contents before the name. Without this a crash can publish a + * name whose bytes are still only in the page cache. */ + if (fsync(fd) != 0) { close(fd); free(body); return -1; } close(fd); + + if (rename(tmp, path) != 0) { unlink(tmp); free(body); return -1; } free(body); return 0; }