Skip to content

Commit a572b80

Browse files
committed
ZLFS: concurrency-safe commit
Lift the documented single-threaded-write-path assumption. Every reader and writer of the inode map now holds zm_lock -- the map pointer is replaced when zlfs_imap_grow expands it, so an unlocked reader could dereference freed memory: zlfs_read_dinode snapshots the LBA under the lock, the commit's map-block copy loop and commit_node's stores take it, the cleaner's pass 2 snapshots entry by entry, and the remove/rmdir/rename zero-stores are covered (statfs and ialloc already were). The commit itself becomes all-or-nothing under held locks: dirty nodes are snapshotted under one zm_lock hold (count and fill under the same hold, with a headroom retry, so a node prepended to the list while malloc slept cannot displace an older one -- say the fsync caller's -- out of the snapshot), each is referenced with an LK_NOWAIT vget (a dying vnode is mid-reclaim and skipped), and then ALL their vnode locks are taken with trylocks and held until the dirty flags clear. Holding the locks across the segment write closes the overlay use-after-free and the lost-update window (a write slipping between a node's commit and the bulk flag clear), and keeps every checkpoint a consistent all-or-nothing set -- no dangling dirents from a half-committed create. Blocking on a vnode lock while holding zm_wlock would deadlock against an fsync that holds the vnode and waits for the commit lock, so contention releases everything and retries after a short sleep (ten attempts, then EBUSY); the holder always finishes first, so progress is guaranteed. The fsync caller's own node recurses through its RWL_DUPOK rrwlock, whose exit decrements the recursion rather than releasing the caller's hold. The cleaner's pass 3 gets the same single-hold snapshot treatment: its count-then-fill raced list growth, and a displaced unlinked-but-open inode -- the very case pass 3 protects -- could have its zone reset. doc/zlfs-design.md section 7 and the functional_testing.md gap table updated; two long-standing latent-UAF rows close.
1 parent d194f32 commit a572b80

6 files changed

Lines changed: 181 additions & 37 deletions

File tree

doc/zlfs-design.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,14 @@ flushed before the superblock was appended.
144144
- **Locks**: `zm_wlock` serialises commits; `zm_lock` guards the node
145145
list and inode-map swaps; per-inode `rrwlock` vnode locks follow the
146146
ufs contracts (documented per-VOP in `zlfs_vnops.c`). The write
147-
path is single-threaded under `KERNEL_LOCK` by documented
148-
assumption; the concurrency-safe-commit phase lifts this.
147+
path no longer assumes single-threading: every `zm_imap`
148+
reader/writer holds `zm_lock`; a commit snapshots dirty nodes under
149+
`zm_lock` with vnode references and commits each under its vnode
150+
lock — all locks taken up front with trylocks and held until the
151+
dirty flags clear, so the checkpoint is an all-or-nothing set and no
152+
write slips between a node's commit and its flag clear; contention
153+
releases everything and retries after the holder finishes (avoiding
154+
the vnode->wlock inversion).
149155

150156
## 8. Why these choices
151157

functional_testing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ size 4096; superblock (SB) zones 0-1, 126 data zones.
5151
| `statfs` accuracy | IMPROVED (sbcap phase): `f_bfree` = allocatable now (free zones + log-head remainder), `f_files`/`f_ffree` from the real inode map; dead-but-unreclaimed zones still count as used until the cleaner runs (honest for an LFS) | closed enough for bring-up | per-zone byte accounting with the copying cleaner |
5252
| `zst_live_bytes` semantics | only tested against 0 in the reset loop; not a true byte count | none today; trap for future code | rename or fix when the copying cleaner needs real counts |
5353
| Inode ceiling (was: single-block map, 512) | format v2 raises the cap to `ZLFS_CKPT_NIMAP * epb` (~256000 at a 4 KB block: ~500 map-block LBAs fit in the checkpoint block) | wide trees fine now; the ~256k cap is structural until the map gets its own indirection | none planned; revisit only if a workload needs more |
54-
| Commit vs concurrent write on the same file | `zlfs_commit_file_blocks` adoption frees the overlay (`zlfs_dblk_free`) without the vnode lock while a concurrent `write` could be filling a buffer — the documented single-threaded-commit assumption now shields a potential use-after-free, not just a stale flag | none under KERNEL_LOCK today | serialise commit against vnode ops in the concurrency-safe-commit phase |
55-
| `zlfs_imap_grow` swaps `zm_imap` under `zm_lock` only | readers (`read_dinode`, commit, cleaner pass 2, remove/rmdir/rename) index the map without `zm_lock`; safe today because all accesses are single-expression under the kernel lock and grow has no sleep between copy and install — a latent use-after-free the moment these paths run MP-unlocked. `statfs` now takes `zm_lock` for its scan (sbcap phase) | none under the current single-threaded assumption | take `zm_lock` (or `zm_wlock`) in the remaining map readers as part of the concurrency-safe-commit phase |
54+
| Commit vs concurrent write on the same file | CLOSED (concurrency phase): commit snapshots dirty nodes under `zm_lock` with vnode references (`LK_NOWAIT` vget skips dying vnodes) and takes ALL their `rrw` locks up front (trylock; contention = release all + retry, holder finishes first) and holds them until the dirty flags clear — closes the overlay UAF, the commit-vs-write lost-update window, and keeps the checkpoint an all-or-nothing set | closed | |
55+
| `zlfs_imap_grow` swaps `zm_imap` under `zm_lock` only | readers (`read_dinode`, commit, cleaner pass 2, remove/rmdir/rename) index the map without `zm_lock`; CLOSED (concurrency phase): every reader/writer of `zm_imap` now holds `zm_lock``read_dinode`, the commit's map snapshot and `commit_node` stores, cleaner pass 2 (per-entry snapshot), remove/rmdir/rename zero-stores; `statfs`/`ialloc` already did | closed | |
5656
| Conventional (non-write-pointer) superblock zones would break the write path | discovery supports them (forward scan), but `zlfs_zones_load` copies `DK_ZONE_WP_INVALID` (~0) write pointers verbatim, so `zm_sb_lba` starts at ~0 and the first commit's superblock write targets a nonsense LBA (pre-existing; flagged by the sbcap review) | RW mount of a device with conventional zones 0-1 fails at the first commit; the QEMU ZNS target has no conventional zones | validate the active SB zone's write pointer at mount; proper conventional-zone append tracking when such a target matters |
5757

5858
## 4. Later (roadmap order)

sys/zlfs/zlfs_alloc.c

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,20 @@ zlfs_clean(struct zlfs_mount *zmp)
294294
}
295295
pass1_done:
296296

