Summary
When ufs_rename() moves a directory to a new parent, it first adds a link to the new parent for the directory's ... With SU+J it also sets up a journal record (jaddref) for that link with softdep_setup_dotdot_link(). Then it rewrites .. with ufs_dirrewrite().
If that rewrite fails, for example on an I/O error reading the directory block, ufs_dirrewrite() puts the old parent's link back, so .. still consistently names the old parent. But two things are left behind:
- the extra link on the new parent
- the journal record, which is neither written nor cancelled
The same happens if the update of the new parent fails before the rewrite is tried. That path also leaves through unlockout with the link and the record in place.
Without the journal, fsck later finds the new parent's link count one too high.
With SU+J the result is a livelock:
- initiate_write_inodeblock_ufs2() rolls the link count back to that of the first unwritten journal record on the inodedep in every write of the inode. handle_written_inodeblock() then redirties the block. The leaked record is never written, so this never ends.
- An unmount cannot suspend writes. A plain umount fails with EAGAIN once vn_fsync_buf() gives up on the block. The deferred forced unmount after I/O errors keeps retrying in vfs_write_suspend(), which leaves every writer stuck in suspfs, and every vfs_busy() of the mount stuck as well (mount -v, df, ...).
The fix: on either failure, cancel the journal record with softdep_revert_link() and take the extra link off the new parent. Both paths go through a small helper, ufs_rename_dotdot_undo(). How the link comes off depends on whether the move replaced a directory:
- The target name was free (tip == NULL). A link was added to the new parent for the new ... It is taken back.
- The move replaced an existing directory (tip != NULL). No link was added, because the new .. was to take over the link of the replaced directory's ... For that, the replaced directory's removal is set up with DIRCHG, which tells handle_workitem_remove() to leave the parent's link count alone. With the rewrite failed, nothing takes that link over, and the new parent kept it. The fix lowers the parent's i_effnlink and clears DIRCHG on the pending dirrem with a new softdep_revert_dirchg(). The link is then dropped when the removal is on disk, as it is for a directory replaced within the same parent. The replaced directory stays locked for the whole rename, so the dirrem cannot have been processed yet. Without soft updates the link count is lowered directly.
Before a179e72489f8, a failed rewrite was reported as "rename: missing .. entry" through ufs_dirbad(), which only prints. The rename then went on the same way. Those messages, often seen at forced unmounts in the gnop stress2 tests, were this leak.
How it was found
gnop9.sh (SU+J, forced unmounts with injected I/O errors) hung. The deferred unmount task spun in vfs_write_suspend() -> ffs_sync() -> vn_fsync_buf() for hours, rewriting the same 4 inode blocks about 475 times a second each. A live minidump (savecore -L) examined with kgdb showed:
- 6 directories with 235 to 1028 links, each with a non-empty id_inoreflst. Their inodedeps were otherwise ALLCOMPLETE.
- 111 D_JADDREF records on those lists, all in state ATTACHED only, with if_diroff 12 (..), no jseg and not on the journal pending list (sd_on_journal 0). if_ino was the new parent and if_parent the moved directory.
Test
New stress2 test rename19.sh, run on SU and on SU+J, each with a free target name and with an existing empty target directory:
- It creates a/d and b (and b/d for the existing-target pass) on a gnop(8) device, remounts, and caches everything the rename needs except a/d's directory block.
- It makes all reads fail for the duration of rename("a/d", "b/d"), so the only read that fails is ufs_dirrewrite()'s.
- It unmounts, with a watchdog, and runs fsck_ffs -fn.
- The test requires the stale .. to be reported, which proves the rewrite failed, and nothing else to be reported.
Test Plan
Testing was on amd64 (AWS EC2, 4 vCPUs, 16 GB RAM), non-debug kernel.
Without the fix
With a free target name, rename19.sh fails on both file system types:
- SU: fsck reports the stale .. (CURRENTLY POINTS TO I=... (/a), SHOULD POINT TO I=... (/b)) and LINK COUNT DIR I=... (/b) COUNT 3 SHOULD BE 2.
- SU+J: umount fails with "Resource temporarily unavailable" (EAGAIN), and the file system cannot be unmounted at all.
With an existing target directory, the previous revision of this fix (which only cancelled the journal record in that case) let the unmount succeed, but fsck still reported LINK COUNT DIR I=... (/b) COUNT 3 SHOULD BE 2 on both SU and SU+J.
With the fix
- rename19.sh: all four passes pass: SU and SU+J, each with a free and with an existing target. The stale .. is the only problem fsck reports, and the unmount succeeds. DTrace showed ufs_dirrewrite() failing with EIO in each of the four passes, softdep_revert_link() called in each, and softdep_revert_dirchg() called in the two existing-target passes.
- gnop9.sh: passed 4 times in a row with this revision, and 4 times with the previous one, which handled only the free-target case. No forced unmount got stuck, and the fsck runs in the tests reported no link count or .. repairs.
- Other stress2 tests: these passed on the same kernel, with no panics and only the expected kernel messages from the injected errors and forced unmounts:
- gnop10.sh, gnop7.sh (SU and SU+J) and fsck6.sh
- fdatasync.sh and fdatasync2.sh (without soft updates, SU and SU+J) and ftruncate2.sh
- fsync2.sh (SU and SU+J) and fsync3.sh
- 10-minute marcus.cfg loads on -o sync mounts: SU, SU+J, and SU with snapshots
- nlink6.sh
The tested kernel also carried the fdatasync/O_SYNC durability and ufs_sync_nlink() changes that are now on main. They do not touch ufs_rename().
Not covered
- The failure of the new parent's update before the rewrite (the UFS_UPDATE() path) is not exercised by the test. It takes the same undo as the rewrite failure.
- A file system without soft updates. The existing-target case lowers the link count directly there, and rename19.sh only runs on SU and SU+J.