fakeroot 2.1.4 deadlocks when removing empty directories on Btrfs using
the SysV IPC backend. The reproducer below blocks on the 32nd removal.
Tested on Arch Linux x86_64 with Linux 7.2.14-arch1-1, using fakeroot
1:2.1.4-1 (affected) and 1:1.37.2-3 (working). Empty directories report
st_nlink == 1 on the Btrfs filesystem and st_nlink == 2 on tmpfs.
Save the following as `repro.c`:
```c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char **argv)
{
if (argc != 3)
return 2;
for (int i = 0; i < 32; ++i) {
char path[4096];
snprintf(path, sizeof(path), "%s/%d", argv[2], i);
int result;
if (strcmp(argv[1], "prepare") == 0)
result = mkdir(path, 0700);
else if (strcmp(argv[1], "unlinkat") == 0)
result = unlinkat(AT_FDCWD, path, AT_REMOVEDIR);
else
result = rmdir(path);
if (result != 0) {
fprintf(stderr, "%s: %s\n", path, strerror(errno));
return 1;
}
if (strcmp(argv[1], "prepare") != 0)
fprintf(stderr, "removed %d\n", i + 1);
}
return 0;
}
```
Compile and run from a directory on Btrfs:
```sh
cc -O0 -g -Wall -Wextra -o repro repro.c
repro_dir=$(mktemp -d ./fakeroot-rmdir.XXXXXX)
./repro prepare "$repro_dir"
stat -f -c '%T' "$repro_dir" # btrfs
stat -c '%h' "$repro_dir/0" # 1
fakeroot -- timeout --kill-after=2s 8s ./repro rmdir "$repro_dir"
```
Expected: all 32 removals complete and the command exits successfully.
Actual with 2.1.4: the program prints "removed 1" through "removed 31",
then blocks inside the 32nd rmdir() call. timeout exits with status 124.
Using fresh directories and selecting unlinkat instead of rmdir reproduces
the same failure:
```sh
repro_dir=$(mktemp -d ./fakeroot-unlinkat.XXXXXX)
./repro prepare "$repro_dir"
fakeroot -- timeout --kill-after=2s 8s ./repro unlinkat "$repro_dir"
```
Results:
| fakeroot | Filesystem | Operation | Result |
|---|---|---|---|
| 1.37.2 | Btrfs | rmdir | All 32 removals complete |
| 2.1.4 | Btrfs | rmdir | Blocks on call 32 |
| 2.1.4 | Btrfs | unlinkat with AT_REMOVEDIR | Blocks on call 32 |
| 2.1.4 | tmpfs | rmdir | All 32 removals complete |
While the reproducer is blocked, both it and faked are sleeping in
do_msgsnd. Both IPC queues contain 15 messages totaling 16,380 bytes,
with kernel.msgmnb = 16384. The reply queue has lrpid = 0: no process has
read from it.
In 2.1.4, faked.c:process_unlink() (line 885) sends an acknowledgement when:
```c
(buf->st.nlink == 1) ||
(S_ISDIR(buf->st.mode) && buf->st.nlink == 2)
```
However, libfakeroot.c:rmdir() (line 1385) waits for that acknowledgement
only when the target is a directory with st_nlink == 2. The AT_REMOVEDIR
branch of unlinkat() (line 1359) uses the same condition. For directories
with st_nlink == 1, both wrappers send an asynchronous request, but the
daemon sends a reply anyway.
Unconsumed replies fill the reply queue and block the daemon. The client
then fills the request queue and blocks too. The client and daemon need
matching conditions for whether a removal requires an acknowledgement,
including directories with link count 1. The existing remove() wrapper
already handles both link-count cases.
Source history points to commit 0af11b281beb2aa83e07b46cfbf93a5c4ce4f91c,
"Avoid potential unlink-related race conditions", first included in 2.1.1:
https://salsa.debian.org/clint/fakeroot/-/commit/0af11b281beb2aa83e07b46cfbf93a5c4ce4f91c