297-
/* Pass 2: the in-core live set, through zm_imap. */
298-
for (ino = 0; ino < zmp->zm_ninodes; ino++) {
299-
if (zmp->zm_imap[ino] == 0)
297+
/* Pass 2: the in-core live set, through zm_imap. Snapshot each
298+
* entry under zm_lock (the map may be grown/replaced) and read
299+
* the inode outside it. */
300+
for (ino = 0;; ino++) {
301+
rw_enter_read(&zmp->zm_lock);
302+
lba = (ino < zmp->zm_ninodes) ? zmp->zm_imap[ino] : 0;
303+
if (ino >= zmp->zm_ninodes) {
304+
rw_exit_read(&zmp->zm_lock);
305+
break;
306+
}
307+
rw_exit_read(&zmp->zm_lock);
308+
if (lba == 0)
300309
continue;
301-
if (zlfs_gc_mark_inode(zmp, zmp->zm_imap[ino]) != 0)
310+
if (zlfs_gc_mark_inode(zmp, lba) != 0)
302311
return;
303312
}
304313

@@ -307,11 +316,55 @@ zlfs_clean(struct zlfs_mount *zmp)
307316
* unlinked-but-open file is in neither map once its removal has
308317
* been committed, yet the open vnode still reads its old blocks
309318
* from disk, so mark everything each in-core inode points at.
319+
* The marking sleeps (bread), so snapshot the inodes under
320+
* zm_lock first -- the list may change while we sleep.
310321
*/
311-
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry) {
312-
if (zlfs_gc_mark_blocks(zmp, &znp->zn_dinode) != 0)
313-
return;
322+
{
323+
struct zlfs_inode *snap = NULL;
324+
u_int64_t nsnap = 0, cap = 0, n, s;
325+
326+
/*
327+
* Count and fill under one zm_lock hold, with a headroom
328+
* retry when the list grew while we slept in malloc --
329+
* a node prepended during that sleep must not displace an
330+
* older one (the unlinked-but-open case pass 3 exists for)
331+
* out of a capped snapshot.
332+
*/
333+
for (;;) {
334+
rw_enter_read(&zmp->zm_lock);
335+
n = 0;
336+
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry)
337+
n++;
338+
if (n == 0) {
339+
rw_exit_read(&zmp->zm_lock);
340+
free(snap, M_TEMP, cap * sizeof(*snap));
341+
goto pass3_done;
342+
}
343+
if (snap == NULL || n > cap) {
344+
rw_exit_read(&zmp->zm_lock);
345+
free(snap, M_TEMP, cap * sizeof(*snap));
346+
cap = n + 8;
347+
snap = mallocarray(cap, sizeof(*snap),
348+
M_TEMP, M_WAITOK);
349+
continue;
350+
}
351+
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry) {
352+
if (nsnap >= cap)
353+
break;
354+
snap[nsnap++] = znp->zn_dinode;
355+
}
356+
rw_exit_read(&zmp->zm_lock);
357+
break;
358+
}
359+
for (s = 0; s < nsnap; s++) {
360+
if (zlfs_gc_mark_blocks(zmp, &snap[s]) != 0) {
361+
free(snap, M_TEMP, cap * sizeof(*snap));
362+
return;
363+
}
364+
}
365+
free(snap, M_TEMP, cap * sizeof(*snap));
314366
}
367+
pass3_done:
315368

