rename: it was writing v1 dirents into a B-tree, and nobody noticed
`rename()` refused an existing destination, so write-to-a-temporary-then-rename
-- how every careful program saves a file -- failed on the second save of
anything. zsh's history was the visible case: `.zsh_history.new` accumulated
for ever and the history itself never updated.
An earlier attempt to fix this was reverted after it lost both files, and the
note it left behind blamed HTree slot reuse. That was the wrong suspect.
**sys_rename was removing the old name by hand.** memset a `struct dirent` to
zero and writei() it into the parent directory at the offset dirlookup()
returned. That is the v1 filesystem's on-disk layout, written directly,
bypassing dp->ops entirely. On SchFS a directory is a B-tree with an index over
it -- "the record at that offset" is not a thing, and writing zeroes there
corrupted the directory while leaving the name in the index. Adding a second
removal on top of a corrupting one is how both files disappeared.
Every other caller in the file already used dirunlink(). This one had been
open-coding a filesystem it no longer ran on, and the bug stayed invisible for
as long as nothing asked it to remove two names instead of one.
With that corrected, replacing the destination is the straightforward thing it
should always have been: look it up, refuse if it is a directory, unlink it,
drop its nlink. rename(a, a) returns 0 and touches nothing, per POSIX -- both
names already reach the same file, and removing one to add it back is only
equivalent when nothing else is watching.
The lock discipline is inherited rather than invented, and it is the delicate
part: dp_old and dp_new are frequently the *same* directory reached through two
nameiparent() calls, which is two references to one inode. So no two directory
locks are held at once -- each is locked, used, unlocked before the next -- and
the source inode stays unlocked throughout, held alive by an nlink bump.
Directories are still refused, in both roles: moving one needs the `..` fixup
and the ancestor-loop check that are not written, and replacing one needs them
plus an emptiness test. EISDIR either way.
mv a c -> 0, c has a's contents
mv c b -> 0, b replaced, b has a's contents (this is the fix)
mv b b -> 0, nothing happens
mv nope z -> ENOENT
mv b d/ -> 0, b lands inside d
60 create-and-rename cycles leave one file and 4 KB of disk, not 60. /root
holds .zsh_history and no .zsh_history.new, and a marker written in one boot is
still in the history file on the next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4e3e345b41
commit
370605452c
2 changed files with 102 additions and 18 deletions
|
|
@ -218,26 +218,57 @@ zsh: failed to write history file /root/.zsh_history.new: operation not permitte
|
|||
not**, and that is the honest state: these four were corrected because these
|
||||
four were the ones a program tripped over.
|
||||
|
||||
`symlink()` joined them later, and for the same reason: `symlink()` is the
|
||||
classic atomic lock — create the link, and if it exists someone else holds it —
|
||||
so zsh distinguishes `EEXIST` ("contended, retry") from anything else ("this
|
||||
cannot work, give up"). A bare `-1` is `-EPERM`, which put every shell on the
|
||||
machine in the second bucket on a filesystem that supports symlinks perfectly
|
||||
well, and printed the first line above on every boot.
|
||||
|
||||
### rename() replaces its target, and why it did not
|
||||
|
||||
`rename()` used to refuse an existing destination, which broke
|
||||
write-to-a-temporary-then-rename — how every careful program saves a file, and
|
||||
how zsh saves history. The second save of anything failed with `EEXIST` and left
|
||||
a `.zsh_history.new` behind for ever.
|
||||
|
||||
The first attempt to fix it was reverted after it lost both files, and the note
|
||||
left behind blamed HTree slot reuse. That was the wrong suspect. **`sys_rename`
|
||||
was removing the old name by hand** — `memset` a `struct dirent` to zero and
|
||||
`writei()` it into the parent directory at the offset `dirlookup` returned. That
|
||||
is the v1 filesystem's on-disk layout, written directly, bypassing `dp->ops`.
|
||||
On SchFS a directory is a B-tree with an index over it: "the record at that
|
||||
offset" is not a thing, and writing zeroes there corrupted the directory while
|
||||
leaving the name in the index. Adding a second removal on top of that is what
|
||||
lost both files.
|
||||
|
||||
Every other caller in the file already used `dirunlink()`. This one had been
|
||||
open-coding a filesystem it no longer ran on, and the bug was invisible while
|
||||
nothing asked it to remove two names instead of one.
|
||||
|
||||
The lock discipline is the delicate part and is inherited rather than invented:
|
||||
`dp_old` and `dp_new` are frequently the *same* directory reached through two
|
||||
`nameiparent()` calls, which is two references to one inode. No two directory
|
||||
locks are ever held at once, and the source inode stays unlocked throughout,
|
||||
held alive by an `nlink` bump.
|
||||
|
||||
Renaming a *directory* is still refused with `EISDIR`, and so is replacing one:
|
||||
both need the `..` fixup and the ancestor-loop check that are not written.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- **`rename()` will not replace an existing target.** POSIX says it must, and
|
||||
write-to-a-temporary-then-rename is how every careful program saves a file —
|
||||
so the second save of anything fails with `EEXIST` where it should succeed.
|
||||
It is the reason `zsh: can't rename /root/.zsh_history.new to $HISTFILE`
|
||||
appears on every shell exit after the first.
|
||||
|
||||
An attempt to fix it by unlinking the target first was **reverted**: the
|
||||
directory is an HTree, `dirunlink` followed by `dirlink` of the same name did
|
||||
not behave, and the failure mode was the worst possible one — the old file
|
||||
removed and the new link then refused, losing both. Doing this properly means
|
||||
understanding the HTree's slot reuse, not patching around it.
|
||||
|
||||
|
||||
- **No page cache.** `mmap` of a file reads the whole thing in at map time, and
|
||||
writable `MAP_SHARED` of a file is refused rather than faked. This is the
|
||||
single largest gap in the layer.
|
||||
- **No symlinks in procfs**, which is why `/proc/<pid>/exe` is a regular file
|
||||
and `ps -o comm` prints `[init]`.
|
||||
- **Symlinks resolve on the final path component only.** `open()`, `stat()` and
|
||||
friends follow a link they are pointed at; a link in the *middle* of a path —
|
||||
`/a/link/b` — is not resolved. That belongs in `namex()`, which would have to
|
||||
re-enter path lookup while holding a directory lock, and getting that wrong is
|
||||
a deadlock rather than a wrong answer.
|
||||
- **`rename()` does not move directories**, and will not replace one. `EISDIR`
|
||||
either way.
|
||||
- **No `AF_UNIX` socket inodes.** Socket names live in a table in
|
||||
`kernel/net/unix.c`, so `bind()` creates nothing that can be `stat()`ed or
|
||||
`unlink()`ed.
|
||||
|
|
|
|||
Loading…
Reference in a new issue