316369
/*
317370
* Reset any written-but-dead data zone. The log head is spared

sys/zlfs/zlfs_subr.c

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -499,9 +499,10 @@ zlfs_read_dinode(struct zlfs_mount *zmp, u_int64_t ino, struct zlfs_inode *zi)
499499
u_int64_t lba;
500500
int error;
501501

502-
if (ino >= zmp->zm_ninodes)
503-
return ENOENT;
504-
lba = zmp->zm_imap[ino];
502+
/* zm_lock: zlfs_imap_grow may free and replace the map. */
503+
rw_enter_read(&zmp->zm_lock);
504+
lba = (ino < zmp->zm_ninodes) ? zmp->zm_imap[ino] : 0;
505+
rw_exit_read(&zmp->zm_lock);
505506
if (lba == 0)
506507
return ENOENT;
507508

sys/zlfs/zlfs_vnops.c

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,8 +795,11 @@ zlfs_remove(void *v)
795795

796796
if (np->zn_dinode.zi_nlink > 0)
797797
np->zn_dinode.zi_nlink--;
798-
if (np->zn_dinode.zi_nlink == 0)
798+
if (np->zn_dinode.zi_nlink == 0) {
799+
rw_enter_write(&zmp->zm_lock);
799800
zmp->zm_imap[np->zn_ino] = 0;
801+
rw_exit_write(&zmp->zm_lock);
802+
}
800803
zlfs_node_dirty(np);
801804
return 0;
802805
}
@@ -837,7 +840,9 @@ zlfs_rmdir(void *v)
837840
zlfs_node_dirty(dnp);
838841
np->zn_dinode.zi_nlink = 0;
839842
np->zn_datalen = 0;
843+
rw_enter_write(&zmp->zm_lock);
840844
zmp->zm_imap[np->zn_ino] = 0;
845+
rw_exit_write(&zmp->zm_lock);
841846
zlfs_node_dirty(np);
842847

843848
out:
@@ -963,7 +968,9 @@ zlfs_rename(void *v)
963968
tdnp->zn_dinode.zi_nlink--; /* lost target's ".." */
964969
tnp->zn_dinode.zi_nlink = 0;
965970
tnp->zn_datalen = 0;
971+
rw_enter_write(&zmp->zm_lock);
966972
zmp->zm_imap[tnp->zn_ino] = 0;
973+
rw_exit_write(&zmp->zm_lock);
967974
zlfs_node_dirty(tnp);
968975
}
969976

sys/zlfs/zlfs_write.c

Lines changed: 99 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <sys/systm.h>
1818
#include <sys/buf.h>
1919
#include <sys/endian.h>
20+
#include <sys/lock.h>
2021
#include <sys/malloc.h>
2122
#include <sys/mount.h>
2223
#include <sys/pool.h>
@@ -518,7 +519,9 @@ zlfs_commit_node(struct zlfs_mount *zmp, struct zlfs_node *znp)
518519
* written; its blocks become garbage for a later cleaner.
519520
*/
520521
if (znp->zn_dinode.zi_nlink == 0) {
522+
rw_enter_write(&zmp->zm_lock);
521523
zmp->zm_imap[znp->zn_ino] = 0;
524+
rw_exit_write(&zmp->zm_lock);
522525
return 0;
523526
}
524527

@@ -609,9 +612,11 @@ zlfs_commit_node(struct zlfs_mount *zmp, struct zlfs_node *znp)
609612
if (error != 0)
610613
return error;
611614

615+
rw_enter_write(&zmp->zm_lock);
612616
zmp->zm_imap[znp->zn_ino] = lba;
613617
if (znp->zn_ino + 1 > zmp->zm_ninodes)
614618
zmp->zm_ninodes = znp->zn_ino + 1;
619+
rw_exit_write(&zmp->zm_lock);
615620
return 0;
616621
}
617622

@@ -693,10 +698,14 @@ zlfs_commit_super(struct zlfs_mount *zmp, u_int8_t *blk, u_int64_t generation,
693698
* Commit all dirty inodes as a new log segment and checkpoint.
694699
*
695700
* Serialised against other writers by zm_wlock. The node list is
696-
* walked without zm_lock (taking it here would invert the vnode-lock
697-
* ordering used by zlfs_vget); this bring-up therefore assumes commits
698-
* do not run concurrently with vnode creation or reclaim. Proper
699-
* concurrent-safe commit is future work.
701+
* snapshotted under one zm_lock hold with vnode references (LK_NOWAIT
702+
* vget skips dying vnodes), then ALL their vnode locks are taken up
703+
* front with trylocks and held until the dirty flags clear -- so no
704+
* write can race an overlay adoption or slip between a node's commit
705+
* and its flag clear, and the checkpoint always covers a consistent
706+
* all-or-nothing set. Contention releases everything and retries,
707+
* letting the holder (e.g. an fsync waiting for zm_wlock) finish. The
708+
* fsync caller's own node recurses through its rrwlock (RWL_DUPOK).
700709
*/
701710
int
702711
zlfs_commit(struct zlfs_mount *zmp)
@@ -707,20 +716,78 @@ zlfs_commit(struct zlfs_mount *zmp)
707716
u_int64_t imap_lba, ckpt_lba, newgen, i, j, n, epb, nblocks, ninodes;
708717
u_int64_t *imap_lbas = NULL;
709718
u_int8_t *blk = NULL;
710-
int error = 0, ndirty = 0;
719+
struct zlfs_node **dnodes = NULL;
720+
int error = 0, ndirty = 0, nd = 0, di2, nl, tries;
711721

712722
if (zmp->zm_rdonly)
713723
return EROFS;
714724

715-
rw_enter_write(&zmp->zm_wlock);
725+
for (tries = 0;; tries++) {
726+
rw_enter_write(&zmp->zm_wlock);
727+
/*
728+
* Snapshot the dirty nodes and take their vnode locks, all
729+
* or nothing. Count and fill run under one zm_lock hold,
730+
* so a node inserted at the list head while we slept in
731+
* malloc cannot displace an older dirty node (say, the
732+
* fsync caller's) out of the snapshot.
733+
*/
734+
rw_enter_read(&zmp->zm_lock);
735+
nd = 0;
736+
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry) {
737+
if (znp->zn_dirty)
738+
nd++;
739+
}
740+
if (nd == 0) {
741+
rw_exit_read(&zmp->zm_lock);
742+
rw_exit_write(&zmp->zm_wlock);
743+
free(dnodes, M_TEMP, ndirty * sizeof(*dnodes));
744+
return 0;
745+
}
746+
if (dnodes == NULL || nd > ndirty) {
747+
rw_exit_read(&zmp->zm_lock);
748+
rw_exit_write(&zmp->zm_wlock);
749+
free(dnodes, M_TEMP, ndirty * sizeof(*dnodes));
750+
ndirty = nd + 8; /* headroom for new dirtiers */
751+
dnodes = mallocarray(ndirty, sizeof(*dnodes),
752+
M_TEMP, M_WAITOK);
753+
continue;
754+
}
755+
nd = 0;
756+
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry) {
757+
if (!znp->zn_dirty || nd >= ndirty)
758+
continue;
759+
/* A dying vnode is mid-reclaim; its in-core state
760+
* is lost either way (dirty-reclaim caveat). */
761+
if (vget(znp->zn_vnode, LK_NOWAIT) != 0)
762+
continue;
763+
dnodes[nd++] = znp;
764+
}
765+
rw_exit_read(&zmp->zm_lock);
716766

717-
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry) {
718-
if (znp->zn_dirty)
719-
ndirty++;
720-
}
721-
if (ndirty == 0) {
767+
/*
768+
* Trylocks only: blocking on a vnode lock while holding
769+
* zm_wlock would deadlock against an fsync that holds the
770+
* vnode and waits for zm_wlock. On contention release
771+
* everything and retry shortly; the holder finishes first.
772+
*/
773+
for (nl = 0; nl < nd; nl++) {
774+
if (rrw_enter(&dnodes[nl]->zn_lock,
775+
RW_WRITE | RW_NOSLEEP) != 0)
776+
break;
777+
}
778+
if (nl == nd)
779+
break; /* all locked; the commit proceeds */
780+
while (nl-- > 0)
781+
rrw_exit(&dnodes[nl]->zn_lock);
722782
rw_exit_write(&zmp->zm_wlock);
723-
return 0;
783+
for (nl = 0; nl < nd; nl++)
784+
vrele(dnodes[nl]->zn_vnode);
785+
if (tries >= 9) {
786+
free(dnodes, M_TEMP, ndirty * sizeof(*dnodes));
787+
return EBUSY;
788+
}
789+
tsleep_nsec(&zmp->zm_wlock, PPAUSE, "zlfsretry",
790+
10 * 1000 * 1000ULL);
724791
}
725792

726793
/*
@@ -740,11 +807,10 @@ zlfs_commit(struct zlfs_mount *zmp)
740807
/* Checkpoint plus however many inode-map blocks. */
741808
need = 1 + howmany(zmp->zm_ninodes,
742809
bsize / sizeof(u_int64_t));
743-
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry) {
810+
for (di2 = 0; di2 < nd; di2++) {
744811
u_int32_t b;
745812

746-
if (!znp->zn_dirty)
747-
continue;
813+
znp = dnodes[di2];
748814
if (znp->zn_data != NULL) {
749815
/* Directory: rewritten whole. */
750816
need += howmany(znp->zn_datalen, bsize) + 2;
@@ -767,11 +833,12 @@ zlfs_commit(struct zlfs_mount *zmp)
767833
zlfs_clean(zmp);
768834
}
769835

770-
/* 1. Data and inode blocks for every dirty inode. */
771-
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry) {
772-
if (!znp->zn_dirty)
773-
continue;
774-
error = zlfs_commit_node(zmp, znp);
836+
/*
837+
* 1. Data and inode blocks for every dirty inode, each under its
838+
* vnode lock so writes cannot race the overlay adoption.
839+
*/
840+
for (di2 = 0; di2 < nd; di2++) {
841+
error = zlfs_commit_node(zmp, dnodes[di2]);
775842
if (error != 0)
776843
goto out;
777844
}
@@ -801,9 +868,12 @@ zlfs_commit(struct zlfs_mount *zmp)
801868
goto out_blk;
802869
memset(blk, 0, bsize);
803870
n = MIN(epb, ninodes - j * epb);
871+
/* zm_lock: a concurrent create may grow (replace) the map. */
872+
rw_enter_read(&zmp->zm_lock);
804873
for (i = 0; i < n; i++)
805874
((u_int64_t *)blk)[i] =
806875
htole64(zmp->zm_imap[j * epb + i]);
876+
rw_exit_read(&zmp->zm_lock);
807877
error = zlfs_write_block(zmp, imap_lba, blk);
808878
if (error != 0)
809879
goto out_blk;
@@ -853,13 +923,20 @@ zlfs_commit(struct zlfs_mount *zmp)
853923
*/
854924
zmp->zm_super.zs_generation = newgen;
855925
zmp->zm_super.zs_checkpoint_lba = ckpt_lba;
856-
LIST_FOREACH(znp, &zmp->zm_nodes, zn_entry)
857-
znp->zn_dirty = 0;
926+
for (di2 = 0; di2 < nd; di2++)
927+
dnodes[di2]->zn_dirty = 0;
858928

859929
out_blk:
860930
free(imap_lbas, M_ZLFS, bsize - sizeof(struct zlfs_checkpoint));
861931
free(blk, M_ZLFS, bsize);
862932
out:
933+
/* Dirty flags cleared (or preserved on failure) under the node
934+
* locks; release them, then the commit lock, then the refs. */
935+
for (di2 = 0; di2 < nd; di2++)
936+
rrw_exit(&dnodes[di2]->zn_lock);
863937
rw_exit_write(&zmp->zm_wlock);
938+
for (di2 = 0; di2 < nd; di2++)
939+
vrele(dnodes[di2]->zn_vnode);
940+
free(dnodes, M_TEMP, ndirty * sizeof(*dnodes));
864941
return error;
865942
}

0 commit comments

Comments
 (